From b9729a7542d2613397c4122bf912e0bf27b25cb7 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 21 Jul 2026 09:03:41 +0700 Subject: [PATCH 1/3] Api key lifecylcle --- app/Http/Controllers/Api/ApiKeyController.php | 79 +++++ app/Http/Middleware/ApiKeyMiddleware.php | 45 +++ app/Http/Requests/Api/StoreApiKeyRequest.php | 23 ++ app/Models/ApiKey.php | 75 +++++ app/Models/ApiKeyAuditLog.php | 36 +++ app/Services/KeyService.php | 177 ++++++++++ bootstrap/app.php | 1 + database/factories/ApiKeyFactory.php | 71 ++++ database/factories/SamplePayloadFactory.php | 51 +++ ...026_07_20_000001_create_api_keys_table.php | 29 ++ ...000002_create_api_key_audit_logs_table.php | 28 ++ phpunit.xml | 102 +++--- routes/api.php | 33 ++ tests/ApiKey/ApiKeyIntegrationTest.php | 302 ++++++++++++++++++ tests/ApiKey/KeyServiceTest.php | 251 +++++++++++++++ tests/ApiKeyTestCase.php | 44 +++ tests/Pest.php | 5 + tests/Traits/WithApiKeyTesting.php | 130 ++++++++ 18 files changed, 1439 insertions(+), 43 deletions(-) create mode 100644 app/Http/Controllers/Api/ApiKeyController.php create mode 100644 app/Http/Middleware/ApiKeyMiddleware.php create mode 100644 app/Http/Requests/Api/StoreApiKeyRequest.php create mode 100644 app/Models/ApiKey.php create mode 100644 app/Models/ApiKeyAuditLog.php create mode 100644 app/Services/KeyService.php create mode 100644 database/factories/ApiKeyFactory.php create mode 100644 database/factories/SamplePayloadFactory.php create mode 100644 database/migrations/2026_07_20_000001_create_api_keys_table.php create mode 100644 database/migrations/2026_07_20_000002_create_api_key_audit_logs_table.php create mode 100644 tests/ApiKey/ApiKeyIntegrationTest.php create mode 100644 tests/ApiKey/KeyServiceTest.php create mode 100644 tests/ApiKeyTestCase.php create mode 100644 tests/Traits/WithApiKeyTesting.php diff --git a/app/Http/Controllers/Api/ApiKeyController.php b/app/Http/Controllers/Api/ApiKeyController.php new file mode 100644 index 0000000000..89d1fccc0d --- /dev/null +++ b/app/Http/Controllers/Api/ApiKeyController.php @@ -0,0 +1,79 @@ +user()->id) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json([ + 'data' => $apiKeys, + ]); + } + + public function store(StoreApiKeyRequest $request): JsonResponse + { + $result = $this->keyService->create($request->validated(), $request->user()->id); + + return response()->json([ + 'data' => [ + 'api_key' => $result['api_key'], + 'raw_key' => $result['raw_key'], + ], + 'message' => 'API key created successfully', + ], 201); + } + + public function show(Request $request, ApiKey $apiKey): JsonResponse + { + if ($apiKey->user_id !== $request->user()->id) { + return response()->json(['message' => 'Forbidden'], 403); + } + + return response()->json([ + 'data' => $apiKey, + ]); + } + + public function revoke(Request $request, ApiKey $apiKey): JsonResponse + { + if ($apiKey->user_id !== $request->user()->id) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $this->keyService->revoke($apiKey->id, $request->user()->id); + + return response()->json([ + 'message' => 'API key revoked successfully', + ]); + } + + public function destroy(Request $request, ApiKey $apiKey): JsonResponse + { + if ($apiKey->user_id !== $request->user()->id) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $this->keyService->revoke($apiKey->id, $request->user()->id); + + return response()->json([ + 'message' => 'API key deleted successfully', + ]); + } +} diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php new file mode 100644 index 0000000000..3fa3fd68fb --- /dev/null +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -0,0 +1,45 @@ +bearerToken(); + + if (empty($rawKey)) { + return response()->json([ + 'message' => 'Missing API key', + ], 401); + } + + $result = $this->keyService->validate($rawKey, $scope); + + if (!$result['valid']) { + $statusCode = match ($result['status'] ?? 'invalid') { + 'revoked', 'disabled', 'insufficient_scope' => 403, + 'expired' => 403, + default => 401, + }; + + return response()->json([ + 'message' => $result['message'] ?? 'Invalid API key', + 'status' => $result['status'] ?? 'invalid', + ], $statusCode); + } + + $request->merge(['api_key' => $result['api_key']]); + + return $next($request); + } +} diff --git a/app/Http/Requests/Api/StoreApiKeyRequest.php b/app/Http/Requests/Api/StoreApiKeyRequest.php new file mode 100644 index 0000000000..49d28449e1 --- /dev/null +++ b/app/Http/Requests/Api/StoreApiKeyRequest.php @@ -0,0 +1,23 @@ + 'required|string|max:255', + 'scopes' => 'nullable|array', + 'scopes.*' => 'string|max:100', + 'expires_at' => 'nullable|date|after:now', + ]; + } +} diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php new file mode 100644 index 0000000000..ae63f553d2 --- /dev/null +++ b/app/Models/ApiKey.php @@ -0,0 +1,75 @@ + 'array', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + ]; + + public const STATUS_ACTIVE = 'active'; + public const STATUS_REVOKED = 'revoked'; + public const STATUS_DISABLED = 'disabled'; + public const STATUS_EXPIRED = 'expired'; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function auditLogs(): HasMany + { + return $this->hasMany(ApiKeyAuditLog::class); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function hasScope(string $scope): bool + { + if (empty($this->scopes)) { + return true; + } + + return in_array($scope, $this->scopes, true); + } + + public static function generateKey(): string + { + return 'opendk_' . Str::random(32) . '_' . Str::random(16); + } + + public static function generateKeyPrefix(string $key): string + { + return substr($key, 0, 12) . '...'; + } +} diff --git a/app/Models/ApiKeyAuditLog.php b/app/Models/ApiKeyAuditLog.php new file mode 100644 index 0000000000..7e14f2928a --- /dev/null +++ b/app/Models/ApiKeyAuditLog.php @@ -0,0 +1,36 @@ + 'array', + 'success' => 'boolean', + ]; + + public function apiKey(): BelongsTo + { + return $this->belongsTo(ApiKey::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/KeyService.php b/app/Services/KeyService.php new file mode 100644 index 0000000000..54cb6280de --- /dev/null +++ b/app/Services/KeyService.php @@ -0,0 +1,177 @@ +id(); + + $rawKey = ApiKey::generateKey(); + + $apiKey = ApiKey::create([ + 'user_id' => $userId, + 'name' => $data['name'], + 'key' => Hash::make($rawKey), + 'key_prefix' => ApiKey::generateKeyPrefix($rawKey), + 'scopes' => $data['scopes'] ?? null, + 'expires_at' => $data['expires_at'] ?? null, + 'status' => ApiKey::STATUS_ACTIVE, + ]); + + $this->log($apiKey->id, $userId, 'created'); + + return [ + 'api_key' => $apiKey, + 'raw_key' => $rawKey, + ]; + } + + public function validate(string $rawKey, ?string $requiredScope = null): array + { + $apiKeys = ApiKey::all(); + + foreach ($apiKeys as $apiKey) { + if (!Hash::check($rawKey, $apiKey->key)) { + continue; + } + + if ($apiKey->status !== ApiKey::STATUS_ACTIVE) { + return [ + 'valid' => false, + 'status' => $apiKey->status, + 'message' => 'API key is ' . $apiKey->status, + ]; + } + + if ($apiKey->isExpired()) { + $apiKey->update(['status' => ApiKey::STATUS_EXPIRED]); + $this->log($apiKey->id, null, 'validate.expired', [ + 'reason' => 'key_expired', + ], false); + + return [ + 'valid' => false, + 'status' => ApiKey::STATUS_EXPIRED, + 'message' => 'API key has expired', + ]; + } + + if ($requiredScope !== null && !$apiKey->hasScope($requiredScope)) { + $this->log($apiKey->id, null, 'validate.insufficient_scope', [ + 'required_scope' => $requiredScope, + 'key_scopes' => $apiKey->scopes, + ], false); + + return [ + 'valid' => false, + 'status' => 'insufficient_scope', + 'message' => 'API key does not have the required scope', + ]; + } + + $apiKey->update(['last_used_at' => now()]); + + $this->log($apiKey->id, null, 'validate.success', [ + 'scope' => $requiredScope, + ]); + + return [ + 'valid' => true, + 'api_key' => $apiKey, + ]; + } + + return [ + 'valid' => false, + 'status' => 'invalid', + 'message' => 'API key is invalid', + ]; + } + + public function revoke(int $id, ?int $userId = null): ?ApiKey + { + $userId = $userId ?? auth()->id(); + $apiKey = ApiKey::find($id); + + if (!$apiKey) { + return null; + } + + $apiKey->update(['status' => ApiKey::STATUS_REVOKED]); + $this->log($apiKey->id, $userId, 'revoked'); + + return $apiKey; + } + + public function disable(int $id, ?int $userId = null): ?ApiKey + { + $userId = $userId ?? auth()->id(); + $apiKey = ApiKey::find($id); + + if (!$apiKey) { + return null; + } + + $apiKey->update(['status' => ApiKey::STATUS_DISABLED]); + $this->log($apiKey->id, $userId, 'disabled'); + + return $apiKey; + } + + public function enable(int $id, ?int $userId = null): ?ApiKey + { + $userId = $userId ?? auth()->id(); + $apiKey = ApiKey::find($id); + + if (!$apiKey) { + return null; + } + + $apiKey->update(['status' => ApiKey::STATUS_ACTIVE]); + $this->log($apiKey->id, $userId, 'enabled'); + + return $apiKey; + } + + public function find(string $rawKey): ?ApiKey + { + $apiKeys = ApiKey::all(); + + foreach ($apiKeys as $apiKey) { + if (Hash::check($rawKey, $apiKey->key)) { + return $apiKey; + } + } + + return null; + } + + public function log( + int $apiKeyId, + ?int $userId, + string $action, + ?array $payload = null, + bool $success = true, + ): void { + try { + ApiKeyAuditLog::create([ + 'api_key_id' => $apiKeyId, + 'user_id' => $userId, + 'action' => $action, + 'payload' => $payload, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'success' => $success, + ]); + } catch (\Exception $e) { + Log::error('Failed to create API key audit log: ' . $e->getMessage()); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 3ed4e97520..dcd5abfe82 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -89,6 +89,7 @@ 'xss_sanitization' => \App\Http\Middleware\XssSanitization::class, 'complete_profile' => \App\Http\Middleware\CompleteProfile::class, 'token.registered' => \App\Http\Middleware\TokenRegistered::class, + 'api.key' => \App\Http\Middleware\ApiKeyMiddleware::class, 'track.visitors' => \App\Http\Middleware\TrackVisitors::class, 'otp.enabled' => \App\Http\Middleware\CheckOtpEnabled::class, 'theme.api' => \App\Http\Middleware\ThemeApiMiddleware::class, diff --git a/database/factories/ApiKeyFactory.php b/database/factories/ApiKeyFactory.php new file mode 100644 index 0000000000..3856e047cc --- /dev/null +++ b/database/factories/ApiKeyFactory.php @@ -0,0 +1,71 @@ + User::factory(), + 'name' => $this->faker->words(3, true), + 'key' => Hash::make($rawKey), + 'key_prefix' => ApiKey::generateKeyPrefix($rawKey), + 'scopes' => null, + 'expires_at' => null, + 'last_used_at' => null, + 'status' => ApiKey::STATUS_ACTIVE, + ]; + } + + public function revoked(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ApiKey::STATUS_REVOKED, + ]); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ApiKey::STATUS_DISABLED, + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDay(), + 'status' => ApiKey::STATUS_EXPIRED, + ]); + } + + public function withScope(string|array $scopes): static + { + $scopes = is_string($scopes) ? [$scopes] : $scopes; + + return $this->state(fn (array $attributes) => [ + 'scopes' => $scopes, + ]); + } + + public function withRawKey(string &$rawKey): static + { + $rawKey = ApiKey::generateKey(); + + return $this->state(fn (array $attributes) => [ + 'key' => Hash::make($rawKey), + 'key_prefix' => ApiKey::generateKeyPrefix($rawKey), + ]); + } +} diff --git a/database/factories/SamplePayloadFactory.php b/database/factories/SamplePayloadFactory.php new file mode 100644 index 0000000000..0d33cef2ab --- /dev/null +++ b/database/factories/SamplePayloadFactory.php @@ -0,0 +1,51 @@ + [ + 'id' => $this->faker->uuid(), + 'type' => $this->faker->randomElement(['penduduk', 'keluarga', 'bantuan', 'laporan']), + 'attributes' => [ + 'name' => $this->faker->name(), + 'description' => $this->faker->sentence(), + 'amount' => $this->faker->numberBetween(100000, 10000000), + 'quantity' => $this->faker->numberBetween(1, 100), + 'is_active' => $this->faker->boolean(), + ], + ], + 'meta' => [ + 'version' => '1.0', + 'timestamp' => now()->toIso8601String(), + ], + ]; + } + + public function penduduk(): static + { + return $this->state(fn (array $attributes) => [ + 'data' => [ + 'id' => $this->faker->uuid(), + 'type' => 'penduduk', + 'attributes' => [ + 'nik' => $this->faker->numerify('################'), + 'nama' => $this->faker->name(), + 'tempat_lahir' => $this->faker->city(), + 'tanggal_lahir' => $this->faker->date(), + 'jenis_kelamin' => $this->faker->randomElement(['L', 'P']), + 'alamat' => $this->faker->address(), + ], + ], + 'meta' => [ + 'version' => '1.0', + 'timestamp' => now()->toIso8601String(), + ], + ]); + } +} diff --git a/database/migrations/2026_07_20_000001_create_api_keys_table.php b/database/migrations/2026_07_20_000001_create_api_keys_table.php new file mode 100644 index 0000000000..cb158ad4af --- /dev/null +++ b/database/migrations/2026_07_20_000001_create_api_keys_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedInteger('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->string('key', 64)->unique(); + $table->string('key_prefix', 8); + $table->json('scopes')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->string('status', 20)->default('active'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; diff --git a/database/migrations/2026_07_20_000002_create_api_key_audit_logs_table.php b/database/migrations/2026_07_20_000002_create_api_key_audit_logs_table.php new file mode 100644 index 0000000000..38b36c006d --- /dev/null +++ b/database/migrations/2026_07_20_000002_create_api_key_audit_logs_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('api_key_id')->constrained('api_keys')->cascadeOnDelete(); + $table->unsignedInteger('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('action', 50); + $table->json('payload')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->boolean('success')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_key_audit_logs'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index e1fa6415d5..a85131e0cf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,44 +1,60 @@ - - - - ./tests/Unit - - - ./tests/Feature - - - ./tests/Arch - - - ./tests/Browser - - - - - - - - - - - - - - - - - - - - - - ./app - - - + + + + ./tests/Unit + + + ./tests/Feature + + + ./tests/Arch + + + ./tests/Browser + + + ./tests/ApiKey + + + + + + + + + + + ./app + + + ./app/Console + ./app/Http/Middleware + ./app/Providers + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index b326f3bb9e..b2395e854d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,6 +29,7 @@ * @link https://github.com/OpenSID/opendk */ +use App\Http\Controllers\Api\ApiKeyController; use App\Http\Controllers\Api\Auth\AuthController; use App\Http\Controllers\Api\LaporanApbdesController; use App\Http\Controllers\Api\LaporanPendudukController; @@ -71,6 +72,7 @@ Route::get('test', function () { return response()->json('Welcome to api route'); }); + /** * Penduduk */ @@ -130,4 +132,35 @@ Route::get('download', 'download'); }); }); + + /** + * API Key management (JWT auth only, no token.registered) + */ + Route::group(['prefix' => 'api-keys', 'controller' => ApiKeyController::class, 'middleware' => 'auth:api'], function () { + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::get('{apiKey}', 'show'); + Route::post('{apiKey}/revoke', 'revoke'); + Route::delete('{apiKey}', 'destroy'); + }); + + /** + * API Key validation (stateless, no JWT required) + */ + Route::group(['prefix' => 'key', 'middleware' => 'api.key'], function () { + Route::get('validate', function () { + return response()->json([ + 'message' => 'API key is valid', + 'data' => request('api_key'), + ]); + }); + }); + + Route::group(['prefix' => 'key', 'middleware' => 'api.key:read'], function () { + Route::get('validate-scope', function () { + return response()->json([ + 'message' => 'API key with scope is valid', + ]); + }); + }); }); diff --git a/tests/ApiKey/ApiKeyIntegrationTest.php b/tests/ApiKey/ApiKeyIntegrationTest.php new file mode 100644 index 0000000000..4522995946 --- /dev/null +++ b/tests/ApiKey/ApiKeyIntegrationTest.php @@ -0,0 +1,302 @@ +keyService = app(KeyService::class); +}); + +test('can create api key via controller', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->postJson('/api/v1/api-keys', [ + 'name' => 'My Test Key', + 'scopes' => ['read', 'write'], + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'data' => [ + 'api_key' => [ + 'id', + 'name', + 'key_prefix', + 'status', + 'scopes', + ], + 'raw_key', + ], + 'message', + ]); + + expect($response->json('data.api_key.name'))->toBe('My Test Key'); + expect($response->json('data.api_key.status'))->toBe(ApiKey::STATUS_ACTIVE); + expect($response->json('data.api_key.scopes'))->toBe(['read', 'write']); + expect(str_starts_with($response->json('data.raw_key'), 'opendk_'))->toBeTrue(); + + $this->assertDatabaseHas('api_keys', [ + 'name' => 'My Test Key', + 'user_id' => $this->testUser->id, + ]); +}); + +test('can list api keys', function () { + ApiKey::factory()->count(3)->create([ + 'user_id' => $this->testUser->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->getJson('/api/v1/api-keys'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'name', 'key_prefix', 'status'], + ], + ]); + + expect(count($response->json('data')))->toBe(3); +}); + +test('can show api key detail', function () { + $apiKey = ApiKey::factory()->create([ + 'user_id' => $this->testUser->id, + 'name' => 'Detail Test Key', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->getJson("/api/v1/api-keys/{$apiKey->id}"); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Detail Test Key'); +}); + +test('can revoke api key via controller', function () { + $apiKey = ApiKey::factory()->create([ + 'user_id' => $this->testUser->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->postJson("/api/v1/api-keys/{$apiKey->id}/revoke"); + + $response->assertStatus(200) + ->assertJson([ + 'message' => 'API key revoked successfully', + ]); + + $this->assertDatabaseHas('api_keys', [ + 'id' => $apiKey->id, + 'status' => ApiKey::STATUS_REVOKED, + ]); +}); + +test('can delete api key via controller', function () { + $apiKey = ApiKey::factory()->create([ + 'user_id' => $this->testUser->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->deleteJson("/api/v1/api-keys/{$apiKey->id}"); + + $response->assertStatus(200) + ->assertJson([ + 'message' => 'API key deleted successfully', + ]); + + $this->assertDatabaseHas('api_keys', [ + 'id' => $apiKey->id, + 'status' => ApiKey::STATUS_REVOKED, + ]); +}); + +test('cannot access other users api key', function () { + $otherUser = User::factory()->create(); + $apiKey = ApiKey::factory()->create([ + 'user_id' => $otherUser->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->getJson("/api/v1/api-keys/{$apiKey->id}"); + + $response->assertStatus(403); +}); + +test('cannot revoke other users api key', function () { + $otherUser = User::factory()->create(); + $apiKey = ApiKey::factory()->create([ + 'user_id' => $otherUser->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->postJson("/api/v1/api-keys/{$apiKey->id}/revoke"); + + $response->assertStatus(403); +}); + +test('middleware rejects missing api key with 401', function () { + $response = getJson('/api/v1/key/validate'); + + $response->assertStatus(401) + ->assertJson([ + 'message' => 'Missing API key', + ]); +}); + +test('middleware rejects invalid api key with 401', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer opendk_invalid_key_that_does_not_exist', + ])->getJson('/api/v1/key/validate'); + + $response->assertStatus(401) + ->assertJson([ + 'message' => 'API key is invalid', + ]); +}); + +test('middleware accepts valid api key', function () { + $result = $this->keyService->create([ + 'name' => 'Middleware Test Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$rawKey}", + ])->getJson('/api/v1/key/validate'); + + $response->assertStatus(200) + ->assertJson([ + 'message' => 'API key is valid', + ]); +}); + +test('middleware rejects revoked api key with 403', function () { + $result = $this->keyService->create([ + 'name' => 'Revocable Middleware Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $this->keyService->revoke($result['api_key']->id, $this->testUser->id); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$rawKey}", + ])->getJson('/api/v1/key/validate'); + + $response->assertStatus(403) + ->assertJson([ + 'status' => 'revoked', + ]); +}); + +test('middleware rejects disabled api key with 403', function () { + $result = $this->keyService->create([ + 'name' => 'Disable Middleware Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $this->keyService->disable($result['api_key']->id, $this->testUser->id); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$rawKey}", + ])->getJson('/api/v1/key/validate'); + + $response->assertStatus(403); +}); + +test('middleware rejects insufficient scope with 403', function () { + $result = $this->keyService->create([ + 'name' => 'Scoped Middleware Key', + 'scopes' => ['write'], + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$rawKey}", + ])->getJson('/api/v1/key/validate-scope'); + + $response->assertStatus(403) + ->assertJson([ + 'status' => 'insufficient_scope', + ]); +}); + +test('middleware accepts valid api key with correct scope', function () { + $result = $this->keyService->create([ + 'name' => 'Scoped Middleware Key', + 'scopes' => ['read'], + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$rawKey}", + ])->getJson('/api/v1/key/validate'); + + $response->assertStatus(200); +}); + +test('idempotency - duplicate api key creation returns different keys', function () { + $response1 = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->postJson('/api/v1/api-keys', [ + 'name' => 'Idempotent Key', + ]); + + $response2 = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->postJson('/api/v1/api-keys', [ + 'name' => 'Idempotent Key', + ]); + + $response1->assertStatus(201); + $response2->assertStatus(201); + + expect($response1->json('data.raw_key'))->not->toBe($response2->json('data.raw_key')); +}); + +test('create api key validates required fields', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->postJson('/api/v1/api-keys', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); +}); + +test('create api key validates scopes must be array', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->jwtToken, + ])->postJson('/api/v1/api-keys', [ + 'name' => 'Bad Scope Key', + 'scopes' => 'not-an-array', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['scopes']); +}); + +test('audit log records api key usage through middleware', function () { + $result = $this->keyService->create([ + 'name' => 'Audit Trail Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $this->withHeaders([ + 'Authorization' => "Bearer {$rawKey}", + ])->getJson('/api/v1/key/validate'); + + $this->assertDatabaseHas('api_key_audit_logs', [ + 'api_key_id' => $result['api_key']->id, + 'action' => 'validate.success', + 'success' => true, + ]); +}); diff --git a/tests/ApiKey/KeyServiceTest.php b/tests/ApiKey/KeyServiceTest.php new file mode 100644 index 0000000000..b2f285de6d --- /dev/null +++ b/tests/ApiKey/KeyServiceTest.php @@ -0,0 +1,251 @@ +keyService = app(KeyService::class); +}); + +test('can create an api key', function () { + $result = $this->keyService->create([ + 'name' => 'Test API Key', + ], $this->testUser->id); + + expect($result['api_key'])->toBeInstanceOf(ApiKey::class); + expect($result['api_key']->name)->toBe('Test API Key'); + expect($result['api_key']->status)->toBe(ApiKey::STATUS_ACTIVE); + expect($result['api_key']->user_id)->toBe($this->testUser->id); + expect($result['raw_key'])->toBeString(); + expect(str_starts_with($result['raw_key'], 'opendk_'))->toBeTrue(); + + $this->assertDatabaseHas('api_keys', [ + 'id' => $result['api_key']->id, + 'name' => 'Test API Key', + 'status' => ApiKey::STATUS_ACTIVE, + ]); +}); + +test('can validate a valid api key', function () { + $result = $this->keyService->create([ + 'name' => 'Test Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $validation = $this->keyService->validate($rawKey); + + expect($validation['valid'])->toBeTrue(); + expect($validation['api_key']->id)->toBe($result['api_key']->id); +}); + +test('returns invalid for non-existent key', function () { + $validation = $this->keyService->validate('opendk_nonexistent_key_abc123'); + + expect($validation['valid'])->toBeFalse(); + expect($validation['status'])->toBe('invalid'); +}); + +test('can revoke an api key', function () { + $result = $this->keyService->create([ + 'name' => 'Revocable Key', + ], $this->testUser->id); + $apiKeyId = $result['api_key']->id; + + $revoked = $this->keyService->revoke($apiKeyId, $this->testUser->id); + + expect($revoked)->toBeInstanceOf(ApiKey::class); + expect($revoked->status)->toBe(ApiKey::STATUS_REVOKED); + + $this->assertDatabaseHas('api_keys', [ + 'id' => $apiKeyId, + 'status' => ApiKey::STATUS_REVOKED, + ]); +}); + +test('can disable and enable an api key', function () { + $result = $this->keyService->create([ + 'name' => 'Togglable Key', + ], $this->testUser->id); + $apiKeyId = $result['api_key']->id; + + $disabled = $this->keyService->disable($apiKeyId, $this->testUser->id); + expect($disabled->status)->toBe(ApiKey::STATUS_DISABLED); + + $enabled = $this->keyService->enable($apiKeyId, $this->testUser->id); + expect($enabled->status)->toBe(ApiKey::STATUS_ACTIVE); +}); + +test('revoked key validation fails', function () { + $result = $this->keyService->create([ + 'name' => 'Revocable Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $this->keyService->revoke($result['api_key']->id, $this->testUser->id); + + $validation = $this->keyService->validate($rawKey); + + expect($validation['valid'])->toBeFalse(); +}); + +test('disabled key validation fails', function () { + $result = $this->keyService->create([ + 'name' => 'Disableable Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $this->keyService->disable($result['api_key']->id, $this->testUser->id); + + $validation = $this->keyService->validate($rawKey); + + expect($validation['valid'])->toBeFalse(); +}); + +test('validation fails for insufficient scope', function () { + $result = $this->keyService->create([ + 'name' => 'Scoped Key', + 'scopes' => ['read'], + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $validation = $this->keyService->validate($rawKey, 'write'); + + expect($validation['valid'])->toBeFalse(); + expect($validation['status'])->toBe('insufficient_scope'); +}); + +test('validation passes with correct scope', function () { + $result = $this->keyService->create([ + 'name' => 'Scoped Key', + 'scopes' => ['read', 'write'], + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $validation = $this->keyService->validate($rawKey, 'read'); + + expect($validation['valid'])->toBeTrue(); +}); + +test('unscoped key passes any scope check', function () { + $result = $this->keyService->create([ + 'name' => 'Unscoped Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $validation = $this->keyService->validate($rawKey, 'admin'); + + expect($validation['valid'])->toBeTrue(); +}); + +test('audit log created on key creation', function () { + $result = $this->keyService->create([ + 'name' => 'Audited Key', + ], $this->testUser->id); + + $this->assertDatabaseHas('api_key_audit_logs', [ + 'api_key_id' => $result['api_key']->id, + 'user_id' => $this->testUser->id, + 'action' => 'created', + 'success' => true, + ]); +}); + +test('audit log created on key validation', function () { + $result = $this->keyService->create([ + 'name' => 'Audited Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + + $this->keyService->validate($rawKey); + + $this->assertDatabaseHas('api_key_audit_logs', [ + 'api_key_id' => $result['api_key']->id, + 'action' => 'validate.success', + ]); +}); + +test('audit log created on key revocation', function () { + $result = $this->keyService->create([ + 'name' => 'Audited Key', + ], $this->testUser->id); + + $this->keyService->revoke($result['api_key']->id, $this->testUser->id); + + $this->assertDatabaseHas('api_key_audit_logs', [ + 'api_key_id' => $result['api_key']->id, + 'user_id' => $this->testUser->id, + 'action' => 'revoked', + ]); +}); + +test('revoke returns null for non-existent key', function () { + $result = $this->keyService->revoke(99999, $this->testUser->id); + + expect($result)->toBeNull(); +}); + +test('audit log tracks failed validation attempts', function () { + $validation = $this->keyService->validate('opendk_fake_key_that_does_not_exist'); + + expect($validation['valid'])->toBeFalse(); + expect($validation['status'])->toBe('invalid'); + + $auditCount = ApiKeyAuditLog::where('action', 'validate.success')->count(); + $failCount = ApiKeyAuditLog::where('success', false)->count(); + expect($auditCount)->toBe(0); + expect($failCount)->toBe(0); +}); + +test('idempotency - same user can create multiple keys with same name', function () { + $result1 = $this->keyService->create([ + 'name' => 'Duplicate Name Key', + ], $this->testUser->id); + + $result2 = $this->keyService->create([ + 'name' => 'Duplicate Name Key', + ], $this->testUser->id); + + expect($result1['api_key']->id)->not->toBe($result2['api_key']->id); + expect($result1['raw_key'])->not->toBe($result2['raw_key']); + + $this->assertDatabaseHas('api_keys', [ + 'id' => $result1['api_key']->id, + 'name' => 'Duplicate Name Key', + ]); + $this->assertDatabaseHas('api_keys', [ + 'id' => $result2['api_key']->id, + 'name' => 'Duplicate Name Key', + ]); +}); + +test('expired key validation returns expired status', function () { + $apiKey = ApiKey::factory()->expired()->create([ + 'user_id' => $this->testUser->id, + ]); + $rawKey = 'opendk_test_key_for_expired_check'; + + $apiKey->update(['key' => bcrypt($rawKey)]); + + $validation = $this->keyService->validate($rawKey); + + expect($validation['valid'])->toBeFalse(); + expect($validation['status'])->toBe(ApiKey::STATUS_EXPIRED); +}); + +test('last_used_at is updated on successful validation', function () { + $result = $this->keyService->create([ + 'name' => 'Usage Tracking Key', + ], $this->testUser->id); + $rawKey = $result['raw_key']; + $apiKeyId = $result['api_key']->id; + + expect(ApiKey::find($apiKeyId)->last_used_at)->toBeNull(); + + $this->keyService->validate($rawKey); + + expect(ApiKey::find($apiKeyId)->last_used_at)->not->toBeNull(); +}); diff --git a/tests/ApiKeyTestCase.php b/tests/ApiKeyTestCase.php new file mode 100644 index 0000000000..c863021d89 --- /dev/null +++ b/tests/ApiKeyTestCase.php @@ -0,0 +1,44 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + ]); + + $this->setUpApiKeyTesting(); + } + + protected function tearDown(): void + { + $this->tearDownApiKeyTesting(); + parent::tearDown(); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index f6b50840e0..f65aa811f4 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -32,6 +32,11 @@ // Set headless mode for faster execution }); +// Configure test groups for ApiKey tests +pest()->group('api-key') + ->extend(Tests\ApiKeyTestCase::class) + ->in('ApiKey'); + // Configure browser settings pest()->browser() ->timeout(30000); // Increase timeout to 30 seconds diff --git a/tests/Traits/WithApiKeyTesting.php b/tests/Traits/WithApiKeyTesting.php new file mode 100644 index 0000000000..7f76fd6f7e --- /dev/null +++ b/tests/Traits/WithApiKeyTesting.php @@ -0,0 +1,130 @@ +createApiKeyTestSchema(); + + DB::beginTransaction(); + + $user = User::first(); + if (!$user) { + $user = User::factory()->create(); + } + $this->testUser = $user; + + config(['jwt.secret' => 'test_secret_key_for_testing_only_do_not_use_in_production']); + $this->jwtToken = app(JWT::class)->fromUser($user); + + SettingAplikasi::updateOrCreate( + ['key' => 'api_key_opendk'], + ['value' => $this->jwtToken] + ); + + $this->setDefaultApplicationConfig(); + } + + protected function tearDownApiKeyTesting(): void + { + DB::rollBack(); + } + + private function createApiKeyTestSchema(): void + { + Schema::dropIfExists('api_key_audit_logs'); + Schema::dropIfExists('api_keys'); + Schema::dropIfExists('password_histories'); + Schema::dropIfExists('personal_access_tokens'); + Schema::dropIfExists('password_resets'); + Schema::dropIfExists('das_setting'); + Schema::dropIfExists('users'); + + Schema::create('users', function ($table) { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('status')->default(1); + $table->boolean('otp_enabled')->default(false); + $table->string('otp_channel')->nullable(); + $table->string('otp_identifier')->nullable(); + $table->string('telegram_chat_id')->nullable(); + $table->boolean('two_fa_enabled')->default(false); + $table->rememberToken(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::create('password_histories', function ($table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('password'); + $table->timestamps(); + }); + + Schema::create('personal_access_tokens', function ($table) { + $table->id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('das_setting', function ($table) { + $table->id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->string('kategori')->nullable(); + $table->string('type')->nullable(); + $table->text('description')->nullable(); + $table->text('option')->nullable(); + }); + + Schema::create('password_resets', function ($table) { + $table->string('email')->index(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('api_keys', function ($table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->string('key', 64)->unique(); + $table->string('key_prefix', 12); + $table->json('scopes')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->string('status', 20)->default('active'); + $table->timestamps(); + }); + + Schema::create('api_key_audit_logs', function ($table) { + $table->id(); + $table->foreignId('api_key_id')->constrained('api_keys')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('action', 50); + $table->json('payload')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->boolean('success')->default(true); + $table->timestamps(); + }); + } +} From 45d33db37e6ef1b1a2cddacfaafaf8aa47c080d7 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 21 Jul 2026 09:43:24 +0700 Subject: [PATCH 2/3] perbaikan test --- phpunit.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index a85131e0cf..eac66f1cde 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -49,8 +49,7 @@ - - + From c9bf86df0fd9ed7d8ae088c19319df3ab5c02af3 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Tue, 21 Jul 2026 10:12:51 +0700 Subject: [PATCH 3/3] perbaiki test yang sangat lama pada tests/Feature/Settings/PengaturanDatabaseRestoreTest.php --- tests/Feature/Settings/PengaturanDatabaseRestoreTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php b/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php index b640599534..cc7bceeed2 100644 --- a/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php +++ b/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php @@ -24,6 +24,9 @@ Storage::fake('public'); + // Mock backup:run agar tidak menjalankan Spatie backup nyata saat test + \Illuminate\Support\Facades\Artisan::shouldReceive('call')->with('backup:run')->andReturn(0)->byDefault(); + // Nonaktifkan upload_limit agar tidak mengganggu test SettingAplikasi::updateOrCreate( ['key' => 'upload_limit'],