Files
projectsend/tests/Feature/Api/OpenApiContractTest.php
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

208 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Api\Http\Controllers\OpenApiController;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\Models\ClientCustomField;
use Dedoc\Scramble\Generator;
use Illuminate\Routing\Route as RouteInstance;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| The committed OpenAPI document
|--------------------------------------------------------------------------
|
| docs/api/openapi.json is generated by `php artisan scramble:export` and
| committed, because it is the contract: it is served to callers, and it
| must not vary with whichever optional packages a given server happens to
| have installed.
|
| Committed means it can go stale, which is what these are for. The drift
| check is deliberately a test rather than a CI step — it then runs
| everywhere the suite does, including locally before the drift is pushed.
|
*/
beforeEach(function () {
$this->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('<h1>')
->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();
});