Files
projectsend/tests/Feature/Auth/WriteRedirectStatusTest.php
denkfabrik-li fc5651faad Check the read half of the redirect rule at every door, not one
Three middleware answer before HandleInertiaRequests and so have to
repeat its 302→303 upgrade themselves: EnsureSetupIsComplete,
EnsureUserIsActive and EnforceTwoFactor. This file has a write case for
each, and the rule has a second half -- a read still gets a plain 302,
because a 303 there would be an upgrade nobody asked for.

That half was checked once, on the deactivation door, under a name that
said otherwise: "leaves a read alone in every one of those cases". The
setup door and the two-factor door were not covered at all, so a change
that upgraded reads at either of them would have gone through with the
suite green and this test's name still claiming it would not.

Both are covered now, as a dataset with one case per door. The setup case
reads a guest-reachable GET for the same reason the write case posts to
/timezone: anything behind `auth` is answered by the guest redirect before
EnsureSetupIsComplete sees it.

No production code changes; today all three doors answer a read with 302,
which is what the new cases assert. Demonstrated by mutation rather than
reversion: making EnsureSetupIsComplete upgrade every redirect to 303
fails this file (1 failed / 8 passed) and passes the old one (7 passed).
2026-08-28 06:40:54 +02:00

120 lines
4.7 KiB
PHP

<?php
use App\Models\User;
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
// A redirect answered to a PUT/PATCH/DELETE must be a 303, not a 302:
// browsers follow a 302 by replaying the same method on the redirect
// target (only POST is downgraded to GET), so "your session expired,
// please sign in" turns into PUT /login — and /login only takes GET and
// POST, so the user sees an unexplainable 405 instead of the login page.
// Redirects born in exception handling (the guest redirect above all)
// never pass back through Inertia's middleware, which normally does this
// upgrade — bootstrap/app.php repeats it for them. See issue #1673.
it('answers an unauthenticated write with 303 so the browser lands on the login page', function () {
$this->put(route('dashboard.widgets.update'), [])
->assertStatus(303)
->assertRedirect(route('login'));
});
it('answers an unauthenticated delete with 303 as well', function () {
$user = User::factory()->create();
$this->delete(route('users.destroy', $user))
->assertStatus(303)
->assertRedirect(route('login'));
});
it('keeps the plain 302 for unauthenticated reads', function () {
$this->get(route('dashboard'))
->assertStatus(302)
->assertRedirect(route('login'));
});
// The three middleware below answer *before* HandleInertiaRequests, so a
// response they return never unwinds through Inertia's 302→303 upgrade
// either — the same 405 as above, reached a different way. Flagged in
// #1680 as deliberately out of scope there; these cover it.
it('answers a write with 303 when the installation has no administrator yet', function () {
// Deliberately no staff user: that is what EnsureSetupIsComplete
// reacts to, and every other test in the suite creates one.
User::query()->delete();
// PUT /timezone rather than a dashboard route: it is one of only two
// writes a guest can reach, and the only one EnsureSetupIsComplete
// does not exempt. Anything behind `auth` is answered by the guest
// redirect first, which is the case #1680 already covers.
$this->put(route('timezone.update'), ['timezone' => 'UTC'])
->assertStatus(303)
->assertRedirect(route('setup'));
});
it('answers a write with 303 when the account was deactivated mid-session', function () {
$user = User::factory()->create();
$this->actingAs($user);
$user->update(['active' => false]);
$this->put(route('dashboard.widgets.update'), [])
->assertStatus(303)
->assertRedirect(route('login'));
});
it('answers a write with 303 when two-factor enrolment is being enforced', function () {
$user = User::factory()->create();
// Set explicitly rather than relying on the default: the settings
// cache outlives a database rollback in this suite.
app(Settings::class)->set(Setting::TwoFactorEnforcement, TwoFactorEnforcement::All->value);
$this->actingAs($user)
->put(route('dashboard.widgets.update'), [])
->assertStatus(303)
->assertRedirect(route('two-factor.show'));
});
// The other half of the rule, and the half that says the upgrade is
// targeted: a read still gets the plain 302. Once per door, because "every
// one of those cases" was one of them — the deactivation — and a change
// that upgraded reads at either of the other two would have been invisible
// here while this name said otherwise.
it('leaves a read alone at every one of those doors', function (Closure $arrange, string $read, Closure $target) {
$arrange();
$this->get($read)
->assertStatus(302)
->assertRedirect($target());
})->with([
// A guest-reachable GET, for the same reason the write case uses
// PUT /timezone: anything behind `auth` is answered by the guest
// redirect before EnsureSetupIsComplete ever sees it.
'no administrator yet' => [
fn () => User::query()->delete(),
'/login',
fn () => route('setup'),
],
'deactivated mid-session' => [
function (): void {
$user = User::factory()->create();
$user->update(['active' => false]);
test()->actingAs($user);
},
'/dashboard',
fn () => route('login'),
],
'two-factor enrolment enforced' => [
function (): void {
// Set explicitly rather than relying on the default: the
// settings cache outlives a database rollback in this suite.
app(Settings::class)->set(Setting::TwoFactorEnforcement, TwoFactorEnforcement::All->value);
test()->actingAs(User::factory()->create());
},
'/dashboard',
fn () => route('two-factor.show'),
],
]);