Skip to content

Commit 216083e

Browse files
kennethphoughclaude
andcommitted
test: McpSiteToolsTest (create/read/update/delete + account isolation)
Row-level coverage for the new SiteTools — create_site lands status="creating", delete_site flips to "deleting" (no AWS; the worker does provisioning), update_site writes settings, read_site reads back. Cross-account isolation asserted on all four (foreign application_id / site_id rejected; invalid region rejected; new site stamped with the caller account). Wired into phpunit.xml.dist so it runs in CI (per the #288 lesson — add the test WITH the feature). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef91905 commit 216083e

2 files changed

Lines changed: 198 additions & 0 deletions

File tree

phpunit.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<file>tests/McpPageToolsTest.php</file>
2323
<file>tests/McpScopeTest.php</file>
2424
<file>tests/McpSensitivityTest.php</file>
25+
<file>tests/McpSiteToolsTest.php</file>
2526
<file>tests/McpTokenControllerTest.php</file>
2627
<file>tests/McpTokenStrategyTest.php</file>
2728
<file>tests/ModelTest.php</file>

tests/McpSiteToolsTest.php

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<?php
2+
namespace Kyte\Test;
3+
4+
use Kyte\Core\Api;
5+
use Kyte\Mcp\Tools\SiteTools;
6+
use PHPUnit\Framework\TestCase;
7+
8+
/**
9+
* Unit tests for SiteTools (create_site / read_site / update_site / delete_site,
10+
* the MCP `provision`-scope site lifecycle tools).
11+
*
12+
* These exercise the tools at the row level only — the actual AWS provisioning
13+
* runs in SiteProvisioningWorker, so here `create_site` lands a KyteSite in
14+
* status "creating" and `delete_site` flips it to "deleting"; no S3/CloudFront.
15+
*
16+
* The security property under test is account isolation: every caller-supplied
17+
* application_id / site_id must be re-scoped to the token's account, so a token
18+
* cannot create under, read, update, or delete another account's site.
19+
*/
20+
class McpSiteToolsTest extends TestCase
21+
{
22+
private const OWN_ACCOUNT = 'mcp-site-test-own';
23+
private const OTHER_ACCOUNT = 'mcp-site-test-other';
24+
25+
private Api $api;
26+
private SiteTools $tools;
27+
private int $ownAccountId;
28+
private int $otherAccountId;
29+
private int $ownAppId;
30+
private int $otherAppId;
31+
private int $otherSiteId;
32+
33+
protected function setUp(): void
34+
{
35+
\Kyte\Core\DBI::dbInit(KYTE_DB_USERNAME, KYTE_DB_PASSWORD, KYTE_DB_HOST, KYTE_DB_DATABASE, KYTE_DB_CHARSET, 'InnoDB');
36+
37+
$this->api = new Api();
38+
39+
// KyteSite + the child tables KyteSiteController::delete() cascades into.
40+
foreach ([
41+
KyteAccount, Application, KyteSite,
42+
KytePage, KytePageData, Media,
43+
Navigation, NavigationItem, SideNav, SideNavItem,
44+
KyteSectionTemplate, KyteLibrary, KyteScript,
45+
] as $model) {
46+
\Kyte\Core\DBI::createTable($model);
47+
}
48+
49+
\Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number IN ('" . self::OWN_ACCOUNT . "','" . self::OTHER_ACCOUNT . "')");
50+
\Kyte\Core\DBI::query("DELETE FROM `Application` WHERE identifier LIKE 'mcp-site-test-%'");
51+
\Kyte\Core\DBI::query("DELETE FROM `KyteSite` WHERE name LIKE 'McpSiteTest%'");
52+
53+
$this->ownAccountId = $this->createAccount(self::OWN_ACCOUNT, 'Own');
54+
$this->otherAccountId = $this->createAccount(self::OTHER_ACCOUNT, 'Other');
55+
56+
$this->ownAppId = $this->createApp('mcp-site-test-own', $this->ownAccountId);
57+
$this->otherAppId = $this->createApp('mcp-site-test-other', $this->otherAccountId);
58+
59+
// A site owned by the OTHER account, for cross-account rejection tests.
60+
$this->otherSiteId = $this->createSite('McpSiteTestOther', $this->otherAppId, $this->otherAccountId);
61+
62+
$this->api->account = new \Kyte\Core\ModelObject(KyteAccount);
63+
$this->api->account->retrieve('id', $this->ownAccountId);
64+
$this->api->mcpScopes = ['read', 'provision'];
65+
66+
$this->tools = new SiteTools($this->api);
67+
68+
$_SERVER = ['REMOTE_ADDR' => '127.0.0.1'];
69+
}
70+
71+
public function testCreateSiteLandsInCreatingStatus(): void
72+
{
73+
$result = $this->tools->createSite($this->ownAppId, 'McpSiteTestNew', 'us-east-1');
74+
75+
$this->assertTrue($result['created'], $result['error'] ?? 'create failed');
76+
$this->assertSame('McpSiteTestNew', $result['site']['name']);
77+
$this->assertSame('creating', $result['site']['status'], 'a new site starts at "creating" for the worker to pick up');
78+
$this->assertSame('us-east-1', $result['site']['region']);
79+
}
80+
81+
public function testCreateSiteScopesToCallerAccount(): void
82+
{
83+
$result = $this->tools->createSite($this->ownAppId, 'McpSiteTestScoped', 'us-east-2');
84+
$this->assertTrue($result['created']);
85+
86+
$site = new \Kyte\Core\ModelObject(KyteSite);
87+
$site->retrieve('id', (int)$result['site']['id']);
88+
$this->assertSame($this->ownAccountId, (int)$site->kyte_account, 'new site must be stamped with the caller account');
89+
}
90+
91+
public function testCreateSiteRejectsForeignApplication(): void
92+
{
93+
$result = $this->tools->createSite($this->otherAppId, 'McpSiteTestEvil', 'us-east-1');
94+
$this->assertFalse($result['created'], 'cannot create a site under another account\'s application');
95+
}
96+
97+
public function testCreateSiteRejectsInvalidRegion(): void
98+
{
99+
$result = $this->tools->createSite($this->ownAppId, 'McpSiteTestBadRegion', 'moon-base-1');
100+
$this->assertFalse($result['created'], 'an invalid AWS region is rejected by the controller');
101+
}
102+
103+
public function testReadSiteReturnsOwnSite(): void
104+
{
105+
$created = $this->tools->createSite($this->ownAppId, 'McpSiteTestRead', 'us-east-1');
106+
$id = (int)$created['site']['id'];
107+
108+
$read = $this->tools->readSite($id);
109+
$this->assertNotNull($read['site']);
110+
$this->assertSame('McpSiteTestRead', $read['site']['name']);
111+
$this->assertSame('creating', $read['site']['status']);
112+
}
113+
114+
public function testReadSiteRejectsForeignSite(): void
115+
{
116+
$this->assertNull($this->tools->readSite($this->otherSiteId)['site'], 'a foreign site_id must not be readable');
117+
}
118+
119+
public function testUpdateSiteUpdatesSettings(): void
120+
{
121+
$created = $this->tools->createSite($this->ownAppId, 'McpSiteTestUpd', 'us-east-1');
122+
$id = (int)$created['site']['id'];
123+
124+
$result = $this->tools->updateSite($id, description: 'A demo site', default_lang: 'en', ga_code: 'G-TEST', gtm_code: null);
125+
$this->assertTrue($result['updated'], $result['error'] ?? 'update failed');
126+
$this->assertSame('A demo site', $result['site']['description']);
127+
$this->assertSame('en', $result['site']['default_lang']);
128+
$this->assertSame('G-TEST', $result['site']['ga_code']);
129+
}
130+
131+
public function testUpdateSiteRejectsForeignSite(): void
132+
{
133+
$result = $this->tools->updateSite($this->otherSiteId, description: 'hijack');
134+
$this->assertFalse($result['updated'], 'cannot update a foreign site');
135+
}
136+
137+
public function testUpdateSiteRejectsEmptyPayload(): void
138+
{
139+
$created = $this->tools->createSite($this->ownAppId, 'McpSiteTestNoop', 'us-east-1');
140+
$result = $this->tools->updateSite((int)$created['site']['id']);
141+
$this->assertFalse($result['updated'], 'no updatable fields provided');
142+
}
143+
144+
public function testDeleteSiteFlipsToDeleting(): void
145+
{
146+
$created = $this->tools->createSite($this->ownAppId, 'McpSiteTestDel', 'us-east-1');
147+
$id = (int)$created['site']['id'];
148+
149+
$result = $this->tools->deleteSite($id);
150+
$this->assertTrue($result['deleting'], $result['error'] ?? 'delete failed');
151+
152+
// The worker hasn't run; the row should now be flagged for teardown.
153+
$read = $this->tools->readSite($id);
154+
$this->assertSame('deleting', $read['site']['status']);
155+
}
156+
157+
public function testDeleteSiteRejectsForeignSite(): void
158+
{
159+
$result = $this->tools->deleteSite($this->otherSiteId);
160+
$this->assertFalse($result['deleting'], 'cannot delete a foreign site');
161+
162+
// And the foreign site is untouched (still active, not "deleting").
163+
$other = new \Kyte\Core\ModelObject(KyteSite);
164+
$other->retrieve('id', $this->otherSiteId);
165+
$this->assertSame('active', (string)$other->status);
166+
}
167+
168+
private function createAccount(string $number, string $name): int
169+
{
170+
$obj = new \Kyte\Core\ModelObject(KyteAccount);
171+
$obj->create(['number' => $number, 'name' => $name]);
172+
return (int)$obj->id;
173+
}
174+
175+
private function createApp(string $identifier, int $accountId): int
176+
{
177+
$obj = new \Kyte\Core\ModelObject(Application);
178+
$obj->create([
179+
'name' => 'App ' . $identifier,
180+
'identifier' => $identifier,
181+
'kyte_account' => $accountId,
182+
]);
183+
return (int)$obj->id;
184+
}
185+
186+
private function createSite(string $name, int $appId, int $accountId): int
187+
{
188+
$obj = new \Kyte\Core\ModelObject(KyteSite);
189+
$obj->create([
190+
'name' => $name,
191+
'status' => 'active',
192+
'application' => $appId,
193+
'kyte_account' => $accountId,
194+
]);
195+
return (int)$obj->id;
196+
}
197+
}

0 commit comments

Comments
 (0)