mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
6e47d76ba6
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.
208 lines
7.6 KiB
PHP
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();
|
|
});
|