POST /api/v1/media stores an image where the panel keeps a page's and answers with the path to send as `featured_image`. Without it the MCP server could create a page but never give it a picture. The file goes through Media::for(), so it is processed, served and pruned like one uploaded in the panel. Two ways in, one per request: a multipart `file`, or JSON with a `url` the API downloads itself. JPEG, PNG, WebP, GIF or AVIF only, decided from the bytes; no SVG, which is a document that can carry script. The URL is fetched from inside the stack's network, so RemoteImage guards it: HTTPS only, every resolved address must be public, the connection is pinned to the checked address with CURLOPT_RESOLVE so a changing DNS answer cannot swap in a private one, redirects are not followed, and the body is read to a ceiling whatever its headers say. Uploading needs permission to write pages. MediaPolicy::create stays false, so the panel gains no free-standing "new file" button. In the MCP server a multipart binary field becomes `<field>_path` over stdio: the server reads the file from the caller's disk and sends it as multipart, instead of the model pushing base64 through its context. Over HTTP the server's disk is not the caller's, so only `body.url` is offered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
7.5 KiB
PHP
198 lines
7.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Models\Category;
|
|
use App\Models\Page;
|
|
use App\Models\User;
|
|
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 four 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'],
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
public function every_admin_record_answers_in_the_shape_it_documents(): void
|
|
{
|
|
$page = Page::factory()->create();
|
|
$category = Category::factory()->create();
|
|
|
|
// 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}'],
|
|
'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()));
|
|
}
|
|
}
|