Delete Customer::assertNotAdmin() — the fault it guarded is now impossible
It checked Auth::guard('operator')->check() instead of the users row it
was handed, so with an operator's own session still attached (shared
mode carries the same cookie through both legs of impersonation) it
threw the moment a customer's email already had a users row — a 500 on
the first impersonation of anyone who had signed into the portal
themselves. Deleting it rather than fixing the check: operators live in
`operators` now, never in `users` (R21), so a users row can no longer
BE an operator account for this to guard against. Also drops the dead
`'is_admin' => false` write — the column reads from nowhere any more.
feat/operator-identity
parent
5a40107256
commit
6e81d6e93e
|
|
@ -8,10 +8,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\UniqueConstraintViolationException;
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class Customer extends Model
|
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.
|
* 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
|
* Race-safe against the unique email index. Never links an operator
|
||||||
* account — that would carry admin authorization into an impersonated
|
* account by construction, not by a runtime check: operators live in
|
||||||
* "customer" session.
|
* `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
|
public function ensureUser(): User
|
||||||
{
|
{
|
||||||
|
|
@ -110,7 +109,6 @@ class Customer extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($existing = User::query()->where('email', $this->email)->first()) !== null) {
|
if (($existing = User::query()->where('email', $this->email)->first()) !== null) {
|
||||||
$this->assertNotAdmin($existing);
|
|
||||||
$this->update(['user_id' => $existing->id]);
|
$this->update(['user_id' => $existing->id]);
|
||||||
|
|
||||||
return $existing;
|
return $existing;
|
||||||
|
|
@ -121,25 +119,14 @@ class Customer extends Model
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'password' => Hash::make(Str::random(40)),
|
'password' => Hash::make(Str::random(40)),
|
||||||
'is_admin' => false,
|
|
||||||
]);
|
]);
|
||||||
} catch (UniqueConstraintViolationException) {
|
} catch (UniqueConstraintViolationException) {
|
||||||
// Concurrent first-time creation — re-fetch and link the winner.
|
// Concurrent first-time creation — re-fetch and link the winner.
|
||||||
$user = User::query()->where('email', $this->email)->firstOrFail();
|
$user = User::query()->where('email', $this->email)->firstOrFail();
|
||||||
$this->assertNotAdmin($user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->update(['user_id' => $user->id]);
|
$this->update(['user_id' => $user->id]);
|
||||||
|
|
||||||
return $user->refresh();
|
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}.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Operator;
|
use App\Models\Operator;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\URL;
|
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=');
|
$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 () {
|
it('signs the customer in on the web guard when the link is followed', function () {
|
||||||
$operator = Operator::factory()->role('Owner')->create();
|
$operator = Operator::factory()->role('Owner')->create();
|
||||||
$customer = Customer::factory()->create();
|
$customer = Customer::factory()->create();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue