diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 56cee53..2687655 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -8,10 +8,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\UniqueConstraintViolationException; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; -use RuntimeException; class Customer extends Model { @@ -99,9 +97,10 @@ class Customer extends Model /** * Find or create the portal login account for this customer and link it. - * Race-safe against the unique email index, and never links an operator - * account — that would carry admin authorization into an impersonated - * "customer" session. + * Race-safe against the unique email index. Never links an operator + * account by construction, not by a runtime check: operators live in + * `operators`, on their own guard, and are never rows in `users` at all + * (see R21) — there is no longer an admin `users` row this could find. */ public function ensureUser(): User { @@ -110,7 +109,6 @@ class Customer extends Model } if (($existing = User::query()->where('email', $this->email)->first()) !== null) { - $this->assertNotAdmin($existing); $this->update(['user_id' => $existing->id]); return $existing; @@ -121,25 +119,14 @@ class Customer extends Model 'email' => $this->email, 'name' => $this->name, 'password' => Hash::make(Str::random(40)), - 'is_admin' => false, ]); } catch (UniqueConstraintViolationException) { // Concurrent first-time creation — re-fetch and link the winner. $user = User::query()->where('email', $this->email)->firstOrFail(); - $this->assertNotAdmin($user); } $this->update(['user_id' => $user->id]); return $user->refresh(); } - - private function assertNotAdmin(User $user): void - { - if (Auth::guard('operator')->check()) { - throw new RuntimeException( - "Refusing to link operator account {$user->email} as a portal login for customer {$this->id}.", - ); - } - } } diff --git a/tests/Feature/ImpersonationTest.php b/tests/Feature/ImpersonationTest.php index decc340..325b5ee 100644 --- a/tests/Feature/ImpersonationTest.php +++ b/tests/Feature/ImpersonationTest.php @@ -2,6 +2,7 @@ use App\Models\Customer; use App\Models\Operator; +use App\Models\User; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\URL; @@ -17,6 +18,36 @@ it('hands over a signed link instead of relying on a shared cookie', function () $response->assertRedirectContains('signature='); }); +it('impersonates a customer who already has a portal login, with the operator session still active throughout', function () { + // Customer::assertNotAdmin() used to check Auth::guard('operator')->check() + // instead of inspecting the `users` row it was actually handed — so with + // the OPERATOR's own session still attached (same host, non-exclusive/ + // shared mode — exactly the setup start() -> enter() runs under here, + // same test client, same cookies throughout, precisely how a real + // operator's browser carries its session across both legs in shared + // mode), ensureUser() threw the instant it found a `users` row that + // already existed for the customer's email: a 500 on the very first + // impersonation of anyone who had ever signed into the portal + // themselves. The guard is gone now (I5) — this is what "gone" has to + // mean in practice. + $operator = Operator::factory()->role('Owner')->create(); + $customer = Customer::factory()->create(); + + // A portal login that exists independently of any impersonation — e.g. + // the customer signed up themselves before ever being impersonated. + User::factory()->create(['email' => $customer->email]); + + $response = $this->actingAs($operator, 'operator') + ->post(route('admin.impersonate', $customer)); + + $location = $response->headers->get('Location'); + + $this->get($location)->assertRedirect(route('dashboard')); + + expect(Auth::guard('web')->check())->toBeTrue() + ->and(Auth::guard('web')->user()->email)->toBe($customer->email); +}); + it('signs the customer in on the web guard when the link is followed', function () { $operator = Operator::factory()->role('Owner')->create(); $customer = Customer::factory()->create();