diff --git a/app/Filament/Resources/ApiTokens/Schemas/ApiTokenForm.php b/app/Filament/Resources/ApiTokens/Schemas/ApiTokenForm.php index ed66fbe11..7f5f64118 100644 --- a/app/Filament/Resources/ApiTokens/Schemas/ApiTokenForm.php +++ b/app/Filament/Resources/ApiTokens/Schemas/ApiTokenForm.php @@ -25,6 +25,7 @@ public static function schema(): array 'results:read' => __('api_tokens.read_results'), 'speedtests:run' => __('general.run_speedtest'), 'ookla:list-servers' => __('general.list_servers'), + 'admin:read' => __('api_tokens.admin_read'), ]) ->required() ->bulkToggleable() @@ -32,6 +33,7 @@ public static function schema(): array 'results:read' => __('api_tokens.read_results_description'), 'speedtests:run' => __('api_tokens.run_speedtest_description'), 'ookla:list-servers' => __('api_tokens.list_servers_description'), + 'admin:read' => __('api_tokens.admin_read_description'), ]), DateTimePicker::make('expires_at') ->label(__('api_tokens.expires_at')) diff --git a/app/Http/Controllers/Api/V1/VersionController.php b/app/Http/Controllers/Api/V1/VersionController.php new file mode 100644 index 000000000..df761e886 --- /dev/null +++ b/app/Http/Controllers/Api/V1/VersionController.php @@ -0,0 +1,41 @@ +user()->tokenCant('admin:read')) { + return $this->sendResponse( + data: null, + message: 'You do not have permission to view version information.', + code: Response::HTTP_FORBIDDEN + ); + } + + $latestVersion = Repository::getLatestVersion(); + + return $this->sendResponse( + data: [ + 'app' => [ + 'version' => config('speedtest.build_version'), + 'build_date' => config('speedtest.build_date')->toIso8601String(), + ], + 'updates' => [ + 'latest_version' => $latestVersion ?: null, + 'update_available' => Repository::updateAvailable(), + ], + ] + ); + } +} diff --git a/app/OpenApi/Annotations/V1/VersionAnnotations.php b/app/OpenApi/Annotations/V1/VersionAnnotations.php new file mode 100644 index 000000000..6addd83e9 --- /dev/null +++ b/app/OpenApi/Annotations/V1/VersionAnnotations.php @@ -0,0 +1,48 @@ + []]], + parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), + ], + responses: [ + new OA\Response( + response: Response::HTTP_OK, + description: 'Version information retrieved successfully', + content: new OA\JsonContent(ref: '#/components/schemas/Version') + ), + new OA\Response( + response: Response::HTTP_UNAUTHORIZED, + description: 'Unauthenticated', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError') + ), + new OA\Response( + response: Response::HTTP_FORBIDDEN, + description: 'Forbidden - Missing admin:read token ability', + content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError') + ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), + ] + )] + public function getVersion(): void {} +} diff --git a/app/OpenApi/OpenApiDefinition.php b/app/OpenApi/OpenApiDefinition.php index c0bf78bd5..830833a84 100644 --- a/app/OpenApi/OpenApiDefinition.php +++ b/app/OpenApi/OpenApiDefinition.php @@ -42,6 +42,10 @@ name: 'Stats', description: 'Endpoints for retrieving aggregated statistics and performance metrics. Requires `speedtests:read` token scope.' ), + new OA\Tag( + name: 'Version', + description: 'Endpoint for retrieving application version and update information. Requires `admin:read` token scope.' + ), ] )] class OpenApiDefinition {} diff --git a/app/OpenApi/Schemas/VersionSchema.php b/app/OpenApi/Schemas/VersionSchema.php new file mode 100644 index 000000000..a19ebb15a --- /dev/null +++ b/app/OpenApi/Schemas/VersionSchema.php @@ -0,0 +1,60 @@ + 'The token will have permission to read results and statistics.', 'run_speedtest_description' => 'The token will have permission to run speedtest.', 'list_servers_description' => 'The token will have permission to list servers.', + 'admin_read' => 'Admin read', + 'admin_read_description' => 'The token will have permission to read administrative information such as application version.', ]; diff --git a/openapi.json b/openapi.json index 0c171dfba..fdf3b6e01 100644 --- a/openapi.json +++ b/openapi.json @@ -269,6 +269,69 @@ } } }, + "/api/v1/version": { + "description": "Endpoint for retrieving application version information.", + "get": { + "tags": [ + "Version" + ], + "summary": "Get application version and update information", + "description": "Returns the currently installed application version and checks for available updates. Requires admin:read token ability.", + "operationId": "getVersion", + "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + } + ], + "responses": { + "200": { + "description": "Version information retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + }, + "401": { + "description": "Unauthenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthenticatedError" + } + } + } + }, + "403": { + "description": "Forbidden - Missing admin:read token ability", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/ookla/list-servers": { "get": { "tags": [ @@ -1146,6 +1209,50 @@ } }, "type": "object" + }, + "Version": { + "title": "Version", + "description": "Application version and update information", + "properties": { + "data": { + "properties": { + "app": { + "properties": { + "version": { + "type": "string", + "example": "v1.13.7" + }, + "build_date": { + "type": "string", + "format": "date-time", + "example": "2026-02-04T00:00:00+00:00" + } + }, + "type": "object" + }, + "updates": { + "properties": { + "latest_version": { + "type": "string", + "example": "v1.13.8", + "nullable": true + }, + "update_available": { + "type": "boolean", + "example": true + } + }, + "type": "object" + } + }, + "type": "object" + }, + "message": { + "type": "string", + "example": "ok" + } + }, + "type": "object" } }, "parameters": { @@ -1181,6 +1288,10 @@ "name": "Stats", "description": "Endpoints for retrieving aggregated statistics and performance metrics. Requires `speedtests:read` token scope." }, + { + "name": "Version", + "description": "Endpoint for retrieving application version and update information. Requires `admin:read` token scope." + }, { "name": "Servers", "description": "Servers" diff --git a/routes/api/v1/routes.php b/routes/api/v1/routes.php index 0bc2c6bbc..8f5bae899 100644 --- a/routes/api/v1/routes.php +++ b/routes/api/v1/routes.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\V1\ResultsController; use App\Http\Controllers\Api\V1\SpeedtestController; use App\Http\Controllers\Api\V1\StatsController; +use App\Http\Controllers\Api\V1\VersionController; use Illuminate\Support\Facades\Route; Route::prefix('v1')->name('api.v1.')->group(function () { @@ -24,4 +25,7 @@ Route::get('/stats', StatsController::class) ->name('stats.aggregated'); + + Route::get('/version', VersionController::class) + ->name('version'); }); diff --git a/tests/Feature/Api/VersionEndpointTest.php b/tests/Feature/Api/VersionEndpointTest.php new file mode 100644 index 000000000..51c32d08f --- /dev/null +++ b/tests/Feature/Api/VersionEndpointTest.php @@ -0,0 +1,75 @@ +create(); + + Sanctum::actingAs($user, ['admin:read']); + + $response = $this->getJson('/api/v1/version'); + + $response->assertSuccessful() + ->assertJsonStructure([ + 'data' => [ + 'app' => ['version', 'build_date'], + 'updates' => ['latest_version', 'update_available'], + ], + ]) + ->assertJsonPath('data.app.version', config('speedtest.build_version')); + }); + + test('denies access for users without admin:read ability', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user, ['results:read']); + + $response = $this->getJson('/api/v1/version'); + + $response->assertForbidden() + ->assertJsonPath('message', 'You do not have permission to view version information.'); + }); + + test('requires authentication', function () { + $response = $this->getJson('/api/v1/version'); + + $response->assertUnauthorized(); + }); + + test('includes update information when available', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user, ['admin:read']); + + // Mock the GitHub service to return a known value + Cache::put('github.latest_version', 'v1.13.8', now()->addHour()); + + $response = $this->getJson('/api/v1/version'); + + $response->assertSuccessful() + ->assertJsonPath('data.updates.latest_version', 'v1.13.8') + ->assertJsonPath('data.updates.update_available', true); + }); + + test('handles unavailable update information gracefully', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user, ['admin:read']); + + // Mock GitHub service to return false (unavailable) + Cache::put('github.latest_version', false, now()->addHour()); + + $response = $this->getJson('/api/v1/version'); + + $response->assertSuccessful() + ->assertJsonPath('data.updates.latest_version', null) + ->assertJsonPath('data.updates.update_available', false); + }); +});