Files
projectsend/tests/Unit/CapabilityRegistryTest.php
T
ignacionelson 4524b75c9d Let a hosted plan switch zip downloads off with downloads.zip
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.
2026-09-24 15:39:44 -03:00

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