mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
06c364d29a
Five more facts for whatever watches an installation from outside the container, and one seam so a package can add its own. Storage is the one that was about to be wrong. It is summed from the rows that record it, not measured on the volume: measuring the directory was correct until external storage went live and silently stopped being, since an upload that resolves to a bucket leaves nothing on disk to measure. A figure taken from the filesystem freezes while the account keeps filling, and on a managed installation that figure is what a customer is shown and billed against. `by_disk` splits the same sum by where the bytes went, which is the only way to see what is still sitting locally from before a cutover. Trashed files are excluded because they hold no bytes -- File's deleted hook takes them. Health is what a container cannot show from outside. A queue worker dying is invisible to anything watching the process: it is still up, and zips quietly stop building while mail stops going out. Same for a deploy whose migrations failed -- the application answers every request and is a schema behind. An unreachable queue reports null rather than zero, because an unreachable Redis is not an empty queue and reading the second as the first is how a dead worker looks healthy. The two-factor enforcement setting is echoed back the way EnforceTwoFactor reads it, fallback included: reporting a stricter rule than the middleware actually applies would be worse than reporting none. And ResolvingInstallationStatus, so a package can report what core cannot know. The managed storage backend and the version of the package providing it live in cloud-modules, which this repository must not reference, and a platform that writes eight environment variables only ever knows what it asked for. Those came apart once: a bucket provisioned, a token minted, every variable correct, and an image whose copy of the package predated the module that reads them. Files went to local disk with the configuration sitting perfectly right beside them. Two shapes are cast to objects deliberately. An empty PHP array encodes as [], so an installation with no packages -- or holding no files -- would answer a map-shaped field with a list, and a reader unmarshalling it breaks on the day it happens to be empty rather than the day it is written. There is a test for each. Requested by the ProjectSend Cloud control plane, whose storage figure stops growing the moment a tenant's uploads start reaching the bucket.
235 lines
9.1 KiB
PHP
235 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Platform\Capabilities\Edition;
|
|
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Event;
|
|
|
|
/**
|
|
* The probe a reconciler reads instead of being given a shell one-liner
|
|
* to run. Its contract is the JSON shape, so that is what these pin.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
function statusJson(bool $assoc = true): array
|
|
{
|
|
// Capturing the command's own output rather than asserting on lines,
|
|
// because the contract here is the document and not the wording.
|
|
Artisan::call('projectsend:status', ['--json' => true]);
|
|
|
|
// Decoded as objects where the shape itself is under test: {} and []
|
|
// are the same array once an associative decode has flattened them,
|
|
// and telling them apart is the point of those cases.
|
|
return (array) json_decode(Artisan::output(), $assoc, flags: JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
test('it reports the version, the edition and the capabilities that edition grants', function () {
|
|
config(['projectsend.edition' => Edition::Cloud]);
|
|
|
|
$status = statusJson();
|
|
|
|
expect($status['version'])->toBe(config('projectsend.version'))
|
|
->and($status['edition'])->toBe('cloud')
|
|
->and($status['capabilities'])->toContain('storage.managed', 'platform.managed', 'users.manage');
|
|
});
|
|
|
|
test('the counts are the ones the cap enforces on', function () {
|
|
config([
|
|
'projectsend.platform.max_staff_users' => 3,
|
|
'projectsend.platform.max_clients' => 25,
|
|
]);
|
|
|
|
User::factory()->client()->create();
|
|
User::factory()->client()->create(['account_requested' => true, 'active' => false]);
|
|
User::factory()->create(['active' => false]);
|
|
|
|
$status = statusJson();
|
|
|
|
// Two staff — the admin and the inactive one, which occupies a seat.
|
|
// One client — the pending request does not.
|
|
expect($status['seats']['staff'])->toBe(['used' => 2, 'limit' => 3])
|
|
->and($status['seats']['clients'])->toBe(['used' => 1, 'limit' => 25]);
|
|
});
|
|
|
|
test('unlimited is null rather than zero or a missing key', function () {
|
|
// A reader that mistook one for the other would report an installation
|
|
// selling unlimited accounts as one that may hold none.
|
|
config([
|
|
'projectsend.platform.max_staff_users' => null,
|
|
'projectsend.platform.max_clients' => null,
|
|
]);
|
|
|
|
$status = statusJson();
|
|
|
|
expect($status['seats']['staff'])->toHaveKey('limit')
|
|
->and($status['seats']['staff']['limit'])->toBeNull()
|
|
->and($status['seats']['clients']['limit'])->toBeNull();
|
|
});
|
|
|
|
test('the human form says unlimited in words', function () {
|
|
config(['projectsend.platform.max_staff_users' => null]);
|
|
|
|
$this->artisan('projectsend:status')
|
|
->expectsOutputToContain('of unlimited')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
// -------------------------------------------------------- is anybody there
|
|
|
|
/**
|
|
* The one question a platform cannot answer from outside the container.
|
|
*
|
|
* Written against the real sign-in path rather than by inserting log rows:
|
|
* what makes this trustworthy is that a login writes the entry, and a test
|
|
* that writes its own entry would keep passing after the listener stopped.
|
|
*/
|
|
test('it reports when a staff account last signed in', function () {
|
|
$this->travelTo('2026-08-24 21:13:32');
|
|
Auth::login($this->admin);
|
|
|
|
expect(statusJson()['activity']['last_staff_login_at'])->toBe('2026-08-24T21:13:32+00:00');
|
|
});
|
|
|
|
test('it reports the most recent sign-in, not the first', function () {
|
|
$this->travelTo('2026-08-01 09:00:00');
|
|
Auth::login($this->admin);
|
|
|
|
$this->travelTo('2026-08-24 21:13:32');
|
|
Auth::login(User::factory()->create());
|
|
|
|
expect(statusJson()['activity']['last_staff_login_at'])->toBe('2026-08-24T21:13:32+00:00');
|
|
});
|
|
|
|
test('a client signing in is not a staff sign-in', function () {
|
|
// Clients using an installation says nothing about whether anybody is
|
|
// still administering it, which is the question being asked.
|
|
Auth::login(User::factory()->client()->create());
|
|
|
|
expect(statusJson()['activity']['last_staff_login_at'])->toBeNull();
|
|
});
|
|
|
|
test('never is null, and the key is there to say so', function () {
|
|
// "Nobody has ever signed in" and "we got no answer from the probe"
|
|
// have to stay distinguishable, and a missing key collapses them.
|
|
$status = statusJson();
|
|
|
|
expect($status['activity'])->toHaveKey('last_staff_login_at')
|
|
->and($status['activity']['last_staff_login_at'])->toBeNull();
|
|
});
|
|
|
|
test('an API token is not somebody signing in', function () {
|
|
// An hourly integration must not make a dormant installation look
|
|
// busy. Only Laravel's Login event writes the entry this reads, and
|
|
// token authentication does not fire it.
|
|
Laravel\Sanctum\Sanctum::actingAs($this->admin, ['manage_users']);
|
|
$this->getJson('/api/v1/users')->assertOk();
|
|
|
|
expect(statusJson()['activity']['last_staff_login_at'])->toBeNull();
|
|
});
|
|
|
|
test('the human form says never rather than nothing', function () {
|
|
$this->artisan('projectsend:status')
|
|
->expectsOutputToContain('Last staff login: never')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
// ------------------------------------------------- what the disk cannot say
|
|
|
|
/**
|
|
* Storage is summed from the rows, not measured on the volume.
|
|
*
|
|
* Measuring the directory was right until external storage went live and
|
|
* silently stopped being: an upload that resolves to a bucket leaves
|
|
* nothing on the volume, so a figure taken from the filesystem freezes
|
|
* while the account keeps filling.
|
|
*/
|
|
test('storage is what the installation holds, wherever the bytes went', function () {
|
|
File::factory()->create(['size' => 100, 'disk' => 'files']);
|
|
File::factory()->create(['size' => 250, 'disk' => 'files']);
|
|
File::factory()->create(['size' => 1000, 'disk' => 'files_external']);
|
|
|
|
$storage = statusJson()['storage'];
|
|
|
|
expect($storage['bytes'])->toBe(1350)
|
|
->and($storage['files'])->toBe(3)
|
|
// Split by disk, which is the only way to see what is still
|
|
// sitting locally from before a cutover.
|
|
->and($storage['by_disk'])->toBe([
|
|
'files' => ['bytes' => 350, 'files' => 2],
|
|
'files_external' => ['bytes' => 1000, 'files' => 1],
|
|
]);
|
|
});
|
|
|
|
test('a trashed file is not still costing anything', function () {
|
|
// File's `deleted` hook takes the bytes off disk, so a soft-deleted
|
|
// row records something that is gone rather than something held.
|
|
$file = File::factory()->create(['size' => 500, 'disk' => 'files']);
|
|
File::factory()->create(['size' => 100, 'disk' => 'files']);
|
|
|
|
$file->delete();
|
|
|
|
expect(statusJson()['storage']['bytes'])->toBe(100);
|
|
});
|
|
|
|
test('an installation holding nothing still answers with a map', function () {
|
|
// An empty PHP array encodes as [], and a reader unmarshalling a map
|
|
// breaks on the day it happens to be empty rather than the day it is
|
|
// written.
|
|
expect(json_encode(statusJson(false)['storage']->by_disk))->toBe('{}');
|
|
});
|
|
|
|
test('it reports the health a container cannot show from outside', function () {
|
|
// A queue worker dying is invisible to anything watching the
|
|
// container: it is still up, and zips quietly stop building.
|
|
$health = statusJson()['health'];
|
|
|
|
expect($health)->toHaveKeys(['pending_migrations', 'failed_jobs', 'queues'])
|
|
->and($health['pending_migrations'])->toBe(0)
|
|
->and($health['failed_jobs'])->toBe(0)
|
|
->and($health['queues'])->toHaveKeys(['default', 'zips']);
|
|
});
|
|
|
|
test('the enforcement setting is echoed back as applied', function () {
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
|
|
|
expect(statusJson()['settings']['two_factor_enforcement'])->toBe('all');
|
|
});
|
|
|
|
test('an unreadable enforcement value reports none, not something stricter', function () {
|
|
// Read the way EnforceTwoFactor reads it: reporting a stricter answer
|
|
// than the middleware actually enforces is worse than reporting none.
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'everybody-ish');
|
|
|
|
expect(statusJson()['settings']['two_factor_enforcement'])->toBe('none');
|
|
});
|
|
|
|
// --------------------------------------------- what core cannot answer alone
|
|
|
|
test('a package can report what core has no way to know', function () {
|
|
// The managed storage backend and the version of the package that
|
|
// provides it live outside this repository. A platform knows what it
|
|
// asked for; only the installation knows what loaded.
|
|
Event::listen(ResolvingInstallationStatus::class, function (ResolvingInstallationStatus $event): void {
|
|
$event->report('cloud_modules', '1.1.0');
|
|
$event->report('managed_storage', 's3 bucket "psc-rebels"');
|
|
});
|
|
|
|
expect(statusJson()['modules'])->toBe([
|
|
'cloud_modules' => '1.1.0',
|
|
'managed_storage' => 's3 bucket "psc-rebels"',
|
|
]);
|
|
});
|
|
|
|
test('an installation running no packages answers with a map, not a list', function () {
|
|
expect(json_encode(statusJson(false)['modules']))->toBe('{}');
|
|
});
|