Skip to content

Commit 42623cc

Browse files
mowens3claude
andcommitted
Add PHP 8.4 support and comprehensive test suite
- Add PHP 8.4 to CI matrix - Add psr/http-factory to dev dependencies - Add 142 unit tests covering all components: - ApiKey value object tests - ApiKeyManager tests (100% coverage) - Storage tests (Array, File, PDO) - IpValidator tests with IPv4/IPv6 CIDR - OriginValidator tests - RequestValidator tests - SecurityMiddleware tests (100% coverage) - SecurityConfig tests - Exception tests - SystemClock tests - Achieve 92.84% line coverage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a03f27b commit 42623cc

File tree

14 files changed

+1649
-4
lines changed

14 files changed

+1649
-4
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
php: ['8.1', '8.2', '8.3']
14+
php: ['8.1', '8.2', '8.3', '8.4']
1515

1616
steps:
1717
- uses: actions/checkout@v4
@@ -42,7 +42,7 @@ jobs:
4242
run: vendor/bin/phpunit --coverage-clover coverage.xml
4343

4444
- name: Upload coverage to Codecov
45-
if: matrix.php == '8.3'
45+
if: matrix.php == '8.4'
4646
uses: codecov/codecov-action@v4
4747
with:
4848
files: coverage.xml
@@ -58,7 +58,7 @@ jobs:
5858
- name: Setup PHP
5959
uses: shivammathur/setup-php@v2
6060
with:
61-
php-version: '8.3'
61+
php-version: '8.4'
6262

6363
- name: Install dependencies
6464
run: composer install --prefer-dist --no-progress

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
},
2727
"require-dev": {
2828
"phpunit/phpunit": "^10.0 || ^11.0",
29-
"phpstan/phpstan": "^1.10"
29+
"phpstan/phpstan": "^1.10",
30+
"psr/http-factory": "^1.0"
3031
},
3132
"autoload": {
3233
"psr-4": {

tests/ApiKey/ApiKeyManagerTest.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,127 @@ public function testApiKeyHasAllScopes(): void
136136
$this->assertTrue($apiKey->hasAllScopes(['read', 'write']));
137137
$this->assertFalse($apiKey->hasAllScopes(['read', 'admin']));
138138
}
139+
140+
public function testCreateKeyWithEmptyLabelUsesDefault(): void
141+
{
142+
$result = $this->manager->createKey('', ['read']);
143+
$apiKey = $this->manager->getKey($result['key_id']);
144+
145+
$this->assertSame('Unnamed key', $apiKey->label);
146+
}
147+
148+
public function testCreateKeyWithWhitespaceOnlyLabelUsesDefault(): void
149+
{
150+
$result = $this->manager->createKey(' ', ['read']);
151+
$apiKey = $this->manager->getKey($result['key_id']);
152+
153+
$this->assertSame('Unnamed key', $apiKey->label);
154+
}
155+
156+
public function testValidateReturnsNullForWrongPrefix(): void
157+
{
158+
$result = $this->manager->createKey('Test', ['read']);
159+
$apiKey = $result['api_key'];
160+
161+
// Change prefix from 'mcp' to 'wrong'
162+
$wrongPrefix = str_replace('mcp.', 'wrong.', $apiKey);
163+
164+
$this->assertNull($this->manager->validate($wrongPrefix));
165+
}
166+
167+
public function testValidateReturnsNullForEmptyKeyId(): void
168+
{
169+
$this->assertNull($this->manager->validate('mcp..secret'));
170+
}
171+
172+
public function testValidateReturnsNullForEmptySecret(): void
173+
{
174+
$this->assertNull($this->manager->validate('mcp.keyid.'));
175+
}
176+
177+
public function testValidateReturnsNullForWrongSecret(): void
178+
{
179+
$result = $this->manager->createKey('Test', ['read']);
180+
$keyId = $result['key_id'];
181+
182+
// Use correct prefix and keyId but wrong secret
183+
$wrongSecret = "mcp.{$keyId}.wrongsecret";
184+
185+
$this->assertNull($this->manager->validate($wrongSecret));
186+
}
187+
188+
public function testValidateReturnsNullForMissingHash(): void
189+
{
190+
// Manually add a record without hash
191+
$this->storage->set('nohash', [
192+
'label' => 'No Hash',
193+
'scopes' => ['read'],
194+
'created' => time(),
195+
]);
196+
197+
$this->assertNull($this->manager->validate('mcp.nohash.anysecret'));
198+
}
199+
200+
public function testListKeysSortsById(): void
201+
{
202+
// Create keys - they will have random IDs
203+
$this->manager->createKey('Key A', ['read']);
204+
$this->manager->createKey('Key B', ['write']);
205+
$this->manager->createKey('Key C', ['admin']);
206+
207+
$keys = $this->manager->listKeys();
208+
209+
// Verify keys are sorted by ID
210+
$keyIds = array_keys($keys);
211+
$sortedKeyIds = $keyIds;
212+
sort($sortedKeyIds);
213+
214+
$this->assertSame($sortedKeyIds, $keyIds);
215+
}
216+
217+
public function testGetKeyReturnsNullForNonexistentKey(): void
218+
{
219+
$this->assertNull($this->manager->getKey('nonexistent'));
220+
}
221+
222+
public function testCreateKeyDeduplicatesScopes(): void
223+
{
224+
$result = $this->manager->createKey('Test', ['read', 'write', 'read', 'admin', 'write']);
225+
$apiKey = $this->manager->getKey($result['key_id']);
226+
227+
$this->assertCount(3, $apiKey->scopes);
228+
$this->assertTrue($apiKey->hasScope('read'));
229+
$this->assertTrue($apiKey->hasScope('write'));
230+
$this->assertTrue($apiKey->hasScope('admin'));
231+
}
232+
233+
public function testCustomPrefixIsUsed(): void
234+
{
235+
$manager = new ApiKeyManager(
236+
storage: $this->storage,
237+
clock: $this->clock,
238+
pepper: 'test',
239+
keyPrefix: 'custom',
240+
);
241+
242+
$result = $manager->createKey('Test', ['read']);
243+
244+
$this->assertStringStartsWith('custom.', $result['api_key']);
245+
246+
// Should validate with the custom prefix
247+
$apiKey = $manager->validate($result['api_key']);
248+
$this->assertNotNull($apiKey);
249+
}
250+
251+
public function testValidateTrimsWhitespace(): void
252+
{
253+
$result = $this->manager->createKey('Test', ['read']);
254+
255+
// Add whitespace around the key
256+
$apiKey = $this->manager->validate(" {$result['api_key']} ");
257+
258+
$this->assertNotNull($apiKey);
259+
}
139260
}
140261

141262
/**

tests/ApiKey/ApiKeyTest.php

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CodeWheel\McpSecurity\Tests\ApiKey;
6+
7+
use CodeWheel\McpSecurity\ApiKey\ApiKey;
8+
use PHPUnit\Framework\TestCase;
9+
10+
final class ApiKeyTest extends TestCase
11+
{
12+
public function testConstructorSetsAllProperties(): void
13+
{
14+
$apiKey = new ApiKey(
15+
keyId: 'abc123',
16+
label: 'Test Key',
17+
scopes: ['read', 'write'],
18+
created: 1000000,
19+
lastUsed: 2000000,
20+
expires: 3000000,
21+
);
22+
23+
$this->assertSame('abc123', $apiKey->keyId);
24+
$this->assertSame('Test Key', $apiKey->label);
25+
$this->assertSame(['read', 'write'], $apiKey->scopes);
26+
$this->assertSame(1000000, $apiKey->created);
27+
$this->assertSame(2000000, $apiKey->lastUsed);
28+
$this->assertSame(3000000, $apiKey->expires);
29+
}
30+
31+
public function testConstructorWithOptionalDefaults(): void
32+
{
33+
$apiKey = new ApiKey(
34+
keyId: 'abc123',
35+
label: 'Test Key',
36+
scopes: ['read'],
37+
created: 1000000,
38+
);
39+
40+
$this->assertNull($apiKey->lastUsed);
41+
$this->assertNull($apiKey->expires);
42+
}
43+
44+
public function testHasScopeReturnsTrueForExistingScope(): void
45+
{
46+
$apiKey = new ApiKey('id', 'label', ['read', 'write', 'admin'], 0);
47+
48+
$this->assertTrue($apiKey->hasScope('read'));
49+
$this->assertTrue($apiKey->hasScope('write'));
50+
$this->assertTrue($apiKey->hasScope('admin'));
51+
}
52+
53+
public function testHasScopeReturnsFalseForMissingScope(): void
54+
{
55+
$apiKey = new ApiKey('id', 'label', ['read'], 0);
56+
57+
$this->assertFalse($apiKey->hasScope('write'));
58+
$this->assertFalse($apiKey->hasScope('admin'));
59+
$this->assertFalse($apiKey->hasScope(''));
60+
}
61+
62+
public function testHasAnyScopeReturnsTrueWhenAnyMatch(): void
63+
{
64+
$apiKey = new ApiKey('id', 'label', ['read', 'write'], 0);
65+
66+
$this->assertTrue($apiKey->hasAnyScope(['read']));
67+
$this->assertTrue($apiKey->hasAnyScope(['write']));
68+
$this->assertTrue($apiKey->hasAnyScope(['read', 'admin']));
69+
$this->assertTrue($apiKey->hasAnyScope(['admin', 'write', 'other']));
70+
}
71+
72+
public function testHasAnyScopeReturnsFalseWhenNoneMatch(): void
73+
{
74+
$apiKey = new ApiKey('id', 'label', ['read'], 0);
75+
76+
$this->assertFalse($apiKey->hasAnyScope(['write', 'admin']));
77+
$this->assertFalse($apiKey->hasAnyScope([]));
78+
}
79+
80+
public function testHasAllScopesReturnsTrueWhenAllPresent(): void
81+
{
82+
$apiKey = new ApiKey('id', 'label', ['read', 'write', 'admin'], 0);
83+
84+
$this->assertTrue($apiKey->hasAllScopes(['read']));
85+
$this->assertTrue($apiKey->hasAllScopes(['read', 'write']));
86+
$this->assertTrue($apiKey->hasAllScopes(['read', 'write', 'admin']));
87+
$this->assertTrue($apiKey->hasAllScopes([]));
88+
}
89+
90+
public function testHasAllScopesReturnsFalseWhenSomeMissing(): void
91+
{
92+
$apiKey = new ApiKey('id', 'label', ['read', 'write'], 0);
93+
94+
$this->assertFalse($apiKey->hasAllScopes(['admin']));
95+
$this->assertFalse($apiKey->hasAllScopes(['read', 'admin']));
96+
$this->assertFalse($apiKey->hasAllScopes(['read', 'write', 'admin']));
97+
}
98+
99+
public function testIsExpiredReturnsFalseWhenNoExpiry(): void
100+
{
101+
$apiKey = new ApiKey('id', 'label', [], 0, expires: null);
102+
103+
$this->assertFalse($apiKey->isExpired(PHP_INT_MAX));
104+
$this->assertFalse($apiKey->isExpired(0));
105+
}
106+
107+
public function testIsExpiredReturnsFalseWhenNotExpired(): void
108+
{
109+
$apiKey = new ApiKey('id', 'label', [], 0, expires: 1000);
110+
111+
$this->assertFalse($apiKey->isExpired(999));
112+
$this->assertFalse($apiKey->isExpired(0));
113+
}
114+
115+
public function testIsExpiredReturnsTrueWhenExpired(): void
116+
{
117+
$apiKey = new ApiKey('id', 'label', [], 0, expires: 1000);
118+
119+
$this->assertTrue($apiKey->isExpired(1001));
120+
$this->assertTrue($apiKey->isExpired(2000));
121+
}
122+
123+
public function testIsExpiredReturnsTrueWhenExactlyExpired(): void
124+
{
125+
$apiKey = new ApiKey('id', 'label', [], 0, expires: 1000);
126+
127+
// Expires at 1000, so at time 1000 it's expired (< not <=)
128+
$this->assertFalse($apiKey->isExpired(1000));
129+
}
130+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CodeWheel\McpSecurity\Tests\ApiKey\Storage;
6+
7+
use CodeWheel\McpSecurity\ApiKey\Storage\ArrayStorage;
8+
use PHPUnit\Framework\TestCase;
9+
10+
final class ArrayStorageTest extends TestCase
11+
{
12+
public function testGetAllReturnsEmptyArrayByDefault(): void
13+
{
14+
$storage = new ArrayStorage();
15+
16+
$this->assertSame([], $storage->getAll());
17+
}
18+
19+
public function testGetAllReturnsInitialKeys(): void
20+
{
21+
$initial = [
22+
'key1' => ['label' => 'Key 1'],
23+
'key2' => ['label' => 'Key 2'],
24+
];
25+
$storage = new ArrayStorage($initial);
26+
27+
$this->assertSame($initial, $storage->getAll());
28+
}
29+
30+
public function testSetAllReplacesAllKeys(): void
31+
{
32+
$storage = new ArrayStorage(['old' => ['label' => 'Old']]);
33+
34+
$new = ['new' => ['label' => 'New']];
35+
$storage->setAll($new);
36+
37+
$this->assertSame($new, $storage->getAll());
38+
}
39+
40+
public function testGetReturnsNullForMissingKey(): void
41+
{
42+
$storage = new ArrayStorage();
43+
44+
$this->assertNull($storage->get('nonexistent'));
45+
}
46+
47+
public function testGetReturnsKeyData(): void
48+
{
49+
$storage = new ArrayStorage([
50+
'test' => ['label' => 'Test', 'scopes' => ['read']],
51+
]);
52+
53+
$this->assertSame(['label' => 'Test', 'scopes' => ['read']], $storage->get('test'));
54+
}
55+
56+
public function testSetAddsNewKey(): void
57+
{
58+
$storage = new ArrayStorage();
59+
60+
$storage->set('new', ['label' => 'New Key']);
61+
62+
$this->assertSame(['label' => 'New Key'], $storage->get('new'));
63+
}
64+
65+
public function testSetUpdatesExistingKey(): void
66+
{
67+
$storage = new ArrayStorage(['existing' => ['label' => 'Old']]);
68+
69+
$storage->set('existing', ['label' => 'Updated']);
70+
71+
$this->assertSame(['label' => 'Updated'], $storage->get('existing'));
72+
}
73+
74+
public function testDeleteReturnsFalseForMissingKey(): void
75+
{
76+
$storage = new ArrayStorage();
77+
78+
$this->assertFalse($storage->delete('nonexistent'));
79+
}
80+
81+
public function testDeleteReturnsTrueAndRemovesKey(): void
82+
{
83+
$storage = new ArrayStorage(['test' => ['label' => 'Test']]);
84+
85+
$this->assertTrue($storage->delete('test'));
86+
$this->assertNull($storage->get('test'));
87+
}
88+
89+
public function testDeleteDoesNotAffectOtherKeys(): void
90+
{
91+
$storage = new ArrayStorage([
92+
'keep' => ['label' => 'Keep'],
93+
'delete' => ['label' => 'Delete'],
94+
]);
95+
96+
$storage->delete('delete');
97+
98+
$this->assertSame(['label' => 'Keep'], $storage->get('keep'));
99+
}
100+
}

0 commit comments

Comments
 (0)