From 2351718939704ff125dde40201ca8b7c26504cbc Mon Sep 17 00:00:00 2001 From: Flycro Date: Thu, 19 Mar 2026 23:17:32 +0100 Subject: [PATCH] test: add auth, social login, email verification, and validation tests --- .../common/__tests__/ErrorBoundary.test.ts | 34 +++ .../js/composables/__tests__/useAuth.test.ts | 85 ++++++ .../js/validation/__tests__/auth.test.ts | 169 ++++++++++++ tests/Feature/Auth/EmailVerificationTest.php | 154 +++++++++++ tests/Feature/Auth/LoginTest.php | 114 ++++++++ .../Feature/Auth/SocialLoginSecurityTest.php | 254 ++++++++++++++++++ tests/Feature/ExampleTest.php | 20 +- 7 files changed, 813 insertions(+), 17 deletions(-) create mode 100644 resources/js/components/common/__tests__/ErrorBoundary.test.ts create mode 100644 resources/js/composables/__tests__/useAuth.test.ts create mode 100644 resources/js/validation/__tests__/auth.test.ts create mode 100644 tests/Feature/Auth/EmailVerificationTest.php create mode 100644 tests/Feature/Auth/LoginTest.php create mode 100644 tests/Feature/Auth/SocialLoginSecurityTest.php diff --git a/resources/js/components/common/__tests__/ErrorBoundary.test.ts b/resources/js/components/common/__tests__/ErrorBoundary.test.ts new file mode 100644 index 0000000..738d7ea --- /dev/null +++ b/resources/js/components/common/__tests__/ErrorBoundary.test.ts @@ -0,0 +1,34 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import ErrorBoundary from '../ErrorBoundary.vue' + +// Stub UIcon and UButton since they come from Nuxt UI +const stubs = { + UIcon: { template: '' }, + UButton: { template: '' }, +} + +describe('errorBoundary', () => { + it('renders slot content when no error', () => { + const wrapper = mount(ErrorBoundary, { + slots: { + default: '
Hello World
', + }, + global: { stubs }, + }) + + expect(wrapper.text()).toContain('Hello World') + }) + + it('does not show error UI by default', () => { + const wrapper = mount(ErrorBoundary, { + slots: { + default: '
Content
', + }, + global: { stubs }, + }) + + expect(wrapper.text()).not.toContain('Something went wrong') + expect(wrapper.text()).toContain('Content') + }) +}) diff --git a/resources/js/composables/__tests__/useAuth.test.ts b/resources/js/composables/__tests__/useAuth.test.ts new file mode 100644 index 0000000..983767a --- /dev/null +++ b/resources/js/composables/__tests__/useAuth.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' + +const mockPageProps = { + auth: { user: null as any }, + flash: { success: null, error: null, message: null, status: null }, + authConfig: { + appName: 'TestApp', + features: { + registration: true, + password_reset: true, + remember_me: true, + email_verification: false, + }, + login: { title: 'Login', description: '', icon: '', submit_label: 'Sign in' }, + register: { title: 'Register', description: '', icon: '', submit_label: 'Sign up' }, + forgotPassword: { title: 'Forgot', description: '', icon: '', submit_label: 'Send' }, + resetPassword: { title: 'Reset', description: '', icon: '', submit_label: 'Reset' }, + providers: [], + legal: { terms_url: null, privacy_url: null, show_in_register: false }, + }, +} + +vi.mock('@inertiajs/vue3', () => ({ + usePage: () => ({ props: mockPageProps }), +})) + +const { useAuth } = await import('../useAuth') + +describe('useAuth', () => { + it('returns null user when not authenticated', () => { + mockPageProps.auth.user = null + + const { user, isAuthenticated } = useAuth() + + expect(user.value).toBeNull() + expect(isAuthenticated.value).toBe(false) + }) + + it('returns user when authenticated', () => { + mockPageProps.auth.user = { + id: 1, + username: 'testuser', + first_name: 'Test', + last_name: 'User', + full_name: 'Test User', + email: 'test@example.com', + email_verified_at: '2026-01-01', + created_at: '2026-01-01', + updated_at: '2026-01-01', + } + + const { user, isAuthenticated } = useAuth() + + expect(user.value).not.toBeNull() + expect(user.value!.username).toBe('testuser') + expect(isAuthenticated.value).toBe(true) + }) + + it('returns flash messages', () => { + mockPageProps.flash.success = 'It worked!' + + const { flash } = useAuth() + + expect(flash.value.success).toBe('It worked!') + }) + + it('returns auth config', () => { + const { config } = useAuth() + + expect(config.value.appName).toBe('TestApp') + expect(config.value.features.registration).toBe(true) + expect(config.value.features.email_verification).toBe(false) + }) + + it('returns providers from config', () => { + mockPageProps.authConfig.providers = [ + { key: 'github', label: 'GitHub', icon: 'i-simple-icons-github' }, + ] + + const { config } = useAuth() + + expect(config.value.providers).toHaveLength(1) + expect(config.value.providers[0].key).toBe('github') + }) +}) diff --git a/resources/js/validation/__tests__/auth.test.ts b/resources/js/validation/__tests__/auth.test.ts new file mode 100644 index 0000000..497ba2f --- /dev/null +++ b/resources/js/validation/__tests__/auth.test.ts @@ -0,0 +1,169 @@ +import * as v from 'valibot' +import { describe, expect, it } from 'vitest' +import { + completeProfileSchema, + forgotPasswordSchema, + loginSchema, + registerSchema, + resetPasswordSchema, +} from '../auth' + +function validate>>(schema: T, data: unknown) { + return v.safeParse(schema, data) +} + +describe('loginSchema', () => { + it('accepts valid login data', () => { + const result = validate(loginSchema, { login: 'user@test.com', password: 'secret' }) + expect(result.success).toBe(true) + }) + + it('rejects empty login', () => { + const result = validate(loginSchema, { login: '', password: 'secret' }) + expect(result.success).toBe(false) + }) + + it('rejects empty password', () => { + const result = validate(loginSchema, { login: 'user', password: '' }) + expect(result.success).toBe(false) + }) + + it('accepts optional remember field', () => { + const result = validate(loginSchema, { login: 'user', password: 'secret', remember: true }) + expect(result.success).toBe(true) + }) +}) + +describe('registerSchema', () => { + const validData = { + username: 'testuser', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + password: 'password123', + password_confirmation: 'password123', + } + + it('accepts valid registration data', () => { + const result = validate(registerSchema, validData) + expect(result.success).toBe(true) + }) + + it('rejects username shorter than 3 characters', () => { + const result = validate(registerSchema, { ...validData, username: 'ab' }) + expect(result.success).toBe(false) + }) + + it('rejects username with spaces', () => { + const result = validate(registerSchema, { ...validData, username: 'test user' }) + expect(result.success).toBe(false) + }) + + it('accepts username with dashes and underscores', () => { + const result = validate(registerSchema, { ...validData, username: 'test-user_123' }) + expect(result.success).toBe(true) + }) + + it('rejects invalid email', () => { + const result = validate(registerSchema, { ...validData, email: 'not-an-email' }) + expect(result.success).toBe(false) + }) + + it('rejects password shorter than 8 characters', () => { + const result = validate(registerSchema, { ...validData, password: 'short', password_confirmation: 'short' }) + expect(result.success).toBe(false) + }) + + it('rejects mismatched passwords', () => { + const result = validate(registerSchema, { ...validData, password_confirmation: 'different123' }) + expect(result.success).toBe(false) + }) + + it('rejects empty first name', () => { + const result = validate(registerSchema, { ...validData, first_name: '' }) + expect(result.success).toBe(false) + }) + + it('rejects empty last name', () => { + const result = validate(registerSchema, { ...validData, last_name: '' }) + expect(result.success).toBe(false) + }) +}) + +describe('forgotPasswordSchema', () => { + it('accepts valid email', () => { + const result = validate(forgotPasswordSchema, { email: 'test@example.com' }) + expect(result.success).toBe(true) + }) + + it('rejects empty email', () => { + const result = validate(forgotPasswordSchema, { email: '' }) + expect(result.success).toBe(false) + }) + + it('rejects invalid email', () => { + const result = validate(forgotPasswordSchema, { email: 'not-email' }) + expect(result.success).toBe(false) + }) +}) + +describe('resetPasswordSchema', () => { + const validData = { + email: 'test@example.com', + password: 'newpassword123', + password_confirmation: 'newpassword123', + } + + it('accepts valid reset data', () => { + const result = validate(resetPasswordSchema, validData) + expect(result.success).toBe(true) + }) + + it('rejects mismatched passwords', () => { + const result = validate(resetPasswordSchema, { ...validData, password_confirmation: 'different' }) + expect(result.success).toBe(false) + }) + + it('rejects password shorter than 8 characters', () => { + const result = validate(resetPasswordSchema, { ...validData, password: 'short', password_confirmation: 'short' }) + expect(result.success).toBe(false) + }) + + it('rejects invalid email', () => { + const result = validate(resetPasswordSchema, { ...validData, email: 'bad' }) + expect(result.success).toBe(false) + }) +}) + +describe('completeProfileSchema', () => { + const validData = { + username: 'newuser', + first_name: 'New', + last_name: 'User', + } + + it('accepts valid profile data', () => { + const result = validate(completeProfileSchema, validData) + expect(result.success).toBe(true) + }) + + it('rejects username shorter than 3 characters', () => { + const result = validate(completeProfileSchema, { ...validData, username: 'ab' }) + expect(result.success).toBe(false) + }) + + it('rejects username with special characters', () => { + const result = validate(completeProfileSchema, { ...validData, username: 'user@name' }) + expect(result.success).toBe(false) + }) + + it('rejects empty first name', () => { + const result = validate(completeProfileSchema, { ...validData, first_name: '' }) + expect(result.success).toBe(false) + }) + + it('rejects empty last name', () => { + const result = validate(completeProfileSchema, { ...validData, last_name: '' }) + expect(result.success).toBe(false) + }) +}) diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..7165c4c --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,154 @@ + false]); + + Notification::fake(); + + $this->post('/register', [ + 'username' => 'testuser', + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + Notification::assertNothingSent(); +}); + +it('sends verification email when feature is enabled', function () { + config(['auth-ui.features.email_verification' => true]); + + Notification::fake(); + + $this->post('/register', [ + 'username' => 'testuser', + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $user = User::where('email', 'test@example.com')->first(); + + Notification::assertSentTo($user, VerifyEmail::class); +}); + +it('shows verification notice to unverified users when feature is enabled', function () { + config(['auth-ui.features.email_verification' => true]); + + $user = User::factory()->unverified()->create(); + + $this->actingAs($user) + ->get('/email/verify') + ->assertSuccessful(); +}); + +it('redirects verified users from verification notice', function () { + config(['auth-ui.features.email_verification' => true]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/email/verify') + ->assertRedirect('/dashboard'); +}); + +it('verifies email with valid signed url', function () { + config(['auth-ui.features.email_verification' => true]); + + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $this->actingAs($user) + ->get($verificationUrl) + ->assertRedirect('/dashboard'); + + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); + +it('rejects verification with invalid hash', function () { + config(['auth-ui.features.email_verification' => true]); + + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong@email.com')] + ); + + $this->actingAs($user) + ->get($verificationUrl) + ->assertForbidden(); + + expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('allows resending verification email', function () { + config(['auth-ui.features.email_verification' => true]); + + Notification::fake(); + + $user = User::factory()->unverified()->create(); + + $this->actingAs($user) + ->post('/email/verification-notification') + ->assertRedirect() + ->assertSessionHas('status', 'verification-link-sent'); + + Notification::assertSentTo($user, VerifyEmail::class); +}); + +it('does not register verification routes when feature is disabled', function () { + config(['auth-ui.features.email_verification' => false]); + + $user = User::factory()->unverified()->create(); + + $this->actingAs($user) + ->get('/email/verify') + ->assertNotFound(); +}); + +it('redirects to verification notice after registration when feature is enabled', function () { + config(['auth-ui.features.email_verification' => true]); + + Notification::fake(); + + $this->post('/register', [ + 'username' => 'newuser', + 'first_name' => 'New', + 'last_name' => 'User', + 'email' => 'new@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ])->assertRedirect('/email/verify'); +}); + +it('redirects to home after registration when feature is disabled', function () { + config(['auth-ui.features.email_verification' => false]); + + $this->post('/register', [ + 'username' => 'newuser', + 'first_name' => 'New', + 'last_name' => 'User', + 'email' => 'new@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ])->assertRedirect('/dashboard'); +}); diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php new file mode 100644 index 0000000..e254968 --- /dev/null +++ b/tests/Feature/Auth/LoginTest.php @@ -0,0 +1,114 @@ +create([ + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + + $this->post('/login', [ + 'login' => 'test@example.com', + 'password' => 'password123', + ])->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); +}); + +it('allows login with username and password', function () { + $user = User::factory()->create([ + 'username' => 'testuser', + 'password' => 'password123', + ]); + + $this->post('/login', [ + 'login' => 'testuser', + 'password' => 'password123', + ])->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); +}); + +it('allows case-insensitive username login', function () { + $user = User::factory()->create([ + 'username' => 'TestUser', + 'password' => 'password123', + ]); + + $this->post('/login', [ + 'login' => 'testuser', + 'password' => 'password123', + ])->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); +}); + +it('rejects login with wrong password', function () { + User::factory()->create([ + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + + $this->post('/login', [ + 'login' => 'test@example.com', + 'password' => 'wrongpassword', + ])->assertSessionHasErrors('login'); + + $this->assertGuest(); +}); + +it('rejects login with nonexistent user', function () { + $this->post('/login', [ + 'login' => 'nobody@example.com', + 'password' => 'password123', + ])->assertSessionHasErrors('login'); + + $this->assertGuest(); +}); + +it('blocks password login for social-only users', function () { + User::factory()->social()->create([ + 'email' => 'social@example.com', + ]); + + $this->post('/login', [ + 'login' => 'social@example.com', + 'password' => 'anything', + ])->assertSessionHasErrors('login'); + + $this->assertGuest(); +}); + +it('blocks password login for social-only users with any password attempt', function () { + User::factory()->social()->create([ + 'email' => 'social@example.com', + ]); + + $passwords = ['', 'password', 'null', '0', 'false']; + + foreach ($passwords as $password) { + $this->post('/login', [ + 'login' => 'social@example.com', + 'password' => $password, + ]); + + $this->assertGuest(); + } +}); + +it('blocks password login for social-only users via username', function () { + User::factory()->social()->create([ + 'username' => 'socialuser', + ]); + + $this->post('/login', [ + 'login' => 'socialuser', + 'password' => 'anything', + ])->assertSessionHasErrors('login'); + + $this->assertGuest(); +}); diff --git a/tests/Feature/Auth/SocialLoginSecurityTest.php b/tests/Feature/Auth/SocialLoginSecurityTest.php new file mode 100644 index 0000000..eb14fce --- /dev/null +++ b/tests/Feature/Auth/SocialLoginSecurityTest.php @@ -0,0 +1,254 @@ + [ + 'label' => 'GitHub', + 'icon' => 'i-simple-icons-github', + 'enabled' => true, + ]]); + config(['auth-ui.providers.google' => [ + 'label' => 'Google', + 'icon' => 'i-simple-icons-google', + 'enabled' => true, + ]]); + config(['auth-ui.features.registration' => true]); +}); + +it('creates social users without a password', function () { + $user = User::factory()->social()->create(); + + expect($user->password)->toBeNull(); + expect($user->hasPassword())->toBeFalse(); +}); + +it('creates regular users with a password', function () { + $user = User::factory()->create(); + + expect($user->password)->not->toBeNull(); + expect($user->hasPassword())->toBeTrue(); +}); + +it('prevents brute force on social-only accounts', function () { + $user = User::factory()->social()->create([ + 'email' => 'social@example.com', + ]); + + $attempts = ['password', '123456', 'admin', $user->email, $user->username, '']; + + foreach ($attempts as $password) { + $this->post('/login', [ + 'login' => 'social@example.com', + 'password' => $password, + ]); + + $this->assertGuest(); + } +}); + +it('does not expose whether a user is social-only via login error', function () { + User::factory()->social()->create(['email' => 'social@example.com']); + User::factory()->create(['email' => 'regular@example.com', 'password' => 'password123']); + + $socialResponse = $this->post('/login', [ + 'login' => 'social@example.com', + 'password' => 'wrongpassword', + ]); + $socialErrors = $socialResponse->assertSessionHasErrors('login') + ->getSession()->get('errors')->get('login'); + + $this->refreshApplication(); + + $regularErrors = $this->post('/login', [ + 'login' => 'regular@example.com', + 'password' => 'wrongpassword', + ])->assertSessionHasErrors('login') + ->getSession()->get('errors')->get('login'); + + expect($socialErrors)->toEqual($regularErrors); +}); + +it('allows social user to set a password later and login with it', function () { + $user = User::factory()->social()->create([ + 'email' => 'social@example.com', + ]); + + expect($user->hasPassword())->toBeFalse(); + + $user->update(['password' => 'newpassword123']); + + expect($user->fresh()->hasPassword())->toBeTrue(); + + $this->post('/login', [ + 'login' => 'social@example.com', + 'password' => 'newpassword123', + ])->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); +}); + +it('creates a social account record on first social login', function () { + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => 'github-123', + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'nickname' => 'johndoe', + ])); + + $this->get('/auth/github/callback'); + + $this->assertDatabaseHas('social_accounts', [ + 'provider' => 'github', + 'provider_id' => 'github-123', + ]); + + $user = User::where('email', 'john@example.com')->first(); + expect($user->socialAccounts)->toHaveCount(1); + expect($user->hasPassword())->toBeFalse(); + expect($user->hasVerifiedEmail())->toBeTrue(); +}); + +it('links provider to existing verified user on social login', function () { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => 'password123', + 'email_verified_at' => now(), + ]); + + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => 'github-456', + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'nickname' => 'johndoe', + ])); + + $this->get('/auth/github/callback'); + + $this->assertAuthenticatedAs($user); + expect($user->fresh()->socialAccounts)->toHaveCount(1); + expect($user->fresh()->hasPassword())->toBeTrue(); +}); + +it('rejects social login linking to unverified existing user', function () { + User::factory()->unverified()->create([ + 'email' => 'unverified@example.com', + 'password' => 'password123', + ]); + + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => 'github-hijack', + 'name' => 'Attacker', + 'email' => 'unverified@example.com', + 'nickname' => 'attacker', + ])); + + $this->get('/auth/github/callback')->assertRedirect('/login'); + + $this->assertGuest(); + $this->assertDatabaseMissing('social_accounts', [ + 'provider_id' => 'github-hijack', + ]); +}); + +it('recognizes returning social user by provider id', function () { + $user = User::factory()->social()->create([ + 'email' => 'john@example.com', + ]); + $user->socialAccounts()->create([ + 'provider' => 'github', + 'provider_id' => 'github-789', + ]); + + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => 'github-789', + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'nickname' => 'johndoe', + ])); + + $this->get('/auth/github/callback'); + + $this->assertAuthenticatedAs($user); + expect(SocialAccount::where('provider', 'github')->where('provider_id', 'github-789')->count())->toBe(1); +}); + +it('prevents different provider with same email from creating duplicate user', function () { + $user = User::factory()->social()->create([ + 'email' => 'john@example.com', + 'email_verified_at' => now(), + ]); + $user->socialAccounts()->create([ + 'provider' => 'github', + 'provider_id' => 'github-100', + ]); + + Socialite::fake('google', (new SocialiteUser)->map([ + 'id' => 'google-200', + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'nickname' => 'johndoe', + ])); + + $this->get('/auth/google/callback'); + + $this->assertAuthenticatedAs($user); + expect(User::where('email', 'john@example.com')->count())->toBe(1); + expect($user->fresh()->socialAccounts)->toHaveCount(2); +}); + +it('creates social account when completing profile after social login', function () { + session()->put('socialite_user', [ + 'email' => 'social@example.com', + 'first_name' => 'Social', + 'last_name' => 'User', + 'suggested_username' => 'socialuser', + 'provider' => 'github', + 'provider_id' => 'github-complete-123', + ]); + + $this->post('/complete-profile', [ + 'username' => 'newsocialuser', + 'first_name' => 'Social', + 'last_name' => 'User', + ])->assertRedirect('/dashboard'); + + $user = User::where('email', 'social@example.com')->first(); + expect($user)->not->toBeNull(); + expect($user->socialAccounts)->toHaveCount(1); + expect($user->socialAccounts->first()->provider)->toBe('github'); + expect($user->socialAccounts->first()->provider_id)->toBe('github-complete-123'); + expect($user->hasVerifiedEmail())->toBeTrue(); + expect($user->hasPassword())->toBeFalse(); +}); + +it('rejects social login for disabled provider', function () { + Socialite::fake('github'); + + config(['auth-ui.providers.github.enabled' => false]); + + $this->get('/auth/github/callback')->assertNotFound(); +}); + +it('rejects social registration when registration is disabled', function () { + config(['auth-ui.features.registration' => false]); + + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => 'github-new', + 'name' => 'New User', + 'email' => 'new@example.com', + 'nickname' => 'newuser', + ])); + + $this->get('/auth/github/callback')->assertRedirect('/login'); + + $this->assertDatabaseMissing('users', [ + 'email' => 'new@example.com', + ]); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8364a84..d2411e5 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,19 +1,5 @@ get('/'); - - $response->assertStatus(200); - } -} +test('the application returns a successful response', function () { + $this->get('/')->assertSuccessful(); +});