152 lines
6.5 KiB
PHP
152 lines
6.5 KiB
PHP
<?php
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Operator;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
it('hands over a signed link instead of relying on a shared cookie', function () {
|
|
$operator = Operator::factory()->role('Owner')->create();
|
|
$customer = Customer::factory()->create();
|
|
|
|
$response = $this->actingAs($operator, 'operator')
|
|
->post(route('admin.impersonate', $customer));
|
|
|
|
// The console and the portal are different hosts in exclusive mode, and a
|
|
// session cookie is host-bound — so the handover cannot be a cookie.
|
|
$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();
|
|
$user = $customer->ensureUser();
|
|
|
|
$url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [
|
|
'customer' => $customer->uuid, 'operator' => $operator->uuid,
|
|
]);
|
|
|
|
$this->get($url)->assertRedirect(route('dashboard'));
|
|
|
|
expect(Auth::guard('web')->id())->toBe($user->id);
|
|
});
|
|
|
|
it('refuses the same link twice', function () {
|
|
$operator = Operator::factory()->role('Owner')->create();
|
|
$customer = Customer::factory()->create();
|
|
$user = $customer->ensureUser();
|
|
|
|
$url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [
|
|
'customer' => $customer->uuid, 'operator' => $operator->uuid,
|
|
]);
|
|
|
|
$this->get($url)->assertRedirect(route('dashboard'));
|
|
Auth::guard('web')->logout();
|
|
|
|
// A link that still works after use is a password with an expiry date.
|
|
$this->get($url)->assertForbidden();
|
|
});
|
|
|
|
it('refuses an expired link', function () {
|
|
$operator = Operator::factory()->role('Owner')->create();
|
|
$customer = Customer::factory()->create();
|
|
$user = $customer->ensureUser();
|
|
|
|
$url = URL::temporarySignedRoute('impersonate.enter', now()->subSecond(), [
|
|
'customer' => $customer->uuid, 'operator' => $operator->uuid,
|
|
]);
|
|
|
|
$this->get($url)->assertForbidden();
|
|
});
|
|
|
|
it('leaves the operator session untouched, so leaving returns to the console', function () {
|
|
$operator = Operator::factory()->role('Owner')->create();
|
|
$customer = Customer::factory()->create();
|
|
$user = $customer->ensureUser();
|
|
|
|
$url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [
|
|
'customer' => $customer->uuid, 'operator' => $operator->uuid,
|
|
]);
|
|
|
|
$this->actingAs($operator, 'operator')->get($url);
|
|
|
|
expect(Auth::guard('operator')->check())->toBeTrue();
|
|
|
|
// "Returns to the console" is the point of this test, not merely
|
|
// decoration on its name — redirect()->to(AdminArea::home()) used to
|
|
// resolve that bare path against whatever host the leave() POST itself
|
|
// arrived on (the portal's), not the console's; see the exclusive-mode
|
|
// test below for where that actually bites.
|
|
$this->post(route('impersonate.leave'))->assertRedirect(route('admin.overview'));
|
|
|
|
expect(Auth::guard('web')->check())->toBeFalse()
|
|
->and(Auth::guard('operator')->check())->toBeTrue();
|
|
});
|
|
|
|
it('signs the link against the portal host, not the console host it was issued from', function () {
|
|
// Exclusive mode is where this bites: the console has a hostname to
|
|
// itself, and RestrictAdminHost 404s any non-console route reached
|
|
// through it. impersonate.enter carries no domain of its own, so a link
|
|
// built and signed against the console's root would die the instant it
|
|
// was followed — precisely the cross-host failure this task exists to
|
|
// fix. A test that only checks `signature=` is present does not catch
|
|
// this: on dev it passes anyway, because exclusive mode is off there.
|
|
config()->set('admin_access.hosts', ['admin.example.test']);
|
|
config()->set('admin_access.exclusive', true);
|
|
config()->set('app.url', 'https://app.example.test');
|
|
|
|
$operator = Operator::factory()->role('Owner')->create();
|
|
$customer = Customer::factory()->create();
|
|
$user = $customer->ensureUser();
|
|
|
|
// Issued exactly as a real operator's click would arrive: on the
|
|
// console's own exclusive host. (Routes are compiled once at boot, under
|
|
// this suite's default non-exclusive ADMIN_HOSTS="" — so the path still
|
|
// carries the fallback /admin prefix; only RestrictAdminHost's own checks
|
|
// read admin_access.* live, per request, which is what is under test.)
|
|
$response = $this->actingAs($operator, 'operator')
|
|
->post('https://admin.example.test/admin/impersonate/'.$customer->uuid);
|
|
|
|
$location = $response->headers->get('Location');
|
|
|
|
// The link has to carry the PORTAL's host, not the console's.
|
|
expect(parse_url($location, PHP_URL_HOST))->toBe('app.example.test');
|
|
|
|
// And it must still genuinely work when followed: valid signature, signs
|
|
// the customer in.
|
|
$this->get($location)->assertRedirect(route('dashboard'));
|
|
|
|
expect(Auth::guard('web')->id())->toBe($user->id);
|
|
});
|