mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-01 00:01:05 +00:00
4524b75c9d
A new capability, granted by both editions. Self-hosted installs keep zip downloads as they are. A platform removes it through PROJECTSEND_CAPABILITIES_DISABLED. The free shared instances do this, because building an archive holds the zips worker, the disk and a CPU on a server that thousands of accounts share. - The three zip routes sit behind capability:downloads.zip, so a hand-made request gets a 404, not just a missing button. - BuildZipDownloadJob refuses a build that was queued before the key went away. The row ends failed and is never stamped as started. StalledZipBuilds stays quiet when the key is off, so leftover rows raise no worker banner. - The zip buttons are hidden. In the portal, the checkboxes and the selection bar are hidden too, since they exist only to pick files for a zip. Staff /files keeps its checkboxes, which also drive bulk edit. - Archives already built are not touched. They expire on the normal purge schedule. - A guard test walks the router. It fails if any route that reaches ZipDownloadsController, or dispatches the build job, lacks the middleware. No API route builds zips today. The case goes last in the enum, because the control plane reads keys in enum order.
152 lines
7.1 KiB
PHP
152 lines
7.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
|
use App\Modules\Platform\Capabilities\Edition;
|
|
|
|
test('every capability declares at least one edition', function () {
|
|
foreach (Capability::cases() as $capability) {
|
|
expect($capability->editions())->not->toBeEmpty();
|
|
}
|
|
});
|
|
|
|
test('community edition has the self-management capabilities and no cloud exclusives', function () {
|
|
$registry = new CapabilityRegistry(Edition::Community);
|
|
|
|
expect($registry->has(Capability::UsersManage))->toBeTrue()
|
|
->and($registry->has(Capability::StorageConfigure))->toBeTrue()
|
|
->and($registry->has(Capability::EmailTransportConfigure))->toBeTrue()
|
|
->and($registry->has(Capability::SystemUpdates))->toBeTrue()
|
|
->and($registry->has(Capability::SchedulerMonitoring))->toBeTrue()
|
|
->and($registry->has(Capability::CustomAssets))->toBeTrue()
|
|
// Branding is both editions since 2026-08-28. Dressing an
|
|
// installation in its own logo is not a hosted concern; what a
|
|
// hosted *plan* withholds is answered by subtraction below.
|
|
->and($registry->has(Capability::Branding))->toBeTrue()
|
|
// The white-label half is not. Taking ProjectSend's name off the
|
|
// pages a customer's visitors see is what a hosted customer pays
|
|
// for, and the code that can answer "hide it" is not in this repo.
|
|
->and($registry->has(Capability::AttributionHide))->toBeFalse()
|
|
// The counterpart of StorageConfigure above: a self-hosted install
|
|
// configures its own bucket and is never handed one.
|
|
->and($registry->has(Capability::StorageManaged))->toBeFalse()
|
|
// Both editions, and the self-hosted side is the reason it must
|
|
// stay present by default: nobody else supplies this
|
|
// installation's CAPTCHA keys.
|
|
->and($registry->has(Capability::CaptchaConfigure))->toBeTrue();
|
|
});
|
|
|
|
test('cloud edition has cloud exclusives and none of the community-only capabilities', function () {
|
|
$registry = new CapabilityRegistry(Edition::Cloud);
|
|
|
|
expect($registry->has(Capability::Branding))->toBeTrue()
|
|
->and($registry->has(Capability::PlatformManaged))->toBeTrue()
|
|
// Both editions since 2.2.0: a platform provisions seats, it does
|
|
// not decide who fills them. See the case's own comment.
|
|
->and($registry->has(Capability::UsersManage))->toBeTrue()
|
|
->and($registry->has(Capability::StorageManaged))->toBeTrue()
|
|
// Granted here too. A hosted platform closes the CAPTCHA screen by
|
|
// subtracting this key, not by the edition withholding it.
|
|
->and($registry->has(Capability::CaptchaConfigure))->toBeTrue()
|
|
->and($registry->has(Capability::StorageConfigure))->toBeFalse()
|
|
->and($registry->has(Capability::EmailTransportConfigure))->toBeFalse()
|
|
->and($registry->has(Capability::SystemUpdates))->toBeFalse()
|
|
->and($registry->has(Capability::SchedulerMonitoring))->toBeFalse()
|
|
->and($registry->has(Capability::CustomAssets))->toBeFalse();
|
|
});
|
|
|
|
test('enabledKeys returns the string keys of enabled capabilities', function () {
|
|
$registry = new CapabilityRegistry(Edition::Cloud);
|
|
|
|
// Order follows the enum, which is the order the control plane reads
|
|
// them in — see GET /platform/v1/status in cloud-modules.
|
|
expect($registry->enabledKeys())->toBe([
|
|
'users.manage',
|
|
'branding.customize',
|
|
'attribution.hide',
|
|
'storage.managed',
|
|
// Both editions, present by default. It appears in a Cloud
|
|
// instance's keys until the platform names it in
|
|
// PROJECTSEND_CAPABILITIES_DISABLED, which is how the fleet keeps
|
|
// one tenant from switching its CAPTCHA off.
|
|
'captcha.configure',
|
|
'captcha.managed_keys',
|
|
'platform.managed',
|
|
'ai.connector',
|
|
'downloads.zip',
|
|
]);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Subtraction
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| An edition grants; an operator may take away. The asymmetry is the whole
|
|
| design, and these pin it: the list can only ever make the answer smaller.
|
|
*/
|
|
|
|
test('a capability the edition grants can be taken away', function () {
|
|
// The case this was built for: branding on a free hosted plan.
|
|
$registry = new CapabilityRegistry(Edition::Cloud, 'branding.customize');
|
|
|
|
expect($registry->has(Capability::Branding))->toBeFalse()
|
|
->and((new CapabilityRegistry(Edition::Community, 'branding.customize'))->has(Capability::Branding))->toBeFalse();
|
|
});
|
|
|
|
test('taking one away leaves the rest alone', function () {
|
|
$registry = new CapabilityRegistry(Edition::Cloud, 'branding.customize');
|
|
|
|
expect($registry->has(Capability::Branding))->toBeFalse()
|
|
->and($registry->has(Capability::AttributionHide))->toBeTrue()
|
|
->and($registry->has(Capability::StorageManaged))->toBeTrue();
|
|
});
|
|
|
|
test('nothing in the environment can grant a capability the edition lacks', function () {
|
|
// The reason this list is subtractive and not a general override. 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.
|
|
$registry = new CapabilityRegistry(Edition::Community, '');
|
|
|
|
expect($registry->has(Capability::StorageManaged))->toBeFalse()
|
|
->and($registry->has(Capability::AttributionHide))->toBeFalse();
|
|
});
|
|
|
|
test('a stale key names something that no longer exists, and is ignored', function () {
|
|
// The variable outlives both the plan that wrote it and the release
|
|
// that named the key. An instance refusing to boot over one would be a
|
|
// self-inflicted outage on upgrade day.
|
|
$registry = new CapabilityRegistry(Edition::Cloud, 'branding.customize,capability.that.never.existed');
|
|
|
|
expect($registry->has(Capability::Branding))->toBeFalse()
|
|
->and($registry->enabledKeys())->toContain('storage.managed');
|
|
});
|
|
|
|
test('spacing and empty entries are somebody typing, not a different instruction', function () {
|
|
$registry = new CapabilityRegistry(Edition::Cloud, ' branding.customize ,, ');
|
|
|
|
expect($registry->has(Capability::Branding))->toBeFalse();
|
|
});
|
|
|
|
test('an unset or empty list takes nothing away', function () {
|
|
foreach ([null, '', ' '] as $value) {
|
|
$registry = new CapabilityRegistry(Edition::Cloud, $value);
|
|
|
|
expect($registry->has(Capability::Branding))
|
|
->toBeTrue(var_export($value, true).' should disable nothing');
|
|
}
|
|
});
|
|
|
|
test('what the installation reports is what it actually grants', function () {
|
|
// projectsend:status reports enabledKeys(), and a control plane
|
|
// compares it against the plan it wrote. A subtracted capability that
|
|
// still appeared there would make that comparison useless.
|
|
$registry = new CapabilityRegistry(Edition::Cloud, 'branding.customize');
|
|
|
|
expect($registry->enabledKeys())->not->toContain('branding.customize')
|
|
->and($registry->enabledKeys())->toContain('attribution.hide');
|
|
});
|