Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
4.9 KiB
PHP
148 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Auth;
|
|
|
|
use App\Models\User;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\FakesPireon;
|
|
use Tests\TestCase;
|
|
|
|
class PireonLoginTest extends TestCase
|
|
{
|
|
use FakesPireon, RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->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'));
|
|
}
|
|
}
|