Initial commit: Pireon Admin — headless admin panel and public API
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>
This commit is contained in:
257
tests/Feature/Auth/PasswordLoginTest.php
Normal file
257
tests/Feature/Auth/PasswordLoginTest.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Filament\Pages\Auth\PireonLogin;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Signing in with an e-mail and a password.
|
||||
*
|
||||
* The identity server still decides whether the credentials are right — there
|
||||
* are no local passwords — but it does so out of sight: nobody signing in is
|
||||
* sent anywhere or told that another server exists.
|
||||
*/
|
||||
class PasswordLoginTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
config()->set('pireon.password_grant_enabled', true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function correct_credentials_sign_somebody_in(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm([
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'correcta',
|
||||
'remember' => true,
|
||||
])
|
||||
->call('authenticate')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$this->assertTrue(Auth::check());
|
||||
|
||||
// The local account is provisioned on the way in, so the very first
|
||||
// page already has roles to authorise against.
|
||||
$user = User::query()->where('email', 'ada@example.com')->sole();
|
||||
$this->assertSame(Auth::id(), $user->id);
|
||||
$this->assertSame(4242, $user->oauth_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_password_goes_to_the_identity_server_and_nowhere_else(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 's3cr3t'])
|
||||
->call('authenticate');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token')
|
||||
&& $request['grant_type'] === 'password'
|
||||
&& $request['password'] === 's3cr3t');
|
||||
|
||||
// It is never written down: the local row has no usable password.
|
||||
$user = User::query()->where('email', 'ada@example.com')->sole();
|
||||
$this->assertNotSame('s3cr3t', $user->password);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wrong_credentials_say_the_same_thing_as_an_unknown_address(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'quien@example.com', 'password' => 'mala'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email']);
|
||||
|
||||
// The identity server distinguishes the two; repeating that here would
|
||||
// turn the form into a way of finding out who has an account.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_identity_server_that_breaks_is_not_blamed_on_the_password(): void
|
||||
{
|
||||
// What a Passport server answers when it cannot sign a token at all —
|
||||
// missing keys, say. It has judged nothing about these credentials.
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500),
|
||||
]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.server_unavailable')]);
|
||||
|
||||
// Saying "those credentials are wrong" here sends somebody to check a
|
||||
// password that was never the problem.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_identity_server_that_cannot_be_reached_says_so_too(): void
|
||||
{
|
||||
Http::fake(fn () => throw new ConnectionException('Connection refused'));
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.server_unavailable')]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_api_token_endpoint_makes_the_same_distinction(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500),
|
||||
]);
|
||||
|
||||
// 503, not 401: nothing was decided about these credentials.
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'correcta',
|
||||
])->assertStatus(503);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_deactivated_account_is_told_why(): void
|
||||
{
|
||||
$this->fakePireon(['is_active' => false]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.inactive')]);
|
||||
|
||||
// Said only once the password was right, so it reveals nothing to
|
||||
// somebody guessing.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_account_still_awaiting_approval_is_told_so(): void
|
||||
{
|
||||
$this->fakePireon(['approved_at' => null]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.pending_approval')]);
|
||||
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_valid_account_with_no_role_here_is_still_refused(): void
|
||||
{
|
||||
// Nothing grants the default role in this test, so the user arrives
|
||||
// with no way into the panel.
|
||||
config()->set('pireon.default_role', null);
|
||||
|
||||
$this->fakePireon();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.no_permission')]);
|
||||
|
||||
// Being a valid account upstream is not the same as being allowed in
|
||||
// here: roles are this application's business.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_says_so_plainly_when_password_sign_in_is_switched_off(): void
|
||||
{
|
||||
config()->set('pireon.password_grant_enabled', false);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.password_grant_disabled')]);
|
||||
|
||||
// A misconfiguration is an operator's problem, not a visitor's, so it
|
||||
// does not masquerade as "those credentials are incorrect".
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_stops_after_five_attempts(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'mala']);
|
||||
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$component->call('authenticate')->assertHasFormErrors(['email']);
|
||||
}
|
||||
|
||||
// The sixth is refused here rather than spending the identity
|
||||
// server's own allowance.
|
||||
Http::fake();
|
||||
$component->call('authenticate');
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_session_id_is_rotated_on_the_way_in(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate');
|
||||
|
||||
// A session fixated before signing in must not survive now that it
|
||||
// carries an identity.
|
||||
$this->assertNotSame($before, session()->getId());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_users_language_follows_them_into_the_panel(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
// A pre-existing account: the default role is only granted to brand
|
||||
// new identities, so this one needs one of its own to get in.
|
||||
User::factory()
|
||||
->create(['email' => 'ada@example.com', 'oauth_id' => 4242, 'locale' => 'ca'])
|
||||
->assignRole('viewer');
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate');
|
||||
|
||||
$this->assertSame('ca', session(config('locales.session_key')));
|
||||
}
|
||||
}
|
||||
147
tests/Feature/Auth/PireonLoginTest.php
Normal file
147
tests/Feature/Auth/PireonLoginTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user