test: add auth, social login, email verification, and validation tests
This commit is contained in:
@@ -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: '<span />' },
|
||||||
|
UButton: { template: '<button><slot /></button>' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('errorBoundary', () => {
|
||||||
|
it('renders slot content when no error', () => {
|
||||||
|
const wrapper = mount(ErrorBoundary, {
|
||||||
|
slots: {
|
||||||
|
default: '<div>Hello World</div>',
|
||||||
|
},
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Hello World')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show error UI by default', () => {
|
||||||
|
const wrapper = mount(ErrorBoundary, {
|
||||||
|
slots: {
|
||||||
|
default: '<div>Content</div>',
|
||||||
|
},
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Something went wrong')
|
||||||
|
expect(wrapper.text()).toContain('Content')
|
||||||
|
})
|
||||||
|
})
|
||||||
85
resources/js/composables/__tests__/useAuth.test.ts
Normal file
85
resources/js/composables/__tests__/useAuth.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
169
resources/js/validation/__tests__/auth.test.ts
Normal file
169
resources/js/validation/__tests__/auth.test.ts
Normal file
@@ -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<T extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(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)
|
||||||
|
})
|
||||||
|
})
|
||||||
154
tests/Feature/Auth/EmailVerificationTest.php
Normal file
154
tests/Feature/Auth/EmailVerificationTest.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('does not send verification email when feature is disabled', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => 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');
|
||||||
|
});
|
||||||
114
tests/Feature/Auth/LoginTest.php
Normal file
114
tests/Feature/Auth/LoginTest.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('allows login with email and password', function () {
|
||||||
|
$user = User::factory()->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();
|
||||||
|
});
|
||||||
254
tests/Feature/Auth/SocialLoginSecurityTest.php
Normal file
254
tests/Feature/Auth/SocialLoginSecurityTest.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\SocialAccount;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['auth-ui.providers.github' => [
|
||||||
|
'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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Tests\Feature;
|
test('the application returns a successful response', function () {
|
||||||
|
$this->get('/')->assertSuccessful();
|
||||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
});
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ExampleTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* A basic test example.
|
|
||||||
*/
|
|
||||||
public function test_the_application_returns_a_successful_response(): void
|
|
||||||
{
|
|
||||||
$response = $this->get('/');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user