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..eac66f1cde 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,44 +1,59 @@
-
-
-
- ./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/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'],
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();
+ });
+ }
+}