diff --git a/backend/test/Cases/Integration/Auth/ApiKeyAuthTest.php b/backend/test/Cases/Integration/Auth/ApiKeyAuthTest.php new file mode 100644 index 0000000..caf4388 --- /dev/null +++ b/backend/test/Cases/Integration/Auth/ApiKeyAuthTest.php @@ -0,0 +1,272 @@ +guard('jwt')->login($user); + } + + protected function authHeaders(User $user): array + { + return ['Authorization' => 'Bearer ' . $this->getAuthToken($user)]; + } + + protected function createTestUser(array $overrides = []): User + { + $suffix = bin2hex(random_bytes(4)); + + return User::query()->create(array_merge([ + 'username' => 'apikey_int_' . $suffix, + 'password' => 'Pass_' . $suffix, + 'email' => 'apikey_int_' . $suffix . '@example.com', + 'status' => 1, + 'api_key_enabled' => true, + ], $overrides)); + } + + // ========== API Key CRUD ========== + + public function test_generate_api_key_success(): void + { + $user = $this->createTestUser(); + + $response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'IntTest Key', + ], $this->authHeaders($user)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonStructure([ + 'data' => ['plain_key', 'api_key' => ['id', 'name', 'key_prefix', 'enabled']], + ]); + } + + public function test_generate_api_key_without_api_key_enabled_returns_403(): void + { + $user = $this->createTestUser(['api_key_enabled' => false]); + + $response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'Blocked Key', + ], $this->authHeaders($user)); + + $response->assertStatus(403); + $response->assertJsonPath('code', 403); + } + + public function test_generate_api_key_missing_name_returns_400(): void + { + $user = $this->createTestUser(); + + $response = $this->post('/api/v1/me/api-keys', [], $this->authHeaders($user)); + + $response->assertStatus(400); + $response->assertJsonPath('code', 400); + } + + public function test_generate_api_key_without_auth_returns_401(): void + { + $response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'No Auth Key', + ]); + + $response->assertStatus(401); + } + + public function test_list_api_keys(): void + { + $user = $this->createTestUser(); + + // 先创建一个 key + $this->post('/api/v1/me/api-keys', [ + 'name' => 'List Test Key', + ], $this->authHeaders($user)); + + $response = $this->get('/api/v1/me/api-keys', [], $this->authHeaders($user)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_delete_api_key_success(): void + { + $user = $this->createTestUser(); + + // 创建一个 key + $create_response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'Delete Test Key', + ], $this->authHeaders($user)); + + $key_id = $create_response->json('data.api_key.id'); + + $response = $this->delete('/api/v1/me/api-keys/' . $key_id, [], $this->authHeaders($user)); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + } + + public function test_delete_other_users_api_key_returns_404(): void + { + $user_a = $this->createTestUser(); + $user_b = $this->createTestUser(); + + // user_a 创建 key + $create_response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'UserA Key', + ], $this->authHeaders($user_a)); + + $key_id = $create_response->json('data.api_key.id'); + + // user_b 尝试删除 + $response = $this->delete('/api/v1/me/api-keys/' . $key_id, [], $this->authHeaders($user_b)); + + $response->assertStatus(404); + $response->assertJsonPath('code', 404); + } + + // ========== API Key 认证 ========== + + public function test_api_key_auth_access_me_endpoint(): void + { + $user = $this->createTestUser(); + + // 通过 JWT 创建 API Key + $create_response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'Auth Test Key', + ], $this->authHeaders($user)); + + $plain_key = $create_response->json('data.plain_key'); + + // 使用 API Key 访问 /me + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $plain_key, + ]); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.id', $user->id); + } + + public function test_invalid_api_key_returns_401(): void + { + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => 'invalid_key_that_does_not_exist', + ]); + + $response->assertStatus(401); + } + + public function test_disabled_api_key_returns_401(): void + { + $user = $this->createTestUser(); + + $create_response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'Disabled Key', + ], $this->authHeaders($user)); + + $plain_key = $create_response->json('data.plain_key'); + $key_id = $create_response->json('data.api_key.id'); + + // 禁用 key + $api_key = ApiKey::query()->find($key_id); + $api_key->enabled = false; + $api_key->save(); + + // 使用已禁用的 key + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $plain_key, + ]); + + $response->assertStatus(401); + } + + public function test_jwt_takes_priority_over_api_key(): void + { + $user_a = $this->createTestUser(); + $user_b = $this->createTestUser(); + + // user_b 创建 API Key + $create_response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'Priority Test Key', + ], $this->authHeaders($user_b)); + + $plain_key = $create_response->json('data.plain_key'); + + // 同时携带 JWT (user_a) 和 API Key (user_b),应使用 JWT + $response = $this->get('/api/v1/me', [], [ + 'Authorization' => 'Bearer ' . $this->getAuthToken($user_a), + 'X-API-Key' => $plain_key, + ]); + + $response->assertStatus(200); + $response->assertJsonPath('data.id', $user_a->id); + } + + public function test_api_key_updates_last_used_at(): void + { + $user = $this->createTestUser(); + + $create_response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'LastUsed Test Key', + ], $this->authHeaders($user)); + + $plain_key = $create_response->json('data.plain_key'); + $key_id = $create_response->json('data.api_key.id'); + + // 使用 API Key 访问 + $this->get('/api/v1/me', [], [ + 'X-API-Key' => $plain_key, + ]); + + // 验证 last_used_at 已更新 + $api_key = ApiKey::query()->find($key_id); + $this->assertNotNull($api_key->last_used_at); + } + + public function test_disabled_user_api_key_returns_403(): void + { + $user = $this->createTestUser(); + + $create_response = $this->post('/api/v1/me/api-keys', [ + 'name' => 'Disabled User Key', + ], $this->authHeaders($user)); + + $plain_key = $create_response->json('data.plain_key'); + + // 禁用用户 + $user->status = 0; + $user->save(); + + // 使用 API Key 访问 + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $plain_key, + ]); + + $response->assertStatus(403); + } + + public function test_no_credentials_returns_401(): void + { + $response = $this->get('/api/v1/me'); + + $response->assertStatus(401); + } +} diff --git a/backend/test/Cases/Unit/Model/ApiKeyTest.php b/backend/test/Cases/Unit/Model/ApiKeyTest.php index e6e1623..083ccc4 100644 --- a/backend/test/Cases/Unit/Model/ApiKeyTest.php +++ b/backend/test/Cases/Unit/Model/ApiKeyTest.php @@ -16,6 +16,11 @@ class ApiKeyTest extends TestCase { protected function runInCoroutine(callable $callback): void { + if (\Swoole\Coroutine::getCid() > 0) { + $callback(); + return; + } + $exception = null; \Swoole\Coroutine\run(static function () use ($callback, &$exception): void { try { diff --git a/backend/test/Cases/Unit/Model/RoleTest.php b/backend/test/Cases/Unit/Model/RoleTest.php index 1b364c7..5a1b814 100644 --- a/backend/test/Cases/Unit/Model/RoleTest.php +++ b/backend/test/Cases/Unit/Model/RoleTest.php @@ -15,6 +15,11 @@ class RoleTest extends TestCase { protected function runInCoroutine(callable $callback): void { + if (\Swoole\Coroutine::getCid() > 0) { + $callback(); + return; + } + $exception = null; \Swoole\Coroutine\run(static function () use ($callback, &$exception): void { try { diff --git a/backend/test/Cases/Unit/Model/UserRoleTest.php b/backend/test/Cases/Unit/Model/UserRoleTest.php index 615f302..711a9e5 100644 --- a/backend/test/Cases/Unit/Model/UserRoleTest.php +++ b/backend/test/Cases/Unit/Model/UserRoleTest.php @@ -16,6 +16,11 @@ class UserRoleTest extends TestCase { protected function runInCoroutine(callable $callback): void { + if (\Swoole\Coroutine::getCid() > 0) { + $callback(); + return; + } + $exception = null; \Swoole\Coroutine\run(static function () use ($callback, &$exception): void { try {