feat: add routes, lang, tests, stubs, docs, and docker configurations
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('login screen can be rendered', function () {
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('users can authenticate using the login screen', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
});
|
||||
|
||||
test('users can not authenticate with invalid password', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('users can logout', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect('/');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
test('email verification screen can be rendered', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/verify-email');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('email can be verified', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
});
|
||||
|
||||
test('email is not verified with invalid hash', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('confirm password screen can be rendered', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/confirm-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('password can be confirmed', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
test('password is not confirmed with invalid password', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PasswordHistory;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('password update succeeds with valid current password', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'New-Pass-123',
|
||||
'password_confirmation' => 'New-Pass-123',
|
||||
])
|
||||
->assertRedirect('/profile')
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
expect(Hash::check('New-Pass-123', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
test('password update stamps password_changed_at', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 0]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('current-pass'),
|
||||
'password_changed_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'New-Pass-456',
|
||||
'password_confirmation' => 'New-Pass-456',
|
||||
]);
|
||||
|
||||
expect($user->fresh()->password_changed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('wrong current password is rejected', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('real-pass')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-pass',
|
||||
'password' => 'New-Pass-789',
|
||||
'password_confirmation' => 'New-Pass-789',
|
||||
])
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password');
|
||||
|
||||
expect(Hash::check('real-pass', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
test('mismatched confirmation is rejected', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('correct')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'correct',
|
||||
'password' => 'New-Pass-Abc',
|
||||
'password_confirmation' => 'Different-Xyz',
|
||||
])
|
||||
->assertSessionHasErrors();
|
||||
});
|
||||
|
||||
// ── History enforcement ───────────────────────────────────────────────────────
|
||||
|
||||
test('reusing a recent password is rejected with history enabled', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 3]);
|
||||
|
||||
$oldHash = Hash::make('OldPassword1!');
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => $oldHash]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'OldPassword1!',
|
||||
'password_confirmation' => 'OldPassword1!',
|
||||
])
|
||||
->assertSessionHasErrors();
|
||||
});
|
||||
|
||||
test('history check is skipped when history_count is zero', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 0]);
|
||||
|
||||
$oldHash = Hash::make('Recycled-Pass!1');
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => $oldHash]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'Recycled-Pass!1',
|
||||
'password_confirmation' => 'Recycled-Pass!1',
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
// ── History recording ─────────────────────────────────────────────────────────
|
||||
|
||||
test('successful password change records new entry in password history', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 5]);
|
||||
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
|
||||
$this->actingAs($user)->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'Brand-New-777!',
|
||||
'password_confirmation' => 'Brand-New-777!',
|
||||
]);
|
||||
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ── old password no longer works after update ─────────────────────────────────
|
||||
|
||||
test('old password cannot be used to log in after successful update', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('old-pass-456')]);
|
||||
|
||||
$this->actingAs($user)->put('/password', [
|
||||
'current_password' => 'old-pass-456',
|
||||
'password' => 'Brand-New-888!',
|
||||
'password_confirmation' => 'Brand-New-888!',
|
||||
]);
|
||||
|
||||
// Old password must not match the new stored hash
|
||||
expect(Hash::check('old-pass-456', $user->fresh()->password))->toBeFalse();
|
||||
// New password must match
|
||||
expect(Hash::check('Brand-New-888!', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
// ── Guest ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot update password', function () {
|
||||
$this->put('/password', [
|
||||
'current_password' => 'x',
|
||||
'password' => 'y',
|
||||
'password_confirmation' => 'y',
|
||||
])->assertRedirect('/login');
|
||||
});
|
||||
|
||||
// ── JSON response ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('json request receives json success response', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->putJson('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'Json-Pass-1!',
|
||||
'password_confirmation' => 'Json-Pass-1!',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\Auth\ResetPasswordNotification as ResetPassword;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('reset password link screen can be rendered', function () {
|
||||
$response = $this->get('/forgot-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('reset password link can be requested', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
});
|
||||
|
||||
test('reset password screen can be rendered', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get('/reset-password/'.$notification->token);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('password can be reset with valid token', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post('/reset-password', [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
test('password can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
});
|
||||
|
||||
test('correct password must be provided to update password', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password')
|
||||
->assertRedirect('/profile');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
test('registration screen can be rendered', function () {
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('new users can register', function () {
|
||||
// Ensure the default role exists (not seeded in test DB)
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'Password1!',
|
||||
'password_confirmation' => 'Password1!',
|
||||
'agree_tos_pdp' => '1',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Session fixation prevention tests.
|
||||
*
|
||||
* Verifies that all authentication flows regenerate the session ID
|
||||
* after a successful login, preventing session fixation attacks.
|
||||
*/
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
// ── Web login ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('web login regenerates the session id', function () {
|
||||
$user = User::factory()->create();
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// After login the session must have a different ID
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── 2FA verify ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('2fa verify regenerates the session id', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '123456');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/2fa', ['code' => '123456']);
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── OAuth / Socialite callback ────────────────────────────────────────────────
|
||||
|
||||
test('oauth callback regenerates the session id after successful login', function () {
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
app(SystemConfigService::class)->update(['feature_google_oauth' => true]);
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
$socialUser = new Laravel\Socialite\Two\User;
|
||||
$socialUser->id = 'google-fixation-id';
|
||||
$socialUser->name = 'Fixation User';
|
||||
$socialUser->email = 'fixation@example.com';
|
||||
$socialUser->avatar = '';
|
||||
$socialUser->user = [];
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn($socialUser);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── Password reset ────────────────────────────────────────────────────────────
|
||||
|
||||
test('password reset regenerates the session id', function () {
|
||||
Notification::fake();
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Request a reset token
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/reset-password', [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
'password' => 'NewSecurePass1!',
|
||||
'password_confirmation' => 'NewSecurePass1!',
|
||||
])->assertRedirect(route('login'));
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── Impersonation ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('starting impersonation regenerates the session id', function () {
|
||||
$perm = \App\Models\Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']);
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->givePermissionTo($perm);
|
||||
$target = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->actingAs($admin)->post("/impersonate/{$target->id}");
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
test('stopping impersonation regenerates the session id', function () {
|
||||
$perm = \App\Models\Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']);
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->givePermissionTo($perm);
|
||||
$target = User::factory()->create(['is_active' => true]);
|
||||
|
||||
// Start impersonation first
|
||||
$this->actingAs($admin)->post("/impersonate/{$target->id}");
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/impersonate/stop');
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUserContract;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
});
|
||||
|
||||
function enableOauth(string $provider): void
|
||||
{
|
||||
app(SystemConfigService::class)->update(['feature_'.$provider.'_oauth' => true]);
|
||||
}
|
||||
|
||||
function fakeSocialiteUser(array $overrides = []): SocialiteUserContract
|
||||
{
|
||||
$u = new Laravel\Socialite\Two\User;
|
||||
$u->id = $overrides['id'] ?? 'oauth-id-123';
|
||||
$u->name = $overrides['name'] ?? 'OAuth User';
|
||||
$u->email = $overrides['email'] ?? 'oauth@example.com';
|
||||
$u->avatar = $overrides['avatar'] ?? 'https://example.com/avatar.png';
|
||||
$u->user = $overrides['user'] ?? [];
|
||||
|
||||
return $u;
|
||||
}
|
||||
|
||||
test('redirect returns 404 when provider feature is disabled', function () {
|
||||
$this->get('/auth/google')->assertNotFound();
|
||||
});
|
||||
|
||||
test('redirect issues a redirect when provider feature is enabled', function () {
|
||||
enableOauth('google');
|
||||
|
||||
Socialite::shouldReceive('driver->redirect')
|
||||
->andReturn(redirect('https://accounts.google.com/o/oauth2/auth?fake=1'));
|
||||
|
||||
$this->get('/auth/google')->assertRedirect();
|
||||
expect(session('social_auth_provider'))->toBe('google');
|
||||
});
|
||||
|
||||
test('callback without provider session redirects to login with error', function () {
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
});
|
||||
|
||||
test('callback rejects unverified email from provider', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'user' => ['email_verified' => false],
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('callback creates a new user via provider id and assigns user role', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'google-uid-9',
|
||||
'email' => 'fresh@example.com',
|
||||
'name' => 'Fresh User',
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
|
||||
$user = User::where('email', 'fresh@example.com')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->google_id)->toBe('google-uid-9');
|
||||
expect($user->hasRole('User'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('callback links to existing user with matching email when no provider id yet', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
$existing = User::factory()->create([
|
||||
'email' => 'link@example.com',
|
||||
'google_id' => null,
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'google-uid-link',
|
||||
'email' => 'link@example.com',
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
|
||||
expect($existing->fresh()->google_id)->toBe('google-uid-link');
|
||||
$this->assertAuthenticatedAs($existing->fresh());
|
||||
});
|
||||
|
||||
test('callback refuses to overwrite a different existing oauth identity', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
$existing = User::factory()->create([
|
||||
'email' => 'taken@example.com',
|
||||
'google_id' => 'different-google-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'attacker-id',
|
||||
'email' => 'taken@example.com',
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
|
||||
expect($existing->fresh()->google_id)->toBe('different-google-id');
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('callback re-uses user matched by provider id', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
$existing = User::factory()->create(['google_id' => 'stable-id']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'stable-id',
|
||||
'email' => $existing->email,
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($existing->fresh());
|
||||
});
|
||||
|
||||
test('callback on socialite exception redirects to login with error', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andThrow(new Exception('OAuth boom'));
|
||||
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
test('guest without 2fa session is redirected to login', function () {
|
||||
$this->get('/2fa')->assertRedirect(route('login', absolute: false));
|
||||
});
|
||||
|
||||
test('2fa view renders when 2fa session is set', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '654321');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->get('/2fa')->assertOk()->assertViewIs('auth.two-factor');
|
||||
});
|
||||
|
||||
test('verify with correct code logs the user in', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '111222'])
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('verify with wrong code keeps user logged out', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '999999'])
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('verify with expired code redirects to login', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->subSecond()->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '111222'])
|
||||
->assertRedirect(route('login', absolute: false))
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('verify rejects code with wrong length', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '12345'])->assertSessionHasErrors('code');
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('trust device option persists a trusted device row', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '888888');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '888888', 'trust_device' => '1']);
|
||||
|
||||
expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('trust device defaults to no row when option is unset', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '777777');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '777777']);
|
||||
|
||||
expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('trusted device cookie skips 2fa view and auto-logs-in', function () {
|
||||
$user = User::factory()->create();
|
||||
$deviceId = (string) Str::uuid();
|
||||
$secret = Str::random(64);
|
||||
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => $deviceId,
|
||||
'token' => hash('sha256', $secret),
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '000000');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->withCookie('2fa_trust_device', $deviceId.'|'.$secret)
|
||||
->get('/2fa')
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('trusted device cookie with wrong secret does not auto-login', function () {
|
||||
$user = User::factory()->create();
|
||||
$deviceId = (string) Str::uuid();
|
||||
$realSecret = Str::random(64);
|
||||
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => $deviceId,
|
||||
'token' => hash('sha256', $realSecret),
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '000000');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->withCookie('2fa_trust_device', $deviceId.'|wrong-secret')
|
||||
->get('/2fa')
|
||||
->assertOk();
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\WebAuthn\WebAuthnLoginController;
|
||||
use App\Http\Controllers\WebAuthn\WebAuthnRegisterController;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
test('WebAuthn login controller class exists and has required methods', function () {
|
||||
$ref = new ReflectionClass(WebAuthnLoginController::class);
|
||||
expect($ref->hasMethod('options'))->toBeTrue();
|
||||
expect($ref->hasMethod('login'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('WebAuthn register controller class exists and has required methods', function () {
|
||||
$ref = new ReflectionClass(WebAuthnRegisterController::class);
|
||||
expect($ref->hasMethod('options'))->toBeTrue();
|
||||
expect($ref->hasMethod('register'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('webauthn_enabled setting defaults to false in fresh DB', function () {
|
||||
expect(get_setting('webauthn_enabled', false))->toBeFalse();
|
||||
});
|
||||
|
||||
test('webauthn_enabled setting can be toggled on', function () {
|
||||
app(SystemConfigService::class)->update(['webauthn_enabled' => true]);
|
||||
|
||||
expect(get_setting('webauthn_enabled', false))->toBeTrue();
|
||||
});
|
||||
|
||||
test('webauthn_credentials migration created the laragear table', function () {
|
||||
expect(Schema::hasTable('webauthn_credentials'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('webauthn_credentials table has the expected key columns', function () {
|
||||
$cols = Schema::getColumnListing('webauthn_credentials');
|
||||
|
||||
foreach (['id', 'authenticatable_id', 'authenticatable_type'] as $required) {
|
||||
expect($cols)->toContain($required);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user