Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/Api/Middleware/ServiceAccountOnly.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
22 changes: 11 additions & 11 deletions app/Api/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
});

Expand All @@ -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',
Expand Down
21 changes: 0 additions & 21 deletions app/Api/V2/Controllers/WebApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}
54 changes: 54 additions & 0 deletions config/api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

return [
/*
|--------------------------------------------------------------------------
| Internal API
|--------------------------------------------------------------------------
|
| Configuration for the internal service-to-service API used by internal
| tools such as Discord bots, internal scripts, etc.
|
*/

'internal' => [
/**
* 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),
],
],
];
37 changes: 0 additions & 37 deletions config/internal-api.php

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', [
Expand Down Expand Up @@ -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', [
Expand Down Expand Up @@ -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', [
Expand All @@ -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', [
Expand Down
88 changes: 88 additions & 0 deletions tests/Feature/Api/V2/Controllers/HealthControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace Tests\Feature\Api\V2\Controllers;

use App\Models\ApiLogEntry;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class HealthControllerTest extends TestCase
{
use RefreshDatabase;

public function testItReturnsUnauthorizedWhenNoApiKeyProvided(): void
{
// Act
$response = $this->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);
}
}