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(); });