mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 17:45:09 +00:00
530f30606d
Groundwork for moving Branding out of the private package. Two changes, both about who decides what an installation may do. An edition grants capabilities; an operator may now take some away, via PROJECTSEND_CAPABILITIES_DISABLED. Subtractive only, and that asymmetry is the whole design: a variable that could *add* would put the hosted edition's proprietary screens one line of .env away on every self-hosted install, which is not a gate at all. So the list is intersected with what the edition already allows and can only make the answer smaller. This is not the plan tier core has always refused to invent. There are still no billing tiers here to key off -- the objection config/api.php makes about rate limits stands. It is the operator stating a fact about this installation, exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does for seats: the platform knows what it sold, the installation is told and enforces. Unknown keys are ignored rather than fatal, because the variable outlives both the plan that wrote it and the release that named the key, and refusing to boot over a stale one would be an outage on upgrade day. The registry takes the list as a constructor argument rather than reading config itself, which keeps it a value object testable without an application -- the failure that surfaced it was a unit test with no container. And branding.customize is now both editions, with the white-label half split into attribution.hide, which stays Cloud-only. Dressing an installation in its own logo is not a hosted concern; taking ProjectSend's name off somebody's public pages is what a hosted customer pays for. The gate on the second is not the key but that the only code able to answer "hide it" ships in the private package, so flipping an edition variable buys nothing. EnsureCapabilityMiddlewareTest had to pick a new Cloud-only example for the second time -- branding after users.manage. It now uses storage.managed, and records what to ask if it ever needs a third. The code move itself is the next commit; nothing user-visible changes yet, because the screens still live in cloud-modules.
71 lines
3.2 KiB
PHP
71 lines
3.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('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();
|
|
});
|