test(scopes): add comprehensive API scope enforcement tests (P2-008)
- Implement wildcard scope matching in ApiKey::hasScope(): - Resource wildcards (posts:*) grant all actions on resource - Action wildcards (*:read) grant action on all resources - Full wildcard (*) grants universal access - Add hasAnyScope() method for OR-style scope checking - Add extensive tests for: - EnforceApiScope middleware validation - CheckApiScope middleware with explicit requirements - Wildcard scope matching (posts:*, *:read, *) - Scope inheritance and hierarchy - Error response formatting with required/provided scopes - Edge cases (null scopes, case sensitivity, nested colons) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
49c862b6c1
commit
97d0b32ed5
3 changed files with 577 additions and 14 deletions
30
TODO.md
30
TODO.md
|
|
@ -23,20 +23,22 @@
|
||||||
- **Completed:** 29 January 2026
|
- **Completed:** 29 January 2026
|
||||||
- **File:** `src/Api/Tests/Feature/WebhookDeliveryTest.php`
|
- **File:** `src/Api/Tests/Feature/WebhookDeliveryTest.php`
|
||||||
|
|
||||||
- [ ] **Test Coverage: Rate Limiting** - Test tier-based limits
|
- [x] **Test Coverage: Rate Limiting** - Test tier-based limits
|
||||||
- [ ] Test per-tier rate limits
|
- [x] Test per-tier rate limits
|
||||||
- [ ] Test rate limit headers
|
- [x] Test rate limit headers
|
||||||
- [ ] Test quota exceeded responses
|
- [x] Test quota exceeded responses
|
||||||
- [ ] Test workspace-scoped limits
|
- [x] Test workspace-scoped limits
|
||||||
- [ ] Test burst allowance
|
- [x] Test burst allowance
|
||||||
- **Estimated effort:** 3-4 hours
|
- **Completed:** 29 January 2026
|
||||||
|
- **File:** `src/Api/Tests/Feature/RateLimitingTest.php`
|
||||||
|
|
||||||
- [ ] **Test Coverage: Scope Enforcement** - Test permission system
|
- [x] **Test Coverage: Scope Enforcement** - Test permission system
|
||||||
- [ ] Test EnforceApiScope middleware
|
- [x] Test EnforceApiScope middleware
|
||||||
- [ ] Test wildcard scopes (posts:*, *:read)
|
- [x] Test wildcard scopes (posts:*, *:read)
|
||||||
- [ ] Test scope inheritance
|
- [x] Test scope inheritance
|
||||||
- [ ] Test scope validation errors
|
- [x] Test scope validation errors
|
||||||
- **Estimated effort:** 3-4 hours
|
- **Completed:** 29 January 2026
|
||||||
|
- **File:** `src/Api/Tests/Feature/ApiScopeEnforcementTest.php`
|
||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
|
|
||||||
|
|
@ -251,5 +253,7 @@
|
||||||
- [x] **API Key Security Tests** - Comprehensive bcrypt hashing and rotation tests (P1-002)
|
- [x] **API Key Security Tests** - Comprehensive bcrypt hashing and rotation tests (P1-002)
|
||||||
- [x] **Webhook System Signature Tests** - HMAC-SHA256 signature verification tests (P1-003)
|
- [x] **Webhook System Signature Tests** - HMAC-SHA256 signature verification tests (P1-003)
|
||||||
- [x] **API Key IP Whitelisting** - allowed_ips column with IPv4/IPv6 and CIDR support (P1-004)
|
- [x] **API Key IP Whitelisting** - allowed_ips column with IPv4/IPv6 and CIDR support (P1-004)
|
||||||
|
- [x] **Scope Enforcement Tests** - Wildcard scopes, inheritance, and error responses (P2-008)
|
||||||
|
- [x] **Rate Limiting Tests** - Tier-based limits with headers and burst allowance
|
||||||
|
|
||||||
*See `changelog/2026/jan/` for completed features.*
|
*See `changelog/2026/jan/` for completed features.*
|
||||||
|
|
|
||||||
|
|
@ -266,10 +266,45 @@ class ApiKey extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if key has a specific scope.
|
* Check if key has a specific scope.
|
||||||
|
*
|
||||||
|
* Supports wildcard matching:
|
||||||
|
* - `posts:*` grants all actions on posts resource
|
||||||
|
* - `*:read` grants read action on all resources
|
||||||
|
* - `*` grants full access to everything
|
||||||
*/
|
*/
|
||||||
public function hasScope(string $scope): bool
|
public function hasScope(string $scope): bool
|
||||||
{
|
{
|
||||||
return in_array($scope, $this->scopes ?? [], true);
|
$scopes = $this->scopes ?? [];
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (in_array($scope, $scopes, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full wildcard (grants everything)
|
||||||
|
if (in_array('*', $scopes, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for resource:action pattern
|
||||||
|
if (! str_contains($scope, ':')) {
|
||||||
|
// Simple scope (read, write, delete) - no wildcard matching
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$resource, $action] = explode(':', $scope, 2);
|
||||||
|
|
||||||
|
// Resource wildcard (e.g., posts:* grants posts:read, posts:write, etc.)
|
||||||
|
if (in_array("{$resource}:*", $scopes, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action wildcard (e.g., *:read grants posts:read, users:read, etc.)
|
||||||
|
if (in_array("*:{$action}", $scopes, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -286,6 +321,20 @@ class ApiKey extends Model
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key has any of the specified scopes.
|
||||||
|
*/
|
||||||
|
public function hasAnyScope(array $scopes): bool
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
if ($this->hasScope($scope)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if key is expired.
|
* Check if key is expired.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -230,3 +230,513 @@ describe('Non-API Key Auth', function () {
|
||||||
// In practice, routes use either 'auth' OR 'api.auth', not both
|
// In practice, routes use either 'auth' OR 'api.auth', not both
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Wildcard Scopes - Resource Wildcards (posts:*)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resource Wildcard Scopes', function () {
|
||||||
|
it('grants access with resource wildcard scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Posts Admin Key',
|
||||||
|
['posts:*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Resource wildcard should grant all actions on that resource
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:write'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:delete'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:publish'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource wildcard does not grant access to other resources', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Posts Only Key',
|
||||||
|
['posts:*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Should not grant access to other resources
|
||||||
|
expect($apiKey->hasScope('users:read'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('analytics:read'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('webhooks:write'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple resource wildcards work independently', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Content Admin Key',
|
||||||
|
['posts:*', 'pages:*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Both resource wildcards should work
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:delete'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('pages:write'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('pages:publish'))->toBeTrue();
|
||||||
|
|
||||||
|
// Others should not
|
||||||
|
expect($apiKey->hasScope('users:read'))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Wildcard Scopes - Action Wildcards (*:read)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Action Wildcard Scopes', function () {
|
||||||
|
it('grants read access to all resources with action wildcard', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read Only All Key',
|
||||||
|
['*:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Action wildcard should grant read on all resources
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('users:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('analytics:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('webhooks:read'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('action wildcard does not grant other actions', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read Only All Key',
|
||||||
|
['*:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Should not grant write or delete
|
||||||
|
expect($apiKey->hasScope('posts:write'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('users:delete'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('webhooks:manage'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple action wildcards work independently', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read/Write All Key',
|
||||||
|
['*:read', '*:write']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Both action wildcards should work
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:write'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('users:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('users:write'))->toBeTrue();
|
||||||
|
|
||||||
|
// Delete should not be granted
|
||||||
|
expect($apiKey->hasScope('posts:delete'))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Wildcard Scopes - Full Wildcard (*)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Full Wildcard Scope', function () {
|
||||||
|
it('full wildcard grants access to everything', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'God Mode Key',
|
||||||
|
['*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Full wildcard should grant everything
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:write'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:delete'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('users:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('admin:system'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('any:thing'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full wildcard grants simple scopes', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'God Mode Key',
|
||||||
|
['*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Simple scopes should also be granted
|
||||||
|
expect($apiKey->hasScope('read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('write'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('delete'))->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Scope Inheritance and Hierarchy
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Scope Inheritance', function () {
|
||||||
|
it('exact scopes take precedence over wildcards', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Mixed Key',
|
||||||
|
['posts:read', 'posts:*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Both exact and wildcard should work
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:write'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combined resource and action wildcards cover different scopes', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Mixed Wildcards Key',
|
||||||
|
['posts:*', '*:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// posts:* should grant all post actions
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:write'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('posts:delete'))->toBeTrue();
|
||||||
|
|
||||||
|
// *:read should grant read on all resources
|
||||||
|
expect($apiKey->hasScope('users:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('analytics:read'))->toBeTrue();
|
||||||
|
|
||||||
|
// But not write on other resources
|
||||||
|
expect($apiKey->hasScope('users:write'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty scopes array denies all access', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'No Scopes Key',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
expect($apiKey->hasScope('read'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('*'))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// hasScopes and hasAnyScope Methods
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Multiple Scope Checking', function () {
|
||||||
|
it('hasScopes requires all scopes to be present', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Limited Key',
|
||||||
|
['posts:read', 'posts:write']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
expect($apiKey->hasScopes(['posts:read']))->toBeTrue();
|
||||||
|
expect($apiKey->hasScopes(['posts:read', 'posts:write']))->toBeTrue();
|
||||||
|
expect($apiKey->hasScopes(['posts:read', 'posts:delete']))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAnyScope requires at least one scope to be present', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Limited Key',
|
||||||
|
['posts:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
expect($apiKey->hasAnyScope(['posts:read']))->toBeTrue();
|
||||||
|
expect($apiKey->hasAnyScope(['posts:read', 'posts:write']))->toBeTrue();
|
||||||
|
expect($apiKey->hasAnyScope(['posts:delete', 'users:read']))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasScopes works with wildcards', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Posts Admin Key',
|
||||||
|
['posts:*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
expect($apiKey->hasScopes(['posts:read', 'posts:write']))->toBeTrue();
|
||||||
|
expect($apiKey->hasScopes(['posts:read', 'users:read']))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAnyScope works with wildcards', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read All Key',
|
||||||
|
['*:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
expect($apiKey->hasAnyScope(['posts:read', 'posts:write']))->toBeTrue();
|
||||||
|
expect($apiKey->hasAnyScope(['posts:write', 'users:write']))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CheckApiScope Middleware (Explicit Scope Requirements)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CheckApiScope Middleware', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
// Register routes with explicit scope requirements
|
||||||
|
Route::middleware(['api', 'api.auth', 'api.scope:posts:read'])
|
||||||
|
->get('/test-explicit/posts', fn () => response()->json(['status' => 'ok']));
|
||||||
|
|
||||||
|
Route::middleware(['api', 'api.auth', 'api.scope:posts:write'])
|
||||||
|
->post('/test-explicit/posts', fn () => response()->json(['status' => 'ok']));
|
||||||
|
|
||||||
|
Route::middleware(['api', 'api.auth', 'api.scope:posts:read,posts:write'])
|
||||||
|
->put('/test-explicit/posts', fn () => response()->json(['status' => 'ok']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows request with exact required scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Posts Reader Key',
|
||||||
|
['posts:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/test-explicit/posts', [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows request with wildcard matching required scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Posts Admin Key',
|
||||||
|
['posts:*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/test-explicit/posts', [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies request without required scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Users Only Key',
|
||||||
|
['users:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/test-explicit/posts', [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json('error'))->toBe('forbidden');
|
||||||
|
expect($response->json('message'))->toContain('posts:read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires all scopes when multiple specified', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Posts Reader Only Key',
|
||||||
|
['posts:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route requires both posts:read AND posts:write
|
||||||
|
$response = $this->putJson('/api/test-explicit/posts', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json('message'))->toContain('posts:write');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows all scopes with full wildcard', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Admin Key',
|
||||||
|
['*']
|
||||||
|
);
|
||||||
|
|
||||||
|
$headers = ['Authorization' => "Bearer {$result['plain_key']}"];
|
||||||
|
|
||||||
|
expect($this->getJson('/api/test-explicit/posts', $headers)->status())->toBe(200);
|
||||||
|
expect($this->postJson('/api/test-explicit/posts', [], $headers)->status())->toBe(200);
|
||||||
|
expect($this->putJson('/api/test-explicit/posts', [], $headers)->status())->toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Scope Denial Error Responses
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Scope Denial Error Responses', function () {
|
||||||
|
it('EnforceApiScope returns 403 with detailed error', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read Only Key',
|
||||||
|
[ApiKey::SCOPE_READ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/test-scope/write', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json())->toHaveKeys(['error', 'message', 'detail', 'key_scopes']);
|
||||||
|
expect($response->json('error'))->toBe('forbidden');
|
||||||
|
expect($response->json('detail'))->toContain('POST');
|
||||||
|
expect($response->json('detail'))->toContain('write');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CheckApiScope returns 403 with required scopes', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Wrong Scopes Key',
|
||||||
|
['users:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/test-explicit/posts', [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json())->toHaveKeys(['error', 'message', 'required_scopes', 'key_scopes']);
|
||||||
|
expect($response->json('required_scopes'))->toContain('posts:read');
|
||||||
|
expect($response->json('key_scopes'))->toBe(['users:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error response contains accurate key scopes', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Multi Scope Key',
|
||||||
|
['posts:read', 'users:read', 'analytics:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->deleteJson('/api/test-scope/delete', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json('key_scopes'))->toBe(['posts:read', 'users:read', 'analytics:read']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Edge Cases
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Edge Cases', function () {
|
||||||
|
it('handles null scopes array gracefully', function () {
|
||||||
|
// Directly create a key with null scopes (bypassing generate())
|
||||||
|
$apiKey = ApiKey::factory()
|
||||||
|
->for($this->workspace)
|
||||||
|
->for($this->user)
|
||||||
|
->create(['scopes' => null]);
|
||||||
|
|
||||||
|
expect($apiKey->hasScope('read'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeFalse();
|
||||||
|
expect($apiKey->hasAnyScope(['read']))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles scope with multiple colons', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Nested Scope Key',
|
||||||
|
['api:v2:posts:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
// Exact match should work
|
||||||
|
expect($apiKey->hasScope('api:v2:posts:read'))->toBeTrue();
|
||||||
|
|
||||||
|
// The first segment before colon is treated as resource
|
||||||
|
// Resource wildcard for 'api' should match
|
||||||
|
$result2 = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'API Wildcard Key',
|
||||||
|
['api:*']
|
||||||
|
);
|
||||||
|
|
||||||
|
// api:* should match api:v2:posts:read (treats v2:posts:read as action)
|
||||||
|
expect($result2['api_key']->hasScope('api:v2:posts:read'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Valid Key',
|
||||||
|
['posts:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
expect($apiKey->hasScope(''))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scope matching is case-sensitive', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Lowercase Key',
|
||||||
|
['posts:read']
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
expect($apiKey->hasScope('posts:read'))->toBeTrue();
|
||||||
|
expect($apiKey->hasScope('Posts:Read'))->toBeFalse();
|
||||||
|
expect($apiKey->hasScope('POSTS:READ'))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue