php-agentic/tests/Feature/AgentApiKeyTest.php

962 lines
27 KiB
PHP
Raw Normal View History

<?php
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;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
}
// =========================================================================
// Key Generation Tests
// =========================================================================
public function test_it_generates_key_with_correct_prefix(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$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'
);
// Argon2id hashes start with $argon2id$
$this->assertStringStartsWith('$argon2id$', $key->key);
}
public function test_plaintext_key_is_only_available_once(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$plainKey = $key->plainTextKey;
$this->assertNotNull($plainKey);
// 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'
);
$this->assertEquals($this->workspace->id, $key->workspace_id);
}
public function test_it_generates_key_with_workspace_model(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertEquals($this->workspace->id, $key->workspace_id);
}
public function test_it_generates_key_with_permissions(): void
{
$permissions = [
AgentApiKey::PERM_PLANS_READ,
AgentApiKey::PERM_PLANS_WRITE,
];
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
$permissions
);
$this->assertEquals($permissions, $key->permissions);
}
public function test_it_generates_key_with_custom_rate_limit(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
500
);
$this->assertEquals(500, $key->rate_limit);
}
public function test_it_generates_key_with_expiry(): void
{
$expiresAt = Carbon::now()->addDays(30);
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100,
$expiresAt
);
$this->assertEquals($expiresAt->toDateTimeString(), $key->expires_at->toDateTimeString());
}
public function test_it_initialises_call_count_to_zero(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertEquals(0, $key->call_count);
}
// =========================================================================
// Key Lookup Tests
// =========================================================================
public function test_find_by_key_returns_correct_key(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$plainKey = $key->plainTextKey;
$found = AgentApiKey::findByKey($plainKey);
$this->assertNotNull($found);
$this->assertEquals($key->id, $found->id);
}
public function test_find_by_key_returns_null_for_invalid_key(): void
{
AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$found = AgentApiKey::findByKey('ak_invalid_key_that_does_not_exist');
$this->assertNull($found);
}
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'));
}
public function test_find_by_key_does_not_find_revoked_keys(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$plainKey = $key->plainTextKey;
$key->revoke();
$found = AgentApiKey::findByKey($plainKey);
$this->assertNull($found);
}
public function test_find_by_key_does_not_find_expired_keys(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100,
Carbon::now()->subDay()
);
$plainKey = $key->plainTextKey;
$found = AgentApiKey::findByKey($plainKey);
$this->assertNull($found);
}
public function test_verify_key_returns_true_for_matching_key(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$plainKey = $key->plainTextKey;
$this->assertTrue($key->verifyKey($plainKey));
}
public function test_verify_key_returns_false_for_non_matching_key(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertFalse($key->verifyKey('ak_wrong_key_entirely'));
}
// =========================================================================
// Status Tests
// =========================================================================
public function test_is_active_returns_true_for_fresh_key(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertTrue($key->isActive());
}
public function test_is_active_returns_false_for_revoked_key(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$key->revoke();
$this->assertFalse($key->isActive());
}
public function test_is_active_returns_false_for_expired_key(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100,
Carbon::now()->subDay()
);
$this->assertFalse($key->isActive());
}
public function test_is_active_returns_true_for_key_with_future_expiry(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100,
Carbon::now()->addDay()
);
$this->assertTrue($key->isActive());
}
public function test_is_revoked_returns_correct_value(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertFalse($key->isRevoked());
$key->revoke();
$this->assertTrue($key->isRevoked());
}
public function test_is_expired_returns_correct_value(): void
{
$notExpired = AgentApiKey::generate(
$this->workspace,
'Not Expired',
[],
100,
Carbon::now()->addDay()
);
$expired = AgentApiKey::generate(
$this->workspace,
'Expired',
[],
100,
Carbon::now()->subDay()
);
$noExpiry = AgentApiKey::generate(
$this->workspace,
'No Expiry'
);
$this->assertFalse($notExpired->isExpired());
$this->assertTrue($expired->isExpired());
$this->assertFalse($noExpiry->isExpired());
}
// =========================================================================
// Permission Tests
// =========================================================================
public function test_has_permission_returns_true_when_granted(): void
{
$key = AgentApiKey::generate(
$this->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));
}
public function test_has_permission_returns_false_when_not_granted(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[AgentApiKey::PERM_PLANS_READ]
);
$this->assertFalse($key->hasPermission(AgentApiKey::PERM_PLANS_WRITE));
}
public function test_has_any_permission_returns_true_when_one_matches(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[AgentApiKey::PERM_PLANS_READ]
);
$this->assertTrue($key->hasAnyPermission([
AgentApiKey::PERM_PLANS_READ,
AgentApiKey::PERM_PLANS_WRITE,
]));
}
public function test_has_any_permission_returns_false_when_none_match(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[AgentApiKey::PERM_TEMPLATES_READ]
);
$this->assertFalse($key->hasAnyPermission([
AgentApiKey::PERM_PLANS_READ,
AgentApiKey::PERM_PLANS_WRITE,
]));
}
public function test_has_all_permissions_returns_true_when_all_granted(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, AgentApiKey::PERM_SESSIONS_READ]
);
$this->assertTrue($key->hasAllPermissions([
AgentApiKey::PERM_PLANS_READ,
AgentApiKey::PERM_PLANS_WRITE,
]));
}
public function test_has_all_permissions_returns_false_when_missing_one(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[AgentApiKey::PERM_PLANS_READ]
);
$this->assertFalse($key->hasAllPermissions([
AgentApiKey::PERM_PLANS_READ,
AgentApiKey::PERM_PLANS_WRITE,
]));
}
public function test_update_permissions_changes_permissions(): void
{
$key = AgentApiKey::generate(
$this->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));
}
// =========================================================================
// Rate Limiting Tests
// =========================================================================
public function test_is_rate_limited_returns_false_when_under_limit(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100
);
Cache::put("agent_api_key_rate:{$key->id}", 50, 60);
$this->assertFalse($key->isRateLimited());
}
public function test_is_rate_limited_returns_true_when_at_limit(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100
);
Cache::put("agent_api_key_rate:{$key->id}", 100, 60);
$this->assertTrue($key->isRateLimited());
}
public function test_is_rate_limited_returns_true_when_over_limit(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100
);
Cache::put("agent_api_key_rate:{$key->id}", 150, 60);
$this->assertTrue($key->isRateLimited());
}
public function test_get_recent_call_count_returns_cache_value(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
Cache::put("agent_api_key_rate:{$key->id}", 42, 60);
$this->assertEquals(42, $key->getRecentCallCount());
}
public function test_get_recent_call_count_returns_zero_when_not_cached(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertEquals(0, $key->getRecentCallCount());
}
public function test_get_remaining_calls_returns_correct_value(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100
);
Cache::put("agent_api_key_rate:{$key->id}", 30, 60);
$this->assertEquals(70, $key->getRemainingCalls());
}
public function test_get_remaining_calls_returns_zero_when_over_limit(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100
);
Cache::put("agent_api_key_rate:{$key->id}", 150, 60);
$this->assertEquals(0, $key->getRemainingCalls());
}
public function test_update_rate_limit_changes_limit(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100
);
$key->updateRateLimit(200);
$this->assertEquals(200, $key->fresh()->rate_limit);
}
// =========================================================================
// IP Restriction Tests
// =========================================================================
public function test_ip_restrictions_disabled_by_default(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertFalse($key->ip_restriction_enabled);
}
public function test_enable_ip_restriction_sets_flag(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$key->enableIpRestriction();
$this->assertTrue($key->fresh()->ip_restriction_enabled);
}
public function test_disable_ip_restriction_clears_flag(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$key->enableIpRestriction();
$key->disableIpRestriction();
$this->assertFalse($key->fresh()->ip_restriction_enabled);
}
public function test_update_ip_whitelist_sets_list(): void
{
$key = AgentApiKey::generate(
$this->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);
}
public function test_add_to_ip_whitelist_adds_entry(): void
{
$key = AgentApiKey::generate(
$this->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);
}
public function test_add_to_ip_whitelist_does_not_duplicate(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$key->updateIpWhitelist(['192.168.1.1']);
$key->addToIpWhitelist('192.168.1.1');
$this->assertCount(1, $key->fresh()->ip_whitelist);
}
public function test_remove_from_ip_whitelist_removes_entry(): void
{
$key = AgentApiKey::generate(
$this->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);
}
public function test_has_ip_restrictions_returns_correct_value(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
// No restrictions
$this->assertFalse($key->hasIpRestrictions());
// Enabled but no whitelist
$key->enableIpRestriction();
$this->assertFalse($key->fresh()->hasIpRestrictions());
// Enabled with whitelist
$key->updateIpWhitelist(['192.168.1.1']);
$this->assertTrue($key->fresh()->hasIpRestrictions());
}
public function test_get_ip_whitelist_count_returns_correct_value(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertEquals(0, $key->getIpWhitelistCount());
$key->updateIpWhitelist(['192.168.1.1', '10.0.0.0/8', '172.16.0.0/12']);
$this->assertEquals(3, $key->fresh()->getIpWhitelistCount());
}
public function test_record_last_used_ip_stores_ip(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$key->recordLastUsedIp('192.168.1.100');
$this->assertEquals('192.168.1.100', $key->fresh()->last_used_ip);
}
// =========================================================================
// Actions Tests
// =========================================================================
public function test_revoke_sets_revoked_at(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$key->revoke();
$this->assertNotNull($key->fresh()->revoked_at);
}
public function test_record_usage_increments_count(): void
{
$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,
'Test Key',
[],
100,
Carbon::now()->addDay()
);
$newExpiry = Carbon::now()->addMonth();
$key->extendExpiry($newExpiry);
$this->assertEquals(
$newExpiry->toDateTimeString(),
$key->fresh()->expires_at->toDateTimeString()
);
}
public function test_remove_expiry_clears_expiry(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100,
Carbon::now()->addDay()
);
$key->removeExpiry();
$this->assertNull($key->fresh()->expires_at);
}
// =========================================================================
// Scope Tests
// =========================================================================
public function test_active_scope_filters_correctly(): void
{
AgentApiKey::generate($this->workspace, 'Active Key');
$revoked = AgentApiKey::generate($this->workspace, 'Revoked Key');
$revoked->revoke();
AgentApiKey::generate($this->workspace, 'Expired Key', [], 100, Carbon::now()->subDay());
$activeKeys = AgentApiKey::active()->get();
$this->assertCount(1, $activeKeys);
$this->assertEquals('Active Key', $activeKeys->first()->name);
}
public function test_for_workspace_scope_filters_correctly(): void
{
$otherWorkspace = Workspace::factory()->create();
AgentApiKey::generate($this->workspace, 'Our Key');
AgentApiKey::generate($otherWorkspace, 'Their Key');
$ourKeys = AgentApiKey::forWorkspace($this->workspace)->get();
$this->assertCount(1, $ourKeys);
$this->assertEquals('Our Key', $ourKeys->first()->name);
}
public function test_revoked_scope_filters_correctly(): void
{
AgentApiKey::generate($this->workspace, 'Active Key');
$revoked = AgentApiKey::generate($this->workspace, 'Revoked Key');
$revoked->revoke();
$revokedKeys = AgentApiKey::revoked()->get();
$this->assertCount(1, $revokedKeys);
$this->assertEquals('Revoked Key', $revokedKeys->first()->name);
}
public function test_expired_scope_filters_correctly(): void
{
AgentApiKey::generate($this->workspace, 'Active Key');
AgentApiKey::generate($this->workspace, 'Expired Key', [], 100, Carbon::now()->subDay());
$expiredKeys = AgentApiKey::expired()->get();
$this->assertCount(1, $expiredKeys);
$this->assertEquals('Expired Key', $expiredKeys->first()->name);
}
// =========================================================================
// Display Helper Tests
// =========================================================================
public function test_get_masked_key_returns_masked_format(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$masked = $key->getMaskedKey();
$this->assertStringStartsWith('ak_', $masked);
$this->assertStringEndsWith('...', $masked);
}
public function test_get_status_label_returns_correct_label(): void
{
$active = AgentApiKey::generate($this->workspace, 'Active');
$revoked = AgentApiKey::generate($this->workspace, 'Revoked');
$revoked->revoke();
$expired = AgentApiKey::generate($this->workspace, 'Expired', [], 100, Carbon::now()->subDay());
$this->assertEquals('Active', $active->getStatusLabel());
$this->assertEquals('Revoked', $revoked->getStatusLabel());
$this->assertEquals('Expired', $expired->getStatusLabel());
}
public function test_get_status_colour_returns_correct_colour(): void
{
$active = AgentApiKey::generate($this->workspace, 'Active');
$revoked = AgentApiKey::generate($this->workspace, 'Revoked');
$revoked->revoke();
$expired = AgentApiKey::generate($this->workspace, 'Expired', [], 100, Carbon::now()->subDay());
$this->assertEquals('green', $active->getStatusColor());
$this->assertEquals('red', $revoked->getStatusColor());
$this->assertEquals('amber', $expired->getStatusColor());
}
public function test_get_last_used_for_humans_returns_never_when_null(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertEquals('Never', $key->getLastUsedForHumans());
}
public function test_get_last_used_for_humans_returns_diff_when_set(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$key->update(['last_used_at' => Carbon::now()->subHour()]);
$this->assertStringContainsString('ago', $key->getLastUsedForHumans());
}
public function test_get_expires_for_humans_returns_never_when_null(): void
{
$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,
'Test Key',
[],
100,
Carbon::now()->subDay()
);
$this->assertStringContainsString('Expired', $key->getExpiresForHumans());
}
public function test_get_expires_for_humans_returns_expires_when_future(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key',
[],
100,
Carbon::now()->addDay()
);
$this->assertStringContainsString('Expires', $key->getExpiresForHumans());
}
// =========================================================================
// Array Output Tests
// =========================================================================
public function test_to_array_includes_expected_keys(): void
{
$key = AgentApiKey::generate(
$this->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);
// Should NOT include the key hash
$this->assertArrayNotHasKey('key', $array);
}
// =========================================================================
// Available Permissions Tests
// =========================================================================
public function test_available_permissions_returns_all_permissions(): void
{
$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);
}
// =========================================================================
// Relationship Tests
// =========================================================================
public function test_belongs_to_workspace(): void
{
$key = AgentApiKey::generate(
$this->workspace,
'Test Key'
);
$this->assertInstanceOf(Workspace::class, $key->workspace);
$this->assertEquals($this->workspace->id, $key->workspace->id);
}
}