Files
projectsend/tests/Feature/Platform/EnsureCapabilityMiddlewareTest.php
T
ignacionelson 6b99e37d01 Merge pull request #1729 from denkfabrik-li/fix/api-surface-by-route
Two places asked "is this the API?", and each got it wrong in the opposite direction.

EnsureCapability asked $request->expectsJson(). Whether a feature exists in this installation's edition is a property of the installation, not of what the caller is willing to parse: the same capability-gated API route answered 403 capability_unavailable to Accept: application/json and a bare 404 to Accept: */*, which is curl's default, while routes/api.php promises the 403 in as many words. The mirror image was worse -- an Inertia visit to a capability-gated web screen accepts JSON, so it took the API branch and announced the feature by name, where the whole point of the 404 is that an unavailable feature is absent rather than teased.

ProblemDetails asked $request->is('api/*'). Two staff pages live under that prefix -- the API dashboard at /api and the OpenAPI reference at /api/docs, both from routes/web.php -- so a signed-out visitor to /api/docs got a 401 problem+json telling them to send a Bearer token instead of the login redirect every other page gives.

One question now, asked once, in App\Support\ApiSurface: under the API prefix, and not part of the web middleware group. The group is what actually separates the two surfaces -- sessions, cookies and CSRF on one side, tokens on the other -- and it keeps answering correctly for a future /api/v2 without being edited. An unmatched path has no route to ask and stays the API's answer, which is what the existing "a missing API route is a problem+json 404" test pins. EnsureStaff keeps its expectsJson() check: there the question really is about the caller.

Verified before merging: the discriminator was checked in the running application rather than assumed -- /api/docs and /api resolve to [web, auth, staff], /api/v1/files to [api, auth:sanctum, api-active, staff-token, token-can:...]. 12 passed on the trial-merge; with app/ reset and ApiSurface deleted, 3 failed / 9 passed, every new test and no old one. Wider suites green: tests/Feature/Api 239 passed, tests/Feature/Platform 485 passed. scramble:export reproduces main's docs/api/openapi.json byte for byte. Worth recording that the blast radius here is the shape of a refusal and never whether one happens: both call sites run after authentication and authorization.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:02:05 -03:00

92 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Modules\Platform\Capabilities\Edition;
use Illuminate\Support\Facades\Route;
beforeEach(function () {
// storage.configure rather than users.manage: this test needs a
// capability that is genuinely Community-only, and users.manage stopped
// being one in 2.2.0 when a platform's seats became a cap rather than a
// closed screen. Any Community-only key would do — this one is picked
// because a managed installation is given its storage, which is the
// clearest example of the edition line the middleware exists to draw.
Route::middleware('capability:storage.configure')->get('/test/community-only', fn () => 'ok');
// storage.managed rather than branding.customize, for the second time
// this file has had to move: branding stopped being Cloud-only on
// 2026-08-28, as users.manage had before it. Both pairs are the same
// key seen from either side -- one edition configures its own storage,
// the other is given storage it cannot see -- which makes them the two
// least likely to move again. If this ever needs picking a third time,
// the question to ask is which capability describes *who operates the
// installation* rather than what the customer is sold.
Route::middleware('capability:storage.managed')->get('/test/cloud-only', fn () => 'ok');
// Under api/, because ProblemDetails is scoped to the API on purpose —
// a refusal on a web route is not supposed to be an RFC 7807 document.
// Not api/v1/, so OpenApiContractTest's documented-vs-registered
// comparison ignores it.
Route::middleware('capability:storage.managed')->get('api/test/cloud-only', fn () => 'ok');
});
test('a capability available in the current edition lets the request through', function () {
config()->set('projectsend.edition', Edition::Community);
$this->get('/test/community-only')->assertOk()->assertSee('ok');
});
test('a capability unavailable in the current edition returns 404 on web requests', function () {
config()->set('projectsend.edition', Edition::Community);
$this->get('/test/cloud-only')->assertNotFound();
});
// The machine-readable half survives, but as an RFC 7807 document like
// every other API error rather than a shape of its own — a caller that
// parses errors once should not have to special-case this one. `type` is
// the slug to branch on; `capability` and `edition` say which feature and
// where, which is the part worth giving up on rather than retrying.
test('a capability unavailable in the current edition returns a machine-readable 403 on API requests', function () {
config()->set('projectsend.edition', Edition::Community);
$this->getJson('/api/test/cloud-only')
->assertForbidden()
->assertHeader('Content-Type', 'application/problem+json')
->assertJson([
'type' => 'capability_unavailable',
'status' => 403,
'capability' => 'storage.managed',
'edition' => 'community',
]);
});
test('an API route answers 403 whatever the caller is willing to parse', function () {
// Accept is the caller's preference; whether a feature exists in this
// edition is not. routes/api.php promises the machine-readable 403,
// and a caller sending */* -- a curl default -- used to get a bare 404
// on the same route instead.
config()->set('projectsend.edition', Edition::Community);
$this->get('api/test/cloud-only', ['Accept' => '*/*'])
->assertForbidden()
->assertHeader('Content-Type', 'application/problem+json')
->assertJson(['type' => 'capability_unavailable']);
});
test('a web route still answers 404 even when the caller asks for JSON', function () {
// The mirror image: an Inertia request accepts JSON, and an
// unavailable feature must stay absent rather than announce itself.
config()->set('projectsend.edition', Edition::Community);
$this->getJson('/test/cloud-only')->assertNotFound();
});
test('the same routes flip availability when running as the cloud edition', function () {
config()->set('projectsend.edition', Edition::Cloud);
$this->get('/test/cloud-only')->assertOk();
$this->get('/test/community-only')->assertNotFound();
});