seed(RolePermissionSeeder::class); } #[Test] public function it_redirects_to_the_identity_server_with_a_pkce_challenge(): void { $response = $this->get(route('auth.redirect')); $response->assertRedirectContains($this->pireonServer.'/oauth/authorize'); $query = []; parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query); $this->assertSame('code', $query['response_type']); $this->assertSame('test-client-id', $query['client_id']); $this->assertSame('S256', $query['code_challenge_method']); $this->assertNotEmpty($query['code_challenge']); // The verifier itself must stay server-side; only its hash travels. $verifier = session('pireon.code_verifier'); $this->assertNotEmpty($verifier); $this->assertStringNotContainsString($verifier, (string) $response->headers->get('Location')); $expectedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); $this->assertSame($expectedChallenge, $query['code_challenge']); } #[Test] public function it_signs_the_user_in_and_provisions_a_local_account(): void { $this->fakePireon(); $response = $this ->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier']) ->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state'])); $response->assertRedirect(); $user = User::where('oauth_id', 4242)->first(); $this->assertNotNull($user); $this->assertSame('ada@example.com', $user->email); $this->assertAuthenticatedAs($user); // New identities get the least-privileged role, never the app_roles // the identity server reported. $this->assertTrue($user->hasRole(config('pireon.default_role'))); $this->assertFalse($user->hasRole('admin')); $this->assertSame(['user'], $user->remote_roles); } #[Test] public function it_rejects_a_callback_whose_state_does_not_match(): void { $this->fakePireon(); $response = $this ->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier']) ->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'attacker-state'])); $response->assertRedirect(route('login')); $this->assertGuest(); $this->assertDatabaseCount('users', 0); } #[Test] public function it_refuses_an_account_still_awaiting_approval(): void { $this->fakePireon(identity: ['approved_at' => null]); $response = $this ->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v']) ->get(route('auth.callback', ['code' => 'c', 'state' => 's'])); $response->assertRedirect(route('login')); $response->assertSessionHas('error', __('pireon.pending_approval')); $this->assertGuest(); } #[Test] public function it_refuses_a_deactivated_account(): void { $this->fakePireon(identity: ['is_active' => false]); $response = $this ->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v']) ->get(route('auth.callback', ['code' => 'c', 'state' => 's'])); $response->assertSessionHas('error', __('pireon.inactive')); $this->assertGuest(); } #[Test] public function it_adopts_a_pre_existing_local_account_with_the_same_email(): void { $existing = User::factory()->unlinked()->create(['email' => 'ada@example.com']); $existing->assignRole('admin'); $this->fakePireon(); $this->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v']) ->get(route('auth.callback', ['code' => 'c', 'state' => 's'])); $this->assertDatabaseCount('users', 1); $existing->refresh(); $this->assertSame(4242, $existing->oauth_id); // Roles granted locally survive the first real sign-in. $this->assertTrue($existing->hasRole('admin')); } #[Test] public function signing_out_clears_the_stored_oauth_token(): void { $user = $this->userWithRoles('viewer'); $this->actingAs($user) ->withSession(['pireon.token' => ['access_token' => 'abc']]) ->post(route('logout')) ->assertRedirect(route('login')); $this->assertGuest(); $this->assertNull(session('pireon.token')); } }