From e70e078bcb77e9e533f5359ed92284e93b1f14fc Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 18:42:56 +0000 Subject: [PATCH] refactor(tests): convert AgentApiKey tests to Pest functional syntax - Convert AgentApiKeyTest from PHPUnit class-based syntax to Pest functional syntax - Add tests/Pest.php configuration with helper functions (createWorkspace, createApiKey) - Organise tests using describe() blocks for better structure - Add additional test coverage for key rotation and security edge cases - Update TODO.md to reflect Pest syntax usage Co-Authored-By: Claude Opus 4.5 --- TODO.md | 6 +- tests/Feature/AgentApiKeyTest.php | 1186 ++++++++++++++--------------- tests/Pest.php | 74 ++ 3 files changed, 650 insertions(+), 616 deletions(-) create mode 100644 tests/Pest.php diff --git a/TODO.md b/TODO.md index de6a498..1b67664 100644 --- a/TODO.md +++ b/TODO.md @@ -53,9 +53,11 @@ Production-quality task list for the AI agent orchestration package. ### Test Coverage (Critical Gap) - [x] **TEST-001: Add AgentApiKey model tests** (FIXED 2026-01-29) - - Created `tests/Feature/AgentApiKeyTest.php` + - Created `tests/Feature/AgentApiKeyTest.php` using Pest functional syntax + - Created `tests/Pest.php` for Pest configuration with helper functions - Covers: key generation with Argon2id, validation, permissions, rate limiting, IP restrictions - - 65+ test cases for comprehensive model coverage + - Additional coverage: key rotation, security edge cases + - 70+ test cases for comprehensive model coverage - [x] **TEST-002: Add AgentApiKeyService tests** (FIXED 2026-01-29) - Created `tests/Feature/AgentApiKeyServiceTest.php` diff --git a/tests/Feature/AgentApiKeyTest.php b/tests/Feature/AgentApiKeyTest.php index d366027..357ccc1 100644 --- a/tests/Feature/AgentApiKeyTest.php +++ b/tests/Feature/AgentApiKeyTest.php @@ -2,202 +2,150 @@ declare(strict_types=1); -namespace Core\Mod\Agentic\Tests\Feature; - -use Carbon\Carbon; -use Core\Mod\Agentic\Models\AgentApiKey; -use Core\Tenant\Models\Workspace; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Cache; -use Tests\TestCase; - /** * Tests for the AgentApiKey model. * * Covers generation, validation, permissions, rate limiting, and IP restrictions. */ -class AgentApiKeyTest extends TestCase -{ - use RefreshDatabase; - private Workspace $workspace; +use Carbon\Carbon; +use Core\Mod\Agentic\Models\AgentApiKey; +use Core\Tenant\Models\Workspace; +use Illuminate\Support\Facades\Cache; - protected function setUp(): void - { - parent::setUp(); - $this->workspace = Workspace::factory()->create(); - } +// ========================================================================= +// Key Generation Tests +// ========================================================================= - // ========================================================================= - // Key Generation Tests - // ========================================================================= +describe('key generation', function () { + it('generates key with correct prefix', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - public function test_it_generates_key_with_correct_prefix(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + expect($key->plainTextKey) + ->toStartWith('ak_') + ->toHaveLength(35); // ak_ + 32 random chars + }); - $this->assertStringStartsWith('ak_', $key->plainTextKey); - $this->assertEquals(35, strlen($key->plainTextKey)); // ak_ + 32 random chars - } - - public function test_it_stores_hashed_key_with_argon2id(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('stores hashed key with Argon2id', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); // Argon2id hashes start with $argon2id$ - $this->assertStringStartsWith('$argon2id$', $key->key); - } + expect($key->key)->toStartWith('$argon2id$'); + }); - public function test_plaintext_key_is_only_available_once(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('makes plaintext key available only once after creation', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $plainKey = $key->plainTextKey; - $this->assertNotNull($plainKey); + expect($key->plainTextKey)->not->toBeNull(); // After fetching from database, plaintext should be null $freshKey = AgentApiKey::find($key->id); - $this->assertNull($freshKey->plainTextKey); - } - public function test_it_generates_key_with_workspace_id(): void - { - $key = AgentApiKey::generate( - $this->workspace->id, - 'Test Key' - ); + expect($freshKey->plainTextKey)->toBeNull(); + }); - $this->assertEquals($this->workspace->id, $key->workspace_id); - } + it('generates key with workspace ID', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace->id, 'Test Key'); - public function test_it_generates_key_with_workspace_model(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + expect($key->workspace_id)->toBe($workspace->id); + }); - $this->assertEquals($this->workspace->id, $key->workspace_id); - } + it('generates key with workspace model', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - public function test_it_generates_key_with_permissions(): void - { + expect($key->workspace_id)->toBe($workspace->id); + }); + + it('generates key with permissions', function () { + $workspace = createWorkspace(); $permissions = [ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, ]; - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - $permissions - ); + $key = AgentApiKey::generate($workspace, 'Test Key', $permissions); - $this->assertEquals($permissions, $key->permissions); - } + expect($key->permissions)->toBe($permissions); + }); - public function test_it_generates_key_with_custom_rate_limit(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 500 - ); + it('generates key with custom rate limit', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 500); - $this->assertEquals(500, $key->rate_limit); - } + expect($key->rate_limit)->toBe(500); + }); - public function test_it_generates_key_with_expiry(): void - { + it('generates key with expiry date', function () { + $workspace = createWorkspace(); $expiresAt = Carbon::now()->addDays(30); - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 100, - $expiresAt - ); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 100, $expiresAt); - $this->assertEquals($expiresAt->toDateTimeString(), $key->expires_at->toDateTimeString()); - } + expect($key->expires_at->toDateTimeString()) + ->toBe($expiresAt->toDateTimeString()); + }); - public function test_it_initialises_call_count_to_zero(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('initialises call count to zero', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertEquals(0, $key->call_count); - } + expect($key->call_count)->toBe(0); + }); +}); - // ========================================================================= - // Key Lookup Tests - // ========================================================================= +// ========================================================================= +// Key Lookup Tests +// ========================================================================= - public function test_find_by_key_returns_correct_key(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); +describe('key lookup', function () { + it('finds key by plaintext value', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $plainKey = $key->plainTextKey; $found = AgentApiKey::findByKey($plainKey); - $this->assertNotNull($found); - $this->assertEquals($key->id, $found->id); - } + expect($found) + ->not->toBeNull() + ->and($found->id)->toBe($key->id); + }); - public function test_find_by_key_returns_null_for_invalid_key(): void - { - AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('returns null for invalid key', function () { + $workspace = createWorkspace(); + AgentApiKey::generate($workspace, 'Test Key'); $found = AgentApiKey::findByKey('ak_invalid_key_that_does_not_exist'); - $this->assertNull($found); - } + expect($found)->toBeNull(); + }); - public function test_find_by_key_returns_null_for_malformed_key(): void - { - $this->assertNull(AgentApiKey::findByKey('')); - $this->assertNull(AgentApiKey::findByKey('invalid')); - $this->assertNull(AgentApiKey::findByKey('ak_short')); - } + it('returns null for malformed key', function () { + expect(AgentApiKey::findByKey(''))->toBeNull(); + expect(AgentApiKey::findByKey('invalid'))->toBeNull(); + expect(AgentApiKey::findByKey('ak_short'))->toBeNull(); + }); - public function test_find_by_key_does_not_find_revoked_keys(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('does not find revoked keys', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $plainKey = $key->plainTextKey; $key->revoke(); $found = AgentApiKey::findByKey($plainKey); - $this->assertNull($found); - } + expect($found)->toBeNull(); + }); - public function test_find_by_key_does_not_find_expired_keys(): void - { + it('does not find expired keys', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [], 100, @@ -207,99 +155,93 @@ class AgentApiKeyTest extends TestCase $found = AgentApiKey::findByKey($plainKey); - $this->assertNull($found); - } + expect($found)->toBeNull(); + }); +}); - public function test_verify_key_returns_true_for_matching_key(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); +// ========================================================================= +// Key Verification Tests +// ========================================================================= + +describe('key verification', function () { + it('verifyKey returns true for matching key', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $plainKey = $key->plainTextKey; - $this->assertTrue($key->verifyKey($plainKey)); - } + expect($key->verifyKey($plainKey))->toBeTrue(); + }); - public function test_verify_key_returns_false_for_non_matching_key(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('verifyKey returns false for non-matching key', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertFalse($key->verifyKey('ak_wrong_key_entirely')); - } + expect($key->verifyKey('ak_wrong_key_entirely'))->toBeFalse(); + }); +}); - // ========================================================================= - // Status Tests - // ========================================================================= +// ========================================================================= +// Status Tests +// ========================================================================= - public function test_is_active_returns_true_for_fresh_key(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); +describe('status helpers', function () { + it('isActive returns true for fresh key', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertTrue($key->isActive()); - } + expect($key->isActive())->toBeTrue(); + }); - public function test_is_active_returns_false_for_revoked_key(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('isActive returns false for revoked key', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->revoke(); - $this->assertFalse($key->isActive()); - } + expect($key->isActive())->toBeFalse(); + }); - public function test_is_active_returns_false_for_expired_key(): void - { + it('isActive returns false for expired key', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [], 100, Carbon::now()->subDay() ); - $this->assertFalse($key->isActive()); - } + expect($key->isActive())->toBeFalse(); + }); - public function test_is_active_returns_true_for_key_with_future_expiry(): void - { + it('isActive returns true for key with future expiry', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [], 100, Carbon::now()->addDay() ); - $this->assertTrue($key->isActive()); - } + expect($key->isActive())->toBeTrue(); + }); - public function test_is_revoked_returns_correct_value(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('isRevoked returns correct value', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertFalse($key->isRevoked()); + expect($key->isRevoked())->toBeFalse(); $key->revoke(); - $this->assertTrue($key->isRevoked()); - } + expect($key->isRevoked())->toBeTrue(); + }); + + it('isExpired returns correct value for various states', function () { + $workspace = createWorkspace(); - public function test_is_expired_returns_correct_value(): void - { $notExpired = AgentApiKey::generate( - $this->workspace, + $workspace, 'Not Expired', [], 100, @@ -307,416 +249,347 @@ class AgentApiKeyTest extends TestCase ); $expired = AgentApiKey::generate( - $this->workspace, + $workspace, 'Expired', [], 100, Carbon::now()->subDay() ); - $noExpiry = AgentApiKey::generate( - $this->workspace, - 'No Expiry' - ); + $noExpiry = AgentApiKey::generate($workspace, 'No Expiry'); - $this->assertFalse($notExpired->isExpired()); - $this->assertTrue($expired->isExpired()); - $this->assertFalse($noExpiry->isExpired()); - } + expect($notExpired->isExpired())->toBeFalse(); + expect($expired->isExpired())->toBeTrue(); + expect($noExpiry->isExpired())->toBeFalse(); + }); +}); - // ========================================================================= - // Permission Tests - // ========================================================================= +// ========================================================================= +// Permission Tests +// ========================================================================= - public function test_has_permission_returns_true_when_granted(): void - { +describe('permissions', function () { + it('hasPermission returns true when granted', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE] ); - $this->assertTrue($key->hasPermission(AgentApiKey::PERM_PLANS_READ)); - $this->assertTrue($key->hasPermission(AgentApiKey::PERM_PLANS_WRITE)); - } + expect($key->hasPermission(AgentApiKey::PERM_PLANS_READ))->toBeTrue(); + expect($key->hasPermission(AgentApiKey::PERM_PLANS_WRITE))->toBeTrue(); + }); - public function test_has_permission_returns_false_when_not_granted(): void - { + it('hasPermission returns false when not granted', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); - $this->assertFalse($key->hasPermission(AgentApiKey::PERM_PLANS_WRITE)); - } + expect($key->hasPermission(AgentApiKey::PERM_PLANS_WRITE))->toBeFalse(); + }); - public function test_has_any_permission_returns_true_when_one_matches(): void - { + it('hasAnyPermission returns true when one matches', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); - $this->assertTrue($key->hasAnyPermission([ + expect($key->hasAnyPermission([ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, - ])); - } + ]))->toBeTrue(); + }); - public function test_has_any_permission_returns_false_when_none_match(): void - { + it('hasAnyPermission returns false when none match', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_TEMPLATES_READ] ); - $this->assertFalse($key->hasAnyPermission([ + expect($key->hasAnyPermission([ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, - ])); - } + ]))->toBeFalse(); + }); - public function test_has_all_permissions_returns_true_when_all_granted(): void - { + it('hasAllPermissions returns true when all granted', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, AgentApiKey::PERM_SESSIONS_READ] ); - $this->assertTrue($key->hasAllPermissions([ + expect($key->hasAllPermissions([ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, - ])); - } + ]))->toBeTrue(); + }); - public function test_has_all_permissions_returns_false_when_missing_one(): void - { + it('hasAllPermissions returns false when missing one', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); - $this->assertFalse($key->hasAllPermissions([ + expect($key->hasAllPermissions([ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, - ])); - } + ]))->toBeFalse(); + }); - public function test_update_permissions_changes_permissions(): void - { + it('updatePermissions changes permissions', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $key->updatePermissions([AgentApiKey::PERM_SESSIONS_WRITE]); - $this->assertFalse($key->hasPermission(AgentApiKey::PERM_PLANS_READ)); - $this->assertTrue($key->hasPermission(AgentApiKey::PERM_SESSIONS_WRITE)); - } + expect($key->hasPermission(AgentApiKey::PERM_PLANS_READ))->toBeFalse(); + expect($key->hasPermission(AgentApiKey::PERM_SESSIONS_WRITE))->toBeTrue(); + }); +}); - // ========================================================================= - // Rate Limiting Tests - // ========================================================================= +// ========================================================================= +// Rate Limiting Tests +// ========================================================================= - public function test_is_rate_limited_returns_false_when_under_limit(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 100 - ); +describe('rate limiting', function () { + it('isRateLimited returns false when under limit', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 50, 60); - $this->assertFalse($key->isRateLimited()); - } + expect($key->isRateLimited())->toBeFalse(); + }); - public function test_is_rate_limited_returns_true_when_at_limit(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 100 - ); + it('isRateLimited returns true when at limit', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 100, 60); - $this->assertTrue($key->isRateLimited()); - } + expect($key->isRateLimited())->toBeTrue(); + }); - public function test_is_rate_limited_returns_true_when_over_limit(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 100 - ); + it('isRateLimited returns true when over limit', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 150, 60); - $this->assertTrue($key->isRateLimited()); - } + expect($key->isRateLimited())->toBeTrue(); + }); - public function test_get_recent_call_count_returns_cache_value(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('getRecentCallCount returns cache value', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); Cache::put("agent_api_key_rate:{$key->id}", 42, 60); - $this->assertEquals(42, $key->getRecentCallCount()); - } + expect($key->getRecentCallCount())->toBe(42); + }); - public function test_get_recent_call_count_returns_zero_when_not_cached(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('getRecentCallCount returns zero when not cached', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertEquals(0, $key->getRecentCallCount()); - } + expect($key->getRecentCallCount())->toBe(0); + }); - public function test_get_remaining_calls_returns_correct_value(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 100 - ); + it('getRemainingCalls returns correct value', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 30, 60); - $this->assertEquals(70, $key->getRemainingCalls()); - } + expect($key->getRemainingCalls())->toBe(70); + }); - public function test_get_remaining_calls_returns_zero_when_over_limit(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 100 - ); + it('getRemainingCalls returns zero when over limit', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 150, 60); - $this->assertEquals(0, $key->getRemainingCalls()); - } + expect($key->getRemainingCalls())->toBe(0); + }); - public function test_update_rate_limit_changes_limit(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key', - [], - 100 - ); + it('updateRateLimit changes limit', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key', [], 100); $key->updateRateLimit(200); - $this->assertEquals(200, $key->fresh()->rate_limit); - } + expect($key->fresh()->rate_limit)->toBe(200); + }); +}); - // ========================================================================= - // IP Restriction Tests - // ========================================================================= +// ========================================================================= +// IP Restriction Tests +// ========================================================================= - public function test_ip_restrictions_disabled_by_default(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); +describe('IP restrictions', function () { + it('has IP restrictions disabled by default', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertFalse($key->ip_restriction_enabled); - } + expect($key->ip_restriction_enabled)->toBeFalse(); + }); - public function test_enable_ip_restriction_sets_flag(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('enableIpRestriction sets flag', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->enableIpRestriction(); - $this->assertTrue($key->fresh()->ip_restriction_enabled); - } + expect($key->fresh()->ip_restriction_enabled)->toBeTrue(); + }); - public function test_disable_ip_restriction_clears_flag(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('disableIpRestriction clears flag', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->enableIpRestriction(); $key->disableIpRestriction(); - $this->assertFalse($key->fresh()->ip_restriction_enabled); - } + expect($key->fresh()->ip_restriction_enabled)->toBeFalse(); + }); - public function test_update_ip_whitelist_sets_list(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('updateIpWhitelist sets list', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->updateIpWhitelist(['192.168.1.1', '10.0.0.0/8']); - $this->assertEquals(['192.168.1.1', '10.0.0.0/8'], $key->fresh()->ip_whitelist); - } + expect($key->fresh()->ip_whitelist)->toBe(['192.168.1.1', '10.0.0.0/8']); + }); - public function test_add_to_ip_whitelist_adds_entry(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('addToIpWhitelist adds entry', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->updateIpWhitelist(['192.168.1.1']); $key->addToIpWhitelist('10.0.0.1'); - $this->assertContains('192.168.1.1', $key->fresh()->ip_whitelist); - $this->assertContains('10.0.0.1', $key->fresh()->ip_whitelist); - } + $whitelist = $key->fresh()->ip_whitelist; + expect($whitelist)->toContain('192.168.1.1'); + expect($whitelist)->toContain('10.0.0.1'); + }); - public function test_add_to_ip_whitelist_does_not_duplicate(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('addToIpWhitelist does not duplicate', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->updateIpWhitelist(['192.168.1.1']); $key->addToIpWhitelist('192.168.1.1'); - $this->assertCount(1, $key->fresh()->ip_whitelist); - } + expect($key->fresh()->ip_whitelist)->toHaveCount(1); + }); - public function test_remove_from_ip_whitelist_removes_entry(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('removeFromIpWhitelist removes entry', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->updateIpWhitelist(['192.168.1.1', '10.0.0.1']); $key->removeFromIpWhitelist('192.168.1.1'); $whitelist = $key->fresh()->ip_whitelist; - $this->assertNotContains('192.168.1.1', $whitelist); - $this->assertContains('10.0.0.1', $whitelist); - } + expect($whitelist)->not->toContain('192.168.1.1'); + expect($whitelist)->toContain('10.0.0.1'); + }); - public function test_has_ip_restrictions_returns_correct_value(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('hasIpRestrictions returns correct value', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); // No restrictions - $this->assertFalse($key->hasIpRestrictions()); + expect($key->hasIpRestrictions())->toBeFalse(); // Enabled but no whitelist $key->enableIpRestriction(); - $this->assertFalse($key->fresh()->hasIpRestrictions()); + expect($key->fresh()->hasIpRestrictions())->toBeFalse(); // Enabled with whitelist $key->updateIpWhitelist(['192.168.1.1']); - $this->assertTrue($key->fresh()->hasIpRestrictions()); - } + expect($key->fresh()->hasIpRestrictions())->toBeTrue(); + }); - public function test_get_ip_whitelist_count_returns_correct_value(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('getIpWhitelistCount returns correct value', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertEquals(0, $key->getIpWhitelistCount()); + expect($key->getIpWhitelistCount())->toBe(0); $key->updateIpWhitelist(['192.168.1.1', '10.0.0.0/8', '172.16.0.0/12']); - $this->assertEquals(3, $key->fresh()->getIpWhitelistCount()); - } + expect($key->fresh()->getIpWhitelistCount())->toBe(3); + }); - public function test_record_last_used_ip_stores_ip(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('recordLastUsedIp stores IP', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->recordLastUsedIp('192.168.1.100'); - $this->assertEquals('192.168.1.100', $key->fresh()->last_used_ip); - } + expect($key->fresh()->last_used_ip)->toBe('192.168.1.100'); + }); +}); - // ========================================================================= - // Actions Tests - // ========================================================================= +// ========================================================================= +// Actions Tests +// ========================================================================= - public function test_revoke_sets_revoked_at(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); +describe('actions', function () { + it('revoke sets revoked_at', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->revoke(); - $this->assertNotNull($key->fresh()->revoked_at); - } + expect($key->fresh()->revoked_at)->not->toBeNull(); + }); - public function test_record_usage_increments_count(): void - { + it('recordUsage increments count', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); + + $key->recordUsage(); + $key->recordUsage(); + $key->recordUsage(); + + expect($key->fresh()->call_count)->toBe(3); + }); + + it('recordUsage updates last_used_at', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); + + expect($key->last_used_at)->toBeNull(); + + $key->recordUsage(); + + expect($key->fresh()->last_used_at)->not->toBeNull(); + }); + + it('extendExpiry updates expiry', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); - - $key->recordUsage(); - $key->recordUsage(); - $key->recordUsage(); - - $this->assertEquals(3, $key->fresh()->call_count); - } - - public function test_record_usage_updates_last_used_at(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); - - $this->assertNull($key->last_used_at); - - $key->recordUsage(); - - $this->assertNotNull($key->fresh()->last_used_at); - } - - public function test_extend_expiry_updates_expiry(): void - { - $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [], 100, @@ -726,16 +599,14 @@ class AgentApiKeyTest extends TestCase $newExpiry = Carbon::now()->addMonth(); $key->extendExpiry($newExpiry); - $this->assertEquals( - $newExpiry->toDateTimeString(), - $key->fresh()->expires_at->toDateTimeString() - ); - } + expect($key->fresh()->expires_at->toDateTimeString()) + ->toBe($newExpiry->toDateTimeString()); + }); - public function test_remove_expiry_clears_expiry(): void - { + it('removeExpiry clears expiry', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [], 100, @@ -744,218 +615,305 @@ class AgentApiKeyTest extends TestCase $key->removeExpiry(); - $this->assertNull($key->fresh()->expires_at); - } + expect($key->fresh()->expires_at)->toBeNull(); + }); +}); - // ========================================================================= - // Scope Tests - // ========================================================================= +// ========================================================================= +// Scope Tests +// ========================================================================= - public function test_active_scope_filters_correctly(): void - { - AgentApiKey::generate($this->workspace, 'Active Key'); - $revoked = AgentApiKey::generate($this->workspace, 'Revoked Key'); +describe('scopes', function () { + it('active scope filters correctly', function () { + $workspace = createWorkspace(); + + AgentApiKey::generate($workspace, 'Active Key'); + $revoked = AgentApiKey::generate($workspace, 'Revoked Key'); $revoked->revoke(); - AgentApiKey::generate($this->workspace, 'Expired Key', [], 100, Carbon::now()->subDay()); + AgentApiKey::generate($workspace, 'Expired Key', [], 100, Carbon::now()->subDay()); $activeKeys = AgentApiKey::active()->get(); - $this->assertCount(1, $activeKeys); - $this->assertEquals('Active Key', $activeKeys->first()->name); - } + expect($activeKeys)->toHaveCount(1); + expect($activeKeys->first()->name)->toBe('Active Key'); + }); - public function test_for_workspace_scope_filters_correctly(): void - { - $otherWorkspace = Workspace::factory()->create(); + it('forWorkspace scope filters correctly', function () { + $workspace = createWorkspace(); + $otherWorkspace = createWorkspace(); - AgentApiKey::generate($this->workspace, 'Our Key'); + AgentApiKey::generate($workspace, 'Our Key'); AgentApiKey::generate($otherWorkspace, 'Their Key'); - $ourKeys = AgentApiKey::forWorkspace($this->workspace)->get(); + $ourKeys = AgentApiKey::forWorkspace($workspace)->get(); - $this->assertCount(1, $ourKeys); - $this->assertEquals('Our Key', $ourKeys->first()->name); - } + expect($ourKeys)->toHaveCount(1); + expect($ourKeys->first()->name)->toBe('Our Key'); + }); - public function test_revoked_scope_filters_correctly(): void - { - AgentApiKey::generate($this->workspace, 'Active Key'); - $revoked = AgentApiKey::generate($this->workspace, 'Revoked Key'); + it('revoked scope filters correctly', function () { + $workspace = createWorkspace(); + + AgentApiKey::generate($workspace, 'Active Key'); + $revoked = AgentApiKey::generate($workspace, 'Revoked Key'); $revoked->revoke(); $revokedKeys = AgentApiKey::revoked()->get(); - $this->assertCount(1, $revokedKeys); - $this->assertEquals('Revoked Key', $revokedKeys->first()->name); - } + expect($revokedKeys)->toHaveCount(1); + expect($revokedKeys->first()->name)->toBe('Revoked Key'); + }); - public function test_expired_scope_filters_correctly(): void - { - AgentApiKey::generate($this->workspace, 'Active Key'); - AgentApiKey::generate($this->workspace, 'Expired Key', [], 100, Carbon::now()->subDay()); + it('expired scope filters correctly', function () { + $workspace = createWorkspace(); + + AgentApiKey::generate($workspace, 'Active Key'); + AgentApiKey::generate($workspace, 'Expired Key', [], 100, Carbon::now()->subDay()); $expiredKeys = AgentApiKey::expired()->get(); - $this->assertCount(1, $expiredKeys); - $this->assertEquals('Expired Key', $expiredKeys->first()->name); - } + expect($expiredKeys)->toHaveCount(1); + expect($expiredKeys->first()->name)->toBe('Expired Key'); + }); +}); - // ========================================================================= - // Display Helper Tests - // ========================================================================= +// ========================================================================= +// Display Helper Tests +// ========================================================================= - public function test_get_masked_key_returns_masked_format(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); +describe('display helpers', function () { + it('getMaskedKey returns masked format', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $masked = $key->getMaskedKey(); - $this->assertStringStartsWith('ak_', $masked); - $this->assertStringEndsWith('...', $masked); - } + expect($masked) + ->toStartWith('ak_') + ->toEndWith('...'); + }); - public function test_get_status_label_returns_correct_label(): void - { - $active = AgentApiKey::generate($this->workspace, 'Active'); - $revoked = AgentApiKey::generate($this->workspace, 'Revoked'); + it('getStatusLabel returns correct label', function () { + $workspace = createWorkspace(); + + $active = AgentApiKey::generate($workspace, 'Active'); + $revoked = AgentApiKey::generate($workspace, 'Revoked'); $revoked->revoke(); - $expired = AgentApiKey::generate($this->workspace, 'Expired', [], 100, Carbon::now()->subDay()); + $expired = AgentApiKey::generate($workspace, 'Expired', [], 100, Carbon::now()->subDay()); - $this->assertEquals('Active', $active->getStatusLabel()); - $this->assertEquals('Revoked', $revoked->getStatusLabel()); - $this->assertEquals('Expired', $expired->getStatusLabel()); - } + expect($active->getStatusLabel())->toBe('Active'); + expect($revoked->getStatusLabel())->toBe('Revoked'); + expect($expired->getStatusLabel())->toBe('Expired'); + }); - public function test_get_status_colour_returns_correct_colour(): void - { - $active = AgentApiKey::generate($this->workspace, 'Active'); - $revoked = AgentApiKey::generate($this->workspace, 'Revoked'); + it('getStatusColor returns correct colour', function () { + $workspace = createWorkspace(); + + $active = AgentApiKey::generate($workspace, 'Active'); + $revoked = AgentApiKey::generate($workspace, 'Revoked'); $revoked->revoke(); - $expired = AgentApiKey::generate($this->workspace, 'Expired', [], 100, Carbon::now()->subDay()); + $expired = AgentApiKey::generate($workspace, 'Expired', [], 100, Carbon::now()->subDay()); - $this->assertEquals('green', $active->getStatusColor()); - $this->assertEquals('red', $revoked->getStatusColor()); - $this->assertEquals('amber', $expired->getStatusColor()); - } + expect($active->getStatusColor())->toBe('green'); + expect($revoked->getStatusColor())->toBe('red'); + expect($expired->getStatusColor())->toBe('amber'); + }); - public function test_get_last_used_for_humans_returns_never_when_null(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('getLastUsedForHumans returns Never when null', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertEquals('Never', $key->getLastUsedForHumans()); - } + expect($key->getLastUsedForHumans())->toBe('Never'); + }); - public function test_get_last_used_for_humans_returns_diff_when_set(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); + it('getLastUsedForHumans returns diff when set', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); $key->update(['last_used_at' => Carbon::now()->subHour()]); - $this->assertStringContainsString('ago', $key->getLastUsedForHumans()); - } + expect($key->getLastUsedForHumans())->toContain('ago'); + }); - public function test_get_expires_for_humans_returns_never_when_null(): void - { + it('getExpiresForHumans returns Never when null', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); + + expect($key->getExpiresForHumans())->toBe('Never'); + }); + + it('getExpiresForHumans returns Expired when past', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); - - $this->assertEquals('Never', $key->getExpiresForHumans()); - } - - public function test_get_expires_for_humans_returns_expired_when_past(): void - { - $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [], 100, Carbon::now()->subDay() ); - $this->assertStringContainsString('Expired', $key->getExpiresForHumans()); - } + expect($key->getExpiresForHumans())->toContain('Expired'); + }); - public function test_get_expires_for_humans_returns_expires_when_future(): void - { + it('getExpiresForHumans returns Expires when future', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [], 100, Carbon::now()->addDay() ); - $this->assertStringContainsString('Expires', $key->getExpiresForHumans()); - } + expect($key->getExpiresForHumans())->toContain('Expires'); + }); +}); - // ========================================================================= - // Array Output Tests - // ========================================================================= +// ========================================================================= +// Array Output Tests +// ========================================================================= - public function test_to_array_includes_expected_keys(): void - { +describe('array output', function () { + it('toArray includes expected keys', function () { + $workspace = createWorkspace(); $key = AgentApiKey::generate( - $this->workspace, + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $array = $key->toArray(); - $this->assertArrayHasKey('id', $array); - $this->assertArrayHasKey('workspace_id', $array); - $this->assertArrayHasKey('name', $array); - $this->assertArrayHasKey('permissions', $array); - $this->assertArrayHasKey('rate_limit', $array); - $this->assertArrayHasKey('call_count', $array); - $this->assertArrayHasKey('status', $array); - $this->assertArrayHasKey('ip_restriction_enabled', $array); - $this->assertArrayHasKey('ip_whitelist_count', $array); + expect($array) + ->toHaveKey('id') + ->toHaveKey('workspace_id') + ->toHaveKey('name') + ->toHaveKey('permissions') + ->toHaveKey('rate_limit') + ->toHaveKey('call_count') + ->toHaveKey('status') + ->toHaveKey('ip_restriction_enabled') + ->toHaveKey('ip_whitelist_count'); // Should NOT include the key hash - $this->assertArrayNotHasKey('key', $array); - } + expect($array)->not->toHaveKey('key'); + }); +}); - // ========================================================================= - // Available Permissions Tests - // ========================================================================= +// ========================================================================= +// Available Permissions Tests +// ========================================================================= - public function test_available_permissions_returns_all_permissions(): void - { +describe('available permissions', function () { + it('returns all permissions', function () { $permissions = AgentApiKey::availablePermissions(); - $this->assertIsArray($permissions); - $this->assertArrayHasKey(AgentApiKey::PERM_PLANS_READ, $permissions); - $this->assertArrayHasKey(AgentApiKey::PERM_PLANS_WRITE, $permissions); - $this->assertArrayHasKey(AgentApiKey::PERM_SESSIONS_READ, $permissions); - $this->assertArrayHasKey(AgentApiKey::PERM_SESSIONS_WRITE, $permissions); - $this->assertArrayHasKey(AgentApiKey::PERM_NOTIFY_READ, $permissions); - $this->assertArrayHasKey(AgentApiKey::PERM_NOTIFY_WRITE, $permissions); - $this->assertArrayHasKey(AgentApiKey::PERM_NOTIFY_SEND, $permissions); - } + expect($permissions) + ->toBeArray() + ->toHaveKey(AgentApiKey::PERM_PLANS_READ) + ->toHaveKey(AgentApiKey::PERM_PLANS_WRITE) + ->toHaveKey(AgentApiKey::PERM_SESSIONS_READ) + ->toHaveKey(AgentApiKey::PERM_SESSIONS_WRITE) + ->toHaveKey(AgentApiKey::PERM_NOTIFY_READ) + ->toHaveKey(AgentApiKey::PERM_NOTIFY_WRITE) + ->toHaveKey(AgentApiKey::PERM_NOTIFY_SEND); + }); +}); - // ========================================================================= - // Relationship Tests - // ========================================================================= +// ========================================================================= +// Relationship Tests +// ========================================================================= - public function test_belongs_to_workspace(): void - { - $key = AgentApiKey::generate( - $this->workspace, - 'Test Key' - ); +describe('relationships', function () { + it('belongs to workspace', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); - $this->assertInstanceOf(Workspace::class, $key->workspace); - $this->assertEquals($this->workspace->id, $key->workspace->id); - } -} + expect($key->workspace) + ->toBeInstanceOf(Workspace::class) + ->and($key->workspace->id)->toBe($workspace->id); + }); +}); + +// ========================================================================= +// Key Rotation Tests +// ========================================================================= + +describe('key rotation', function () { + it('can generate a new key for the same workspace and revoke old', function () { + $workspace = createWorkspace(); + + $oldKey = AgentApiKey::generate($workspace, 'Old Key'); + $oldPlainKey = $oldKey->plainTextKey; + + // Revoke old key + $oldKey->revoke(); + + // Create new key + $newKey = AgentApiKey::generate($workspace, 'New Key'); + $newPlainKey = $newKey->plainTextKey; + + // Old key should not be found + expect(AgentApiKey::findByKey($oldPlainKey))->toBeNull(); + + // New key should be found + expect(AgentApiKey::findByKey($newPlainKey))->not->toBeNull(); + }); + + it('workspace can have multiple active keys', function () { + $workspace = createWorkspace(); + + AgentApiKey::generate($workspace, 'Key 1'); + AgentApiKey::generate($workspace, 'Key 2'); + AgentApiKey::generate($workspace, 'Key 3'); + + $activeKeys = AgentApiKey::forWorkspace($workspace)->active()->get(); + + expect($activeKeys)->toHaveCount(3); + }); +}); + +// ========================================================================= +// Security Edge Cases +// ========================================================================= + +describe('security edge cases', function () { + it('different keys for same workspace have unique hashes', function () { + $workspace = createWorkspace(); + + $key1 = AgentApiKey::generate($workspace, 'Key 1'); + $key2 = AgentApiKey::generate($workspace, 'Key 2'); + + expect($key1->key)->not->toBe($key2->key); + }); + + it('same plaintext would produce different Argon2id hashes', function () { + // This tests that Argon2id includes a random salt + $workspace = createWorkspace(); + $plainKey = 'ak_test_key_12345678901234567890'; + + $hash1 = password_hash($plainKey, PASSWORD_ARGON2ID); + $hash2 = password_hash($plainKey, PASSWORD_ARGON2ID); + + expect($hash1)->not->toBe($hash2); + + // But both verify correctly + expect(password_verify($plainKey, $hash1))->toBeTrue(); + expect(password_verify($plainKey, $hash2))->toBeTrue(); + }); + + it('cannot find key using partial plaintext', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); + $partialKey = substr($key->plainTextKey, 0, 20); + + expect(AgentApiKey::findByKey($partialKey))->toBeNull(); + }); + + it('cannot find key using hash directly', function () { + $workspace = createWorkspace(); + $key = AgentApiKey::generate($workspace, 'Test Key'); + + // Trying to use the hash as if it were the plaintext key + expect(AgentApiKey::findByKey($key->key))->toBeNull(); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..4f1596b --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,74 @@ +in('Feature', 'Unit', 'UseCase'); + +/* +|-------------------------------------------------------------------------- +| Database Refresh +|-------------------------------------------------------------------------- +| +| Apply RefreshDatabase to Feature tests that need a clean database state. +| Unit tests typically don't require database access. +| +*/ + +uses(RefreshDatabase::class)->in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Helper Functions +|-------------------------------------------------------------------------- +| +| Custom helper functions for agent-related tests. +| +*/ + +/** + * Create a workspace for testing. + */ +function createWorkspace(array $attributes = []): Workspace +{ + return Workspace::factory()->create($attributes); +} + +/** + * Create an API key for testing. + */ +function createApiKey( + Workspace|int|null $workspace = null, + string $name = 'Test Key', + array $permissions = [], + int $rateLimit = 100 +): AgentApiKey { + $workspace ??= createWorkspace(); + + return AgentApiKey::generate($workspace, $name, $permissions, $rateLimit); +}