diff --git a/app/Api/Internal/Controllers/HealthController.php b/app/Api/Controllers/HealthController.php similarity index 88% rename from app/Api/Internal/Controllers/HealthController.php rename to app/Api/Controllers/HealthController.php index 98e95f6902..d8da99e8a3 100644 --- a/app/Api/Internal/Controllers/HealthController.php +++ b/app/Api/Controllers/HealthController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Api\Internal\Controllers; +namespace App\Api\Controllers; use App\Http\Controller; use Illuminate\Http\JsonResponse; diff --git a/app/Api/Middleware/ServiceAccountOnly.php b/app/Api/Middleware/ServiceAccountOnly.php index ad89d835dd..5544c0901c 100644 --- a/app/Api/Middleware/ServiceAccountOnly.php +++ b/app/Api/Middleware/ServiceAccountOnly.php @@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next): Response */ private function getAllowedUserIds(): array { - $configValue = config('internal-api.allowed_user_ids', ''); + $configValue = config('api.internal.allowed_user_ids', ''); if (empty($configValue)) { return []; diff --git a/app/Api/RouteServiceProvider.php b/app/Api/RouteServiceProvider.php index aa5c97e5c9..d39c6a9b3b 100755 --- a/app/Api/RouteServiceProvider.php +++ b/app/Api/RouteServiceProvider.php @@ -4,8 +4,8 @@ namespace App\Api; +use App\Api\Controllers\HealthController; use App\Api\Internal\Controllers\AchievementController; -use App\Api\Internal\Controllers\HealthController; use App\Api\Middleware\AddContentLengthHeader; use App\Api\Middleware\LogApiRequest; use App\Api\Middleware\LogLegacyApiUsage; @@ -74,17 +74,17 @@ private function apiRoutes(): void // TODO JSON:API Route::prefix('v2')->group(function () { /** - * list the available connect servers for clients + * API token authenticated endpoints (header-based). + * Logs all requests to track V2 API usage. */ - Route::get('connect', [WebApiController::class, 'connectServers']); + $rateLimit = config('api.v2.rate_limit.requests', 60) . ',' . config('api.v2.rate_limit.minutes', 1); - /** - * Passport guarded - * Note: To have connected clients have access to the web api, too, the client has to send - * auth to both. This is not granted inherently here. - */ - Route::middleware(['auth:passport'])->group(function () { - Route::get('users', [WebApiController::class, 'users']); + Route::middleware([ + LogApiRequest::class . ':v2', + 'auth:api-token-header', // TODO multiauth support with auth:api-token-header,passport + 'throttle:' . $rateLimit, + ])->group(function () { + Route::get('health', [HealthController::class, 'check'])->name('api.v2.health'); }); }); @@ -94,7 +94,7 @@ private function apiRoutes(): void * This is not intended for regular users to access. */ Route::prefix('internal')->group(function () { - $rateLimit = config('internal-api.rate_limit.requests', 60) . ',' . config('internal-api.rate_limit.minutes', 1); + $rateLimit = config('api.internal.rate_limit.requests', 60) . ',' . config('api.internal.rate_limit.minutes', 1); Route::middleware([ LogApiRequest::class . ':internal', diff --git a/app/Api/V2/Controllers/WebApiController.php b/app/Api/V2/Controllers/WebApiController.php index 6b4cedf2ad..37b0fa9cf2 100644 --- a/app/Api/V2/Controllers/WebApiController.php +++ b/app/Api/V2/Controllers/WebApiController.php @@ -5,7 +5,6 @@ namespace App\Api\V2\Controllers; use App\Http\Controller; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class WebApiController extends Controller @@ -14,24 +13,4 @@ public function noop(Request $request, ?string $method = null): void { abort(405, 'Method not allowed'); } - - public function connectServers(Request $request): JsonResponse - { - // TODO JSON:API response - - return response()->json([ - // 'method' => $method, - 'data' => $request->input(), - ], 501); - } - - public function users(Request $request): JsonResponse - { - // TODO JSON:API response - - return response()->json([ - // 'method' => $method, - 'data' => $request->input(), - ], 501); - } } diff --git a/config/api.php b/config/api.php new file mode 100644 index 0000000000..8c099c7f8b --- /dev/null +++ b/config/api.php @@ -0,0 +1,54 @@ + [ + /** + * Allowed Service Accounts + * + * Comma-separated list of user IDs that are allowed to access the internal API. + * These should be service accounts like RABot, NOT regular user accounts. + */ + 'allowed_user_ids' => env('INTERNAL_API_ALLOWED_USER_IDS', ''), + + /** + * Rate Limiting + * + * Rate limit configuration for internal API endpoints. + */ + 'rate_limit' => [ + 'requests' => env('INTERNAL_API_RATE_LIMIT_REQUESTS', 60), + 'minutes' => env('INTERNAL_API_RATE_LIMIT_MINUTES', 1), + ], + ], + + /* + |-------------------------------------------------------------------------- + | V2 API + |-------------------------------------------------------------------------- + | + | Configuration for the V2 public API endpoints. + | + */ + + 'v2' => [ + /** + * Rate Limiting + * + * Rate limit configuration for internal API endpoints. + */ + 'rate_limit' => [ + 'requests' => env('API_V2_RATE_LIMIT_REQUESTS', 60), + 'minutes' => env('API_V2_RATE_LIMIT_MINUTES', 1), + ], + ], +]; diff --git a/config/internal-api.php b/config/internal-api.php deleted file mode 100644 index f5503cdc59..0000000000 --- a/config/internal-api.php +++ /dev/null @@ -1,37 +0,0 @@ - env('INTERNAL_API_ALLOWED_USER_IDS', ''), - - /* - |-------------------------------------------------------------------------- - | Rate Limiting - |-------------------------------------------------------------------------- - | - | Rate limit configuration for internal API endpoints. - | - */ - 'rate_limit' => [ - 'requests' => env('INTERNAL_API_RATE_LIMIT_REQUESTS', 60), - 'minutes' => env('INTERNAL_API_RATE_LIMIT_MINUTES', 1), - ], -]; diff --git a/tests/Feature/Api/Internal/Controllers/AchievementControllerTest.php b/tests/Feature/Api/Internal/Controllers/AchievementControllerTest.php index feb3d94ee7..9bce6db0c2 100644 --- a/tests/Feature/Api/Internal/Controllers/AchievementControllerTest.php +++ b/tests/Feature/Api/Internal/Controllers/AchievementControllerTest.php @@ -53,7 +53,7 @@ public function testItReturnsForbiddenWhenUserIsNotServiceAccount(): void $demotingUser->assignRole(Role::TEAM_ACCOUNT); // ... this user is not in the allowed service accounts list ... - config(['internal-api.allowed_user_ids' => '99999']); + config(['api.internal.allowed_user_ids' => '99999']); // Act $response = $this->patchJson('/api/internal/achievements/' . $achievement->id, [ @@ -96,7 +96,7 @@ public function testItSuccessfullyDemotesAnAchievement(): void ]); // ... this is an actual service account ... - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $demotingUser = User::factory()->create([ 'User' => 'DevCompliance', @@ -165,7 +165,7 @@ public function testItSuccessfullyDemotesAnAchievementWithTitleChange(): void 'User' => 'RABot', 'APIKey' => 'rabot-api-key', ]); - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $demotingUser = User::factory()->create([ 'User' => 'DevCompliance', @@ -216,7 +216,7 @@ public function testItReturnsValidationErrorForMissingId(): void 'User' => 'RABot', 'APIKey' => 'rabot-api-key', ]); - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $achievement = Achievement::factory()->create(); @@ -254,7 +254,7 @@ public function testItReturnsValidationErrorForMissingActingUser(): void 'User' => 'RABot', 'APIKey' => 'rabot-api-key', ]); - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $achievement = Achievement::factory()->create(); @@ -288,7 +288,7 @@ public function testItReturnsErrorForNonExistentAchievement(): void 'User' => 'RABot', 'APIKey' => 'rabot-api-key', ]); - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $demotingUser = User::factory()->create(['User' => 'DevCompliance']); $demotingUser->assignRole(Role::TEAM_ACCOUNT); @@ -333,7 +333,7 @@ public function testItReturnsErrorForNonExistentUsername(): void 'User' => 'RABot', 'APIKey' => 'rabot-api-key', ]); - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $achievement = Achievement::factory()->create(); @@ -370,7 +370,7 @@ public function testItReturnsErrorWhenDemotingAlreadyDemotedAchievement(): void 'User' => 'RABot', 'APIKey' => 'rabot-api-key', ]); - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $demotingUser = User::factory()->create([ 'User' => 'DevCompliance', @@ -418,7 +418,7 @@ public function testItRejectsInvalidJsonApiType(): void 'User' => 'RABot', 'APIKey' => 'rabot-api-key', ]); - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); $achievement = Achievement::factory()->create(); diff --git a/tests/Feature/Api/Internal/Controllers/HealthControllerTest.php b/tests/Feature/Api/Internal/Controllers/HealthControllerTest.php index 9af3b0e47a..548e64850e 100644 --- a/tests/Feature/Api/Internal/Controllers/HealthControllerTest.php +++ b/tests/Feature/Api/Internal/Controllers/HealthControllerTest.php @@ -28,7 +28,7 @@ public function testItReturnsForbiddenWhenUserIsNotServiceAccount(): void User::factory()->create(['APIKey' => 'regular-user-api-key']); // ... this user is not in the allowed service accounts list ... - config(['internal-api.allowed_user_ids' => '99999']); + config(['api.internal.allowed_user_ids' => '99999']); // Act $response = $this->getJson('/api/internal/health', [ @@ -58,7 +58,7 @@ public function testItReturnsOkWhenServiceAccountAuthenticated(): void ]); // ... this is an actual service account ... - config(['internal-api.allowed_user_ids' => (string) $serviceAccount->id]); + config(['api.internal.allowed_user_ids' => (string) $serviceAccount->id]); // Act $response = $this->getJson('/api/internal/health', [ @@ -87,7 +87,7 @@ public function testItAllowsMultipleServiceAccounts(): void ]); // ... configure multiple service accounts as comma-separated IDs ... - config(['internal-api.allowed_user_ids' => "{$serviceAccount1->id},{$serviceAccount2->id}"]); + config(['api.internal.allowed_user_ids' => "{$serviceAccount1->id},{$serviceAccount2->id}"]); // Act $response1 = $this->getJson('/api/internal/health', [ @@ -111,7 +111,7 @@ public function testItHandlesEmptyAllowedUserIdsConfiguration(): void ]); // ... no service accounts are configured ... - config(['internal-api.allowed_user_ids' => '']); + config(['api.internal.allowed_user_ids' => '']); // Act $response = $this->getJson('/api/internal/health', [ diff --git a/tests/Feature/Api/V2/Controllers/HealthControllerTest.php b/tests/Feature/Api/V2/Controllers/HealthControllerTest.php new file mode 100644 index 0000000000..926a208285 --- /dev/null +++ b/tests/Feature/Api/V2/Controllers/HealthControllerTest.php @@ -0,0 +1,88 @@ +getJson('/api/v2/health'); + + // Assert + $response->assertUnauthorized(); + $response->assertJsonPath('message', 'Unauthenticated.'); + } + + public function testItReturnsOkWhenAuthenticated(): void + { + // Arrange + $user = User::factory()->create(['APIKey' => 'test-api-key']); + + // Act + $response = $this->getJson('/api/v2/health', [ + 'X-API-Key' => 'test-api-key', + ]); + + // Assert + $response->assertOk(); + $response->assertJson(['status' => 'ok']); + } + + public function testItReturnsCorrectJsonStructure(): void + { + // Arrange + $user = User::factory()->create(['APIKey' => 'test-api-key']); + + // Act + $response = $this->getJson('/api/v2/health', [ + 'X-API-Key' => 'test-api-key', + ]); + + // Assert + $response->assertJsonStructure([ + 'status', + 'timestamp', + ]); + $response->assertJson(['status' => 'ok']); + + $timestamp = $response->json('timestamp'); + $this->assertNotNull($timestamp); + $this->assertIsString($timestamp); + } + + public function testItLogsApiRequest(): void + { + // Arrange + $user = User::factory()->create(['APIKey' => 'test-api-key']); + + $this->assertDatabaseCount('api_logs', 0); // no logs before request + + // Act + $response = $this->getJson('/api/v2/health', [ + 'X-API-Key' => 'test-api-key', + ]); + + // Assert + $response->assertOk(); + + $this->assertDatabaseCount('api_logs', 1); // logged + + $logEntry = ApiLogEntry::first(); + $this->assertEquals('v2', $logEntry->api_version); // !! logged as V2 API + $this->assertEquals($user->id, $logEntry->user_id); + $this->assertEquals('api/v2/health', $logEntry->endpoint); + $this->assertEquals('GET', $logEntry->method); + $this->assertEquals(200, $logEntry->response_code); + $this->assertGreaterThanOrEqual(0, $logEntry->response_time_ms); + } +}