A token acts as the person who made it, within the permissions it was given and those they still hold, checked on every request. Permission checks now go through one gate rule, since spatie's answered first and never asked the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
12 KiB
PHP
276 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Models\ApiToken;
|
|
use App\Models\Application;
|
|
use App\Models\Call;
|
|
use App\Models\Category;
|
|
use App\Models\Client;
|
|
use App\Models\Media;
|
|
use App\Models\Member;
|
|
use App\Models\MissingUrl;
|
|
use App\Models\Page;
|
|
use App\Models\Project;
|
|
use App\Models\Redirect;
|
|
use App\Models\Repository;
|
|
use App\Models\Service;
|
|
use App\Models\Setting;
|
|
use App\Models\Talk;
|
|
use App\Models\Technology;
|
|
use App\Models\Testimonial;
|
|
use App\Models\User;
|
|
use App\Support\Maintenance;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\FakesPireon;
|
|
use Tests\TestCase;
|
|
use Tests\VerifiesOpenApiContract;
|
|
|
|
/**
|
|
* The administration API answers with what the specification says it does —
|
|
* `PublicApiContractTest`'s other half.
|
|
*
|
|
* The frontend reads the public document from another repository, in a
|
|
* framework not yet chosen; whoever writes it will never see this code, only
|
|
* the specification. That case does not apply here — an admin API caller is
|
|
* inside this codebase, or at least inside this organisation — but the
|
|
* underlying failure is identical: a field renamed in a `Http\Resources\*`
|
|
* class is a change nothing else in this suite notices, because every other
|
|
* test exercises behaviour, not shape.
|
|
*
|
|
* Unlike the public listings, an admin index and its matching show endpoint
|
|
* send the same full record — none of the resources here has a
|
|
* "lightweight for a listing" projection — so both are held to the stricter
|
|
* rule throughout: everything documented and not `nullable` must be present.
|
|
*/
|
|
class AdminApiContractTest extends TestCase
|
|
{
|
|
use FakesPireon, RefreshDatabase, VerifiesOpenApiContract;
|
|
|
|
protected User $caller;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->loadOpenApiSpec();
|
|
|
|
$this->seed(RolePermissionSeeder::class);
|
|
|
|
// super_admin, so a permission gap never masquerades as a shape
|
|
// mismatch: every request here must reach the resource, not a 403.
|
|
$this->caller = User::factory()->create();
|
|
$this->caller->syncRoles(['super_admin']);
|
|
|
|
$this->withHeaders($this->apiHeadersFor($this->caller));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{path: string}>
|
|
*/
|
|
public static function paginatedEndpoints(): array
|
|
{
|
|
return [
|
|
'pages' => ['path' => '/v1/pages'],
|
|
'categories' => ['path' => '/v1/categories'],
|
|
'users' => ['path' => '/v1/users'],
|
|
'technologies' => ['path' => '/v1/technologies'],
|
|
'projects' => ['path' => '/v1/projects'],
|
|
'members' => ['path' => '/v1/members'],
|
|
'services' => ['path' => '/v1/services'],
|
|
'clients' => ['path' => '/v1/clients'],
|
|
'testimonials' => ['path' => '/v1/testimonials'],
|
|
'talks' => ['path' => '/v1/talks'],
|
|
'calls' => ['path' => '/v1/calls'],
|
|
'applications' => ['path' => '/v1/applications'],
|
|
'redirects' => ['path' => '/v1/redirects'],
|
|
'missing-urls' => ['path' => '/v1/missing-urls'],
|
|
'repositories' => ['path' => '/v1/repositories'],
|
|
'media' => ['path' => '/v1/media'],
|
|
'tokens' => ['path' => '/v1/tokens'],
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
public function every_admin_record_answers_in_the_shape_it_documents(): void
|
|
{
|
|
$page = Page::factory()->create();
|
|
$category = Category::factory()->create();
|
|
$technology = Technology::factory()->create();
|
|
$project = Project::factory()->create();
|
|
$member = Member::factory()->create();
|
|
$service = Service::factory()->create();
|
|
$client = Client::factory()->create();
|
|
$testimonial = Testimonial::factory()->create();
|
|
$talk = Talk::factory()->create();
|
|
$call = Call::factory()->create();
|
|
$application = Application::factory()->for($call)->create();
|
|
$redirect = Redirect::factory()->create();
|
|
$missingUrl = MissingUrl::factory()->create();
|
|
$repository = Repository::factory()->create();
|
|
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
|
$media = Media::factory()->create();
|
|
$page->comment('Primera nota', $this->caller);
|
|
ApiToken::issue($this->caller, 'CI', ['pages.*'], now()->addDay());
|
|
|
|
// One request, one documented path — the URL a real caller hits and
|
|
// the path key that describes it, side by side rather than derived
|
|
// from one another, because guessing the second from the first is
|
|
// exactly the kind of cleverness that breaks quietly.
|
|
$cases = [
|
|
'pages' => ['/v1/pages', '/v1/pages'],
|
|
'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'],
|
|
'categories' => ['/v1/categories', '/v1/categories'],
|
|
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
|
'technologies' => ['/v1/technologies', '/v1/technologies'],
|
|
'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'],
|
|
'projects' => ['/v1/projects', '/v1/projects'],
|
|
'project' => ["/v1/projects/{$project->id}", '/v1/projects/{project}'],
|
|
'members' => ['/v1/members', '/v1/members'],
|
|
'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'],
|
|
'services' => ['/v1/services', '/v1/services'],
|
|
'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'],
|
|
'clients' => ['/v1/clients', '/v1/clients'],
|
|
'client' => ["/v1/clients/{$client->id}", '/v1/clients/{client}'],
|
|
'testimonials' => ['/v1/testimonials', '/v1/testimonials'],
|
|
'testimonial' => ["/v1/testimonials/{$testimonial->id}", '/v1/testimonials/{testimonial}'],
|
|
'talks' => ['/v1/talks', '/v1/talks'],
|
|
'talk' => ["/v1/talks/{$talk->id}", '/v1/talks/{talk}'],
|
|
'calls' => ['/v1/calls', '/v1/calls'],
|
|
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
|
|
'applications' => ['/v1/applications', '/v1/applications'],
|
|
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
|
|
'redirects' => ['/v1/redirects', '/v1/redirects'],
|
|
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
|
|
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],
|
|
'missing-url' => ["/v1/missing-urls/{$missingUrl->id}", '/v1/missing-urls/{missing_url}'],
|
|
'repositories' => ['/v1/repositories', '/v1/repositories'],
|
|
'repository' => ["/v1/repositories/{$repository->id}", '/v1/repositories/{repository}'],
|
|
'settings' => ['/v1/settings', '/v1/settings'],
|
|
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
|
|
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
|
|
'media' => ['/v1/media', '/v1/media'],
|
|
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
|
|
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
|
|
'tokens' => ['/v1/tokens', '/v1/tokens'],
|
|
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
|
|
'users' => ['/v1/users', '/v1/users'],
|
|
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
|
'me' => ['/v1/me', '/v1/me'],
|
|
'roles' => ['/v1/roles', '/v1/roles'],
|
|
];
|
|
|
|
$problems = [];
|
|
|
|
foreach ($cases as $name => [$url, $documentedPath]) {
|
|
$response = $this->getJson('/api'.$url);
|
|
|
|
if ($response->status() !== 200) {
|
|
$problems[] = "{$name}: answered {$response->status()}";
|
|
|
|
continue;
|
|
}
|
|
|
|
$payload = $response->json('data');
|
|
$record = array_is_list((array) $payload) ? ($payload[0] ?? null) : $payload;
|
|
|
|
if (! is_array($record)) {
|
|
$problems[] = "{$name}: no record to compare against";
|
|
|
|
continue;
|
|
}
|
|
|
|
$schema = $this->recordSchema($documentedPath);
|
|
|
|
foreach (array_diff(array_keys($record), $this->documentedKeys($schema)) as $undocumented) {
|
|
$problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe";
|
|
}
|
|
|
|
foreach (array_diff($this->alwaysPresent($schema), array_keys($record)) as $missing) {
|
|
$problems[] = "{$name}: documents `{$missing}`, which it does not send";
|
|
}
|
|
}
|
|
|
|
$this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems));
|
|
}
|
|
|
|
#[Test]
|
|
public function an_upload_answers_in_the_shape_it_documents(): void
|
|
{
|
|
Storage::fake((string) config('media.disk'));
|
|
|
|
$response = $this->post('/api/v1/media', [
|
|
'file' => UploadedFile::fake()->image('hero.jpg', 64, 48),
|
|
], ['Accept' => 'application/json'])->assertCreated();
|
|
|
|
$record = $response->json('data');
|
|
$schema = $this->recordSchema('/v1/media', 'post', 201);
|
|
|
|
$this->assertSame([], array_values(array_diff(array_keys($record), $this->documentedKeys($schema))), 'Sent but not documented');
|
|
$this->assertSame([], array_values(array_diff($this->alwaysPresent($schema), array_keys($record))), 'Documented but not sent');
|
|
}
|
|
|
|
/**
|
|
* The pagination envelope every listing here uses — `data`, `links`,
|
|
* `meta` — checked the same way the record inside it is: nothing sent
|
|
* that is not documented.
|
|
*
|
|
* Worth its own test rather than folding into the one above: the record
|
|
* comparison already proves the endpoints work at all, and a failure here
|
|
* points at a completely different place to fix — the collection schema,
|
|
* not a `Resource` class.
|
|
*/
|
|
#[Test]
|
|
public function the_pagination_envelope_matches_what_is_documented(): void
|
|
{
|
|
Page::factory()->create();
|
|
Category::factory()->create();
|
|
|
|
$problems = [];
|
|
|
|
foreach (self::paginatedEndpoints() as $name => $endpoint) {
|
|
$response = $this->getJson('/api'.$endpoint['path'])->assertOk();
|
|
|
|
$envelope = $this->responseSchema($endpoint['path']);
|
|
$documented = $this->documentedKeys($envelope);
|
|
|
|
foreach (array_diff(array_keys($response->json()), $documented) as $undocumented) {
|
|
$problems[] = "{$name}: envelope sends `{$undocumented}`, which the specification does not describe";
|
|
}
|
|
|
|
$metaSchema = $this->resolve($envelope['properties']['meta'] ?? []);
|
|
$meta = (array) $response->json('meta');
|
|
|
|
foreach (array_diff(array_keys($meta), $this->documentedKeys($metaSchema)) as $undocumented) {
|
|
$problems[] = "{$name}: meta sends `{$undocumented}`, which the specification does not describe";
|
|
}
|
|
|
|
foreach (array_diff($this->alwaysPresent($metaSchema), array_keys($meta)) as $missing) {
|
|
$problems[] = "{$name}: meta documents `{$missing}`, which it does not send";
|
|
}
|
|
}
|
|
|
|
$this->assertSame([], $problems, "The pagination envelope and its specification disagree:\n".implode("\n", $problems));
|
|
}
|
|
|
|
/**
|
|
* `/v1/roles` is the one listing here Laravel never paginates —
|
|
* `RoleController::index()` calls `->get()`, not `->paginate()` — so
|
|
* proving it carries no stray `meta`/`links` is the other half of the
|
|
* envelope test above.
|
|
*/
|
|
#[Test]
|
|
public function the_unpaginated_roles_listing_carries_no_pagination_envelope(): void
|
|
{
|
|
$response = $this->getJson('/api/v1/roles')->assertOk();
|
|
|
|
$this->assertSame(['data'], array_keys($response->json()));
|
|
}
|
|
}
|