spec = json_decode((string) file_get_contents(base_path(OpenApiController::PATH)), true); }); test('the document exists and is valid OpenAPI 3.1', function () { expect($this->spec)->toBeArray() ->and($this->spec['openapi'])->toStartWith('3.1') ->and($this->spec['info']['title'])->not->toBeEmpty() ->and($this->spec['info']['version'])->not->toBeEmpty() ->and($this->spec['paths'])->not->toBeEmpty(); }); /* * The real drift check: regenerate and compare. The route-level check * below catches an endpoint appearing or disappearing; this also catches a * changed parameter, response shape or description — every way the * committed contract can quietly stop describing the code. * * Run `php artisan scramble:export` when this fails. */ test('the committed document matches what the code generates', function () { $generated = json_decode(json_encode(app(Generator::class)()), true); expect($generated)->toBe($this->spec); }); test('every documented endpoint is registered, and every registered endpoint is documented', function () { $documented = []; foreach ($this->spec['paths'] as $path => $operations) { foreach (array_keys($operations) as $method) { if (in_array($method, ['get', 'post', 'put', 'patch', 'delete'], true)) { $documented[] = strtoupper($method).' api/v1'.$path; } } } $registered = []; foreach (Route::getRoutes()->getRoutes() as $route) { /** @var RouteInstance $route */ if (! str_starts_with($route->uri(), 'api/v1/')) { continue; } // The document describes itself nowhere, and module endpoints are // excluded from the committed core document on purpose — a package // documents its own surface in its own repository. if ($route->uri() === 'api/v1/openapi.json' || str_starts_with($route->uri(), 'api/v1/modules/')) { continue; } foreach ($route->methods() as $method) { if ($method !== 'HEAD') { $registered[] = $method.' '.$route->uri(); } } } sort($documented); sort($registered); // Both directions: an undocumented endpoint is invisible to every // consumer, and a documented one that no longer exists sends people // chasing a 404. expect($documented)->toBe($registered); }); test('every operation names the abilities it needs', function () { // The single most common reason a first API call fails is a token // missing an ability, and `token-can:` middleware is not something a // reader of the code — or of an inferred spec — can see. $missing = []; foreach ($this->spec['paths'] as $path => $operations) { foreach ($operations as $method => $operation) { if (! in_array($method, ['get', 'post', 'put', 'patch', 'delete'], true)) { continue; } // /me and /tokens/current need no ability beyond a valid token. if (in_array($path, ['/me', '/tokens/current'], true)) { continue; } if (! str_contains((string) ($operation['description'] ?? ''), 'Requires a token with')) { $missing[] = strtoupper($method).' '.$path; } } } expect($missing)->toBe([]); }); test('the document declares bearer authentication', function () { expect($this->spec['components']['securitySchemes'])->not->toBeEmpty() ->and($this->spec['security'])->not->toBeEmpty(); $scheme = reset($this->spec['components']['securitySchemes']); expect($scheme['type'])->toBe('http') ->and($scheme['scheme'])->toBe('bearer'); }); /* * The document is served unauthenticated, which is only defensible because * it describes the shape of the API and nothing about this installation. */ test('the document carries no instance-specific data', function () { $raw = (string) file_get_contents(base_path(OpenApiController::PATH)); expect($raw)->not->toContain('cloud.test') ->and($raw)->not->toContain('localhost:8090') ->and(strtolower($raw))->not->toContain('test@example.com'); // Relative, so it resolves against whichever host served the document. // An absolute URL — Scramble's default, built from APP_URL — would // point every importing client at the machine that ran the export. expect($this->spec['servers'])->toBe([['url' => '/api/v1']]); }); /* * The document must not vary with what is in the database. This is not * hypothetical: the first export embedded `custom_field_values.3` — * an id from the machine that ran it — because the validation rules for * custom fields are built by querying `client_custom_fields`, and Scramble * reflects `$request->validate()`. A committed, unauthenticated document * was describing one installation's configuration. */ test('generation does not depend on the contents of the database', function () { $before = json_decode(json_encode(app(Generator::class)()), true); ClientCustomField::query()->create([ 'name' => 'vat_number', 'label' => 'VAT number', 'type' => ClientCustomFieldType::Text, 'required' => true, 'sort_order' => 1, ]); $after = json_decode(json_encode(app(Generator::class)()), true); expect($after)->toBe($before) ->and(json_encode($after))->not->toContain('vat_number'); }); test('the spec endpoint serves the committed document without a token', function () { User::factory()->create(); $response = $this->getJson('/api/v1/openapi.json')->assertOk(); expect($response->json('info.version'))->toBe($this->spec['info']['version']) ->and($response->headers->get('content-type'))->toContain('application/json'); }); test('the in-app reference lists every endpoint', function () { $staff = User::factory()->create(); $props = $this->actingAs($staff)->get('/api/docs') ->assertOk() ->viewData('page')['props']; $documented = collect($this->spec['paths']) ->flatMap(fn (array $ops, string $path) => collect($ops) ->keys() ->filter(fn (string $m): bool => in_array($m, ['get', 'post', 'put', 'patch', 'delete'], true))) ->count(); expect($props['endpoints'])->toHaveCount($documented) ->and($props['guide_html'])->toContain('

') ->and($props['spec_url'])->toContain('/api/v1/openapi.json'); }); test('clients cannot reach the reference', function () { User::factory()->create(); $client = User::factory()->client()->create(); $this->actingAs($client)->get('/api/docs')->assertRedirect(); });