Files
denkfabrik-li 28e18497b5 Refuse the last administrator deleting themselves, and keep setup shut
ProfileController::destroy() validates current_password and soft-deletes.
It never asks StaffAccounts::guardLastAdministrator(), and every other
door does: Staff update(), guardDeletable(), and both directions of the
role conversion. This is the one door where the account being removed is
certainly signed in.

An installation with a single administrator therefore had a button that
emptied it. Measured on main:

  DELETE /settings/profile   302, the account is gone
  live staff rows            0    (the row is trashed, not removed)
  anonymous GET /            302 -> /setup
  anonymous POST /setup      a new active System Administrator

EnsureSetupIsComplete asks ->exists(), which excludes trashed rows, and
routes/web.php registers GET and POST setup with no auth and no guest
middleware -- correctly, since a fresh installation has nobody to
authenticate. SetupController::store() re-checks the same condition, so
both halves agreed with each other and both were wrong once the last
staff row was trashed.

Two locks, because one of them is asked at five doors and the other at
one.

First: destroy() now asks guardLastAdministrator(), the same call with
the same message as everywhere else. An administrator with a colleague
still goes, a non-administrator staff member still goes, and a client
still closes their own account.

Second: "has this installation been set up" is not the same question as
"does it have a working administrator right now", and only the first one
belongs in EnsureSetupIsComplete. A trashed staff row is still evidence
that setup happened, so it now counts -- in the middleware and in
SetupController::setupIsComplete(), which have to agree or the result is
either a redirect loop or an open form.

That second lock holds even if a future door forgets the first one.
Measured with the guard bypassed entirely and the row trashed directly:
GET / answers with the login screen and POST /setup creates nothing.

Worth stating plainly: an installation that has already lost its last
administrator will now find setup shut rather than open. That is the
point -- the recovery path for it is `php artisan projectsend:admin`,
which is also how every unattended container installs itself, not a form
that anybody on the internet can reach.

Six tests, two measured red against the unfixed code (2 failed / 4
passed) -- one per lock. The other four are the boundaries: a colleague
present, a staff member who is not an administrator, a client, and a
genuinely fresh installation that must still reach setup.

Two existing tests needed saying more clearly rather than changing:
ProfileUpdateTest's deletion cases now create a second administrator, so
that what they assert is self-deletion and not this new refusal; and
GettingStartedTest's "fresh installation" cases forceDelete rather than
delete, because a soft-deleted staff row is no longer a fresh
installation -- which is the whole of the second lock.

Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:35:41 +02:00

276 lines
10 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Scheduling\ScheduledTaskRun;
use App\Modules\Platform\Scheduling\TaskRunStatus;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Inertia\Testing\AssertableInertia;
/**
* A new installation shows its administrator around, once — and the list
* it shows never points at something this edition or this person cannot
* do.
*/
beforeEach(function () {
$this->admin = User::factory()->create();
// Settings survive RefreshDatabase's rollback in the cache, so state
// both markers rather than assuming their defaults.
app(Settings::class)->set(Setting::GettingStartedPending, false);
app(Settings::class)->set(Setting::UpdateWelcomeTo, '');
});
function justInstalled(): void
{
app(Settings::class)->set(Setting::GettingStartedPending, true);
}
/** @return list<string> */
function quickStartKeys(User $user): array
{
$keys = [];
test()->actingAs($user)->get('/system/getting-started')->assertInertia(
function (AssertableInertia $page) use (&$keys) {
$keys = array_column($page->toArray()['props']['items'], 'key');
},
);
return $keys;
}
test('the main administrator lands on it after installing', function () {
justInstalled();
$this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
});
test('it happens exactly once', function () {
justInstalled();
$this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
$this->actingAs($this->admin)->get('/system/getting-started')->assertOk();
$this->actingAs($this->admin)->get('/dashboard')->assertOk();
});
// Closing it on the way past should not be unrecoverable.
test('it stays readable afterwards, with the welcome wording dropped', function () {
justInstalled();
$this->actingAs($this->admin)->get('/system/getting-started')->assertOk();
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(
fn (AssertableInertia $page) => $page
->component('system/getting-started')
->where('justInstalled', false)
->has('items'),
);
});
test('other staff are not interrupted, but may read it', function () {
justInstalled();
$second = User::factory()->create();
$this->actingAs($second)->get('/dashboard')->assertOk();
$this->actingAs($second)->get('/system/getting-started')->assertOk();
// …and reading it did not consume the greeting.
$this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
});
test('clients cannot reach it', function () {
$client = User::factory()->client()->create();
// EnsureStaff redirects a client away from a staff GET rather than
// answering 403 — see its docblock.
$this->actingAs($client)->get('/system/getting-started')->assertRedirect();
});
test('an installation that merely updated is never welcomed to itself', function () {
app(Settings::class)->set(Setting::UpdateWelcomeFrom, '2.0.0');
app(Settings::class)->set(Setting::UpdateWelcomeTo, '2.1.0');
$this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/whats-new');
});
// Both markers at once cannot happen in practice — an update marker is
// only raised for an installation that already existed — but if it did,
// release notes for a version they never ran are the wrong greeting.
test('installing wins over updating', function () {
justInstalled();
app(Settings::class)->set(Setting::UpdateWelcomeTo, '2.1.0');
$this->actingAs($this->admin)->get('/dashboard')->assertRedirect('/system/getting-started');
});
test('completing setup raises the greeting', function () {
// forceDelete, not delete: "a fresh installation" means no staff row
// at all. A soft-deleted one is evidence that setup already happened,
// and EnsureSetupIsComplete counts it as such so that losing the last
// administrator cannot reopen the first-run form to a stranger.
User::query()->forceDelete();
$this->post('/setup', [
'site_name' => 'Acme Files',
'name' => 'Ada',
'email' => 'ada@example.com',
'password' => 'a-long-enough-password',
'password_confirmation' => 'a-long-enough-password',
])->assertRedirect('/setup/success');
expect(app(Settings::class)->get(Setting::GettingStartedPending))->toBeTrue();
});
// Unattended provisioning skips the setup screen entirely, and is how
// every container that came up from environment variables was installed.
test('provisioning from the command line raises it too', function () {
User::query()->forceDelete();
$this->artisan('projectsend:admin', [
'--name' => 'Ada',
'--email' => 'ada@example.com',
'--password' => 'a-long-enough-password',
])->assertSuccessful();
expect(app(Settings::class)->get(Setting::GettingStartedPending))->toBeTrue();
});
test('--if-none on an installed site raises nothing', function () {
$this->artisan('projectsend:admin', ['--if-none' => true])->assertSuccessful();
expect(app(Settings::class)->get(Setting::GettingStartedPending))->toBeFalse();
});
// The list is the point of the page, and a link to a screen that answers
// 403 is worse than no link at all.
test('it only lists what this person may actually do', function () {
$uploader = User::factory()->role(SystemRole::Uploader)->create();
$keys = quickStartKeys($uploader);
expect($keys)->toContain('upload')
->and($keys)->not->toContain('team')
->and($keys)->not->toContain('email')
->and($keys)->not->toContain('theme');
});
// A managed installation has no mail server to point anywhere and no
// scheduler to check. It does now have staff accounts of its own — that
// changed in 2.2.0 — so 'team' belongs on its list where it once did not.
test('a managed installation is not sent to screens it does not have', function () {
config()->set('projectsend.edition', Edition::Cloud);
$keys = quickStartKeys($this->admin);
expect($keys)->toContain('client', 'upload', 'theme', 'email-theme', 'team')
->and($keys)->not->toContain('email')
->and($keys)->not->toContain('scheduler');
});
test('a self-hosted installation gets the full list', function () {
config()->set('projectsend.edition', Edition::Community);
expect(quickStartKeys($this->admin))->toContain('client', 'upload', 'group', 'theme', 'email-theme', 'email', 'team', 'scheduler');
});
// Two of them can be answered from the database rather than guessed, and
// a tick that means "we assume so" would be worse than no tick.
test('the client and upload steps tick themselves', function () {
justInstalled();
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(
fn (AssertableInertia $page) => $page->where('items.0.key', 'client')->where('items.0.done', false),
);
User::factory()->client()->create();
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(
fn (AssertableInertia $page) => $page->where('items.0.done', true),
);
});
// Not every step is equally urgent, and a list that pretends otherwise is
// a list nobody reads twice. The mail server is the one whose absence is
// silent — nothing announces that password resets are going nowhere.
test('it marks which steps the installation does not work without', function () {
$items = [];
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(function (AssertableInertia $page) use (&$items) {
$items = collect($page->toArray()['props']['items'])->keyBy('key');
});
expect($items['email']['essential'])->toBeTrue()
->and($items['client']['essential'])->toBeTrue()
->and($items['scheduler']['essential'])->toBeTrue()
->and($items['email-theme']['essential'])->toBeFalse()
->and($items['team']['essential'])->toBeFalse();
});
// The email-theme link has to land on the email tab, not on the page with
// a sentence asking the reader to find it.
test('the email theme step deep-links to its own tab', function () {
$items = [];
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(function (AssertableInertia $page) use (&$items) {
$items = $page->toArray()['props']['items'];
});
$emailTheme = collect($items)->firstWhere('key', 'email-theme');
expect($emailTheme['href'])->toContain('tab=email');
});
// The one essential step that could never be completed: it was hardcoded
// unticked, while the screen it links to was already answering the
// question from these very rows.
test('the scheduler step ticks itself once the scheduler has run here', function () {
$items = [];
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(function (AssertableInertia $page) use (&$items) {
$items = collect($page->toArray()['props']['items'])->keyBy('key');
});
expect($items['scheduler']['done'])->toBeFalse();
ScheduledTaskRun::query()->create([
'command' => 'projectsend:purge-expired-files',
'status' => TaskRunStatus::Success,
'message' => null,
'duration_ms' => 12,
'ran_at' => now(),
]);
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(function (AssertableInertia $page) use (&$items) {
$items = collect($page->toArray()['props']['items'])->keyBy('key');
});
expect($items['scheduler']['done'])->toBeTrue();
});
// A run that failed still proves cron reaches this installation, which is
// what the step asks. Why it failed is the Scheduler screen's job.
test('a failed run still counts as the scheduler running', function () {
ScheduledTaskRun::query()->create([
'command' => 'projectsend:fetch-news',
'status' => TaskRunStatus::Failed,
'message' => 'Scheduled command failed with exit code [1].',
'duration_ms' => null,
'ran_at' => now(),
]);
$items = [];
$this->actingAs($this->admin)->get('/system/getting-started')->assertInertia(function (AssertableInertia $page) use (&$items) {
$items = collect($page->toArray()['props']['items'])->keyBy('key');
});
expect($items['scheduler']['done'])->toBeTrue();
});