feat: add routes, lang, tests, stubs, docs, and docker configurations

This commit is contained in:
2026-05-21 16:05:16 +07:00
parent fad70d096b
commit 28a06315b8
3385 changed files with 177070 additions and 0 deletions
+41
View File
@@ -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);
});
+60
View File
@@ -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;
});
});
+40
View File
@@ -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');
});
+25
View File
@@ -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));
});
+131
View File
@@ -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);
});
+156
View File
@@ -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();
});
+136
View File
@@ -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();
});
+51
View File
@@ -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);
}
});