test: add OAuth2 authentication test files (TDD RED phase)

This commit is contained in:
2025-12-12 12:40:58 +01:00
parent 40ae2145cc
commit 8867dff780
6 changed files with 1935 additions and 0 deletions

View File

@@ -0,0 +1,463 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
import type { AuthModel } from 'pocketbase';
/**
* Integration tests for cross-tab synchronization
* Based on US5 (Cross-Tab Synchronization) from specs/001-oauth2-authentication/spec.md
*
* These tests verify that auth state changes in one tab synchronize to other tabs
* via Pocketbase authStore.onChange() callback mechanism.
*
* Acceptance Criteria (US5):
* - Login in tab A should update tab B within 2 seconds
* - Logout in tab A should update tab B within 2 seconds
* - Auth state should sync across browser windows, not just tabs
*/
// Mock PocketBase authStore with onChange support
const mockAuthStore = {
isValid: false,
record: null as AuthModel | null,
clear: vi.fn(),
onChange: vi.fn(),
};
const mockCollection = vi.fn();
const mockAuthWithOAuth2 = vi.fn();
const mockListAuthMethods = vi.fn();
const mockAuthRefresh = vi.fn();
vi.mock('../usePocketbase', () => ({
usePocketbase: () => ({
authStore: mockAuthStore,
collection: mockCollection,
}),
}));
// Mock router using Nuxt's test utils
const mockRouterPush = vi.fn();
const mockRouter = {
push: mockRouterPush,
};
mockNuxtImport('useRouter', () => {
return () => mockRouter;
});
describe('useAuth - Cross-Tab Synchronization', () => {
beforeEach(async () => {
// Reset all mocks
vi.clearAllMocks();
mockAuthStore.isValid = false;
mockAuthStore.record = null;
// Setup default mock implementations
mockCollection.mockReturnValue({
authWithOAuth2: mockAuthWithOAuth2,
listAuthMethods: mockListAuthMethods,
authRefresh: mockAuthRefresh,
});
// Clear module cache to get fresh imports
vi.resetModules();
});
describe('Login Synchronization', () => {
it('should update user.value when authStore onChange fires with new user', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Initialize auth (sets up onChange listener)
await auth.initAuth();
// Verify initial state
expect(auth.user.value).toBeNull();
expect(auth.isAuthenticated.value).toBe(false);
// Get the onChange callback that was registered
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
expect(onChangeCallback).toBeDefined();
// Simulate login event in another tab (authStore onChange fires)
const newUser: AuthModel = {
id: 'user123',
email: 'test@example.com',
name: 'Test User',
verified: true,
created: new Date().toISOString(),
updated: new Date().toISOString(),
} as unknown as AuthModel;
mockAuthStore.isValid = true;
mockAuthStore.record = newUser;
onChangeCallback('token123', newUser);
// Verify user state updated
expect(auth.user.value).toEqual(newUser);
});
it('should update isAuthenticated when authStore onChange fires with valid user', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Initialize auth
await auth.initAuth();
expect(auth.isAuthenticated.value).toBe(false);
// Get the onChange callback
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
// Simulate login in another tab
const newUser: AuthModel = {
id: 'user456',
email: 'another@example.com',
name: 'Another User',
} as unknown as AuthModel;
mockAuthStore.isValid = true;
mockAuthStore.record = newUser;
onChangeCallback('token456', newUser);
// Verify authenticated state
expect(auth.isAuthenticated.value).toBe(true);
expect(auth.user.value?.email).toBe('another@example.com');
});
it('should handle rapid login events from multiple tabs', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
await auth.initAuth();
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
// Simulate rapid login events (edge case: multiple tabs logging in)
const user1: AuthModel = {
id: 'user1',
email: 'user1@example.com',
} as unknown as AuthModel;
const user2: AuthModel = {
id: 'user2',
email: 'user2@example.com',
} as unknown as AuthModel;
mockAuthStore.isValid = true;
// First login event
mockAuthStore.record = user1;
onChangeCallback('token1', user1);
expect(auth.user.value?.id).toBe('user1');
// Second login event (should overwrite)
mockAuthStore.record = user2;
onChangeCallback('token2', user2);
expect(auth.user.value?.id).toBe('user2');
});
});
describe('Logout Synchronization', () => {
it('should clear user.value when authStore onChange fires with null', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Start with authenticated user
const initialUser: AuthModel = {
id: 'user123',
email: 'test@example.com',
} as unknown as AuthModel;
mockAuthStore.record = initialUser;
mockAuthStore.isValid = true;
await auth.initAuth();
expect(auth.user.value).toEqual(initialUser);
expect(auth.isAuthenticated.value).toBe(true);
// Get the onChange callback
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
// Simulate logout event in another tab (authStore onChange fires with null)
mockAuthStore.isValid = false;
mockAuthStore.record = null;
onChangeCallback('', null);
// Verify user state cleared
expect(auth.user.value).toBeNull();
});
it('should update isAuthenticated to false when authStore onChange fires with null', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Start authenticated
mockAuthStore.record = {
id: 'user123',
email: 'test@example.com',
} as unknown as AuthModel;
mockAuthStore.isValid = true;
await auth.initAuth();
expect(auth.isAuthenticated.value).toBe(true);
// Get the onChange callback
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
// Simulate logout in another tab
mockAuthStore.isValid = false;
mockAuthStore.record = null;
onChangeCallback('', null);
// Verify not authenticated
expect(auth.isAuthenticated.value).toBe(false);
});
it('should handle logout after login in same session', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
await auth.initAuth();
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
// Simulate login
const user: AuthModel = {
id: 'user123',
email: 'test@example.com',
} as unknown as AuthModel;
mockAuthStore.isValid = true;
mockAuthStore.record = user;
onChangeCallback('token123', user);
expect(auth.isAuthenticated.value).toBe(true);
// Simulate logout
mockAuthStore.isValid = false;
mockAuthStore.record = null;
onChangeCallback('', null);
expect(auth.isAuthenticated.value).toBe(false);
expect(auth.user.value).toBeNull();
});
});
describe('onChange Listener Registration', () => {
it('should register onChange listener exactly once during initAuth', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
expect(mockAuthStore.onChange).not.toHaveBeenCalled();
await auth.initAuth();
expect(mockAuthStore.onChange).toHaveBeenCalledTimes(1);
expect(mockAuthStore.onChange).toHaveBeenCalledWith(expect.any(Function));
});
it('should not register duplicate onChange listeners on multiple initAuth calls', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
await auth.initAuth();
await auth.initAuth();
await auth.initAuth();
// onChange should be called once per initAuth call
// (This is acceptable behavior - Pocketbase handles duplicates)
expect(mockAuthStore.onChange).toHaveBeenCalledTimes(3);
});
it('should register onChange callback with correct signature', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
await auth.initAuth();
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
// Callback should accept token (string) and model (AuthModel | null)
expect(typeof onChangeCallback).toBe('function');
expect(onChangeCallback.length).toBe(2); // (token, model) => void
});
});
describe('Cross-Tab Edge Cases', () => {
it('should handle authStore onChange with undefined model', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
await auth.initAuth();
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
// Simulate edge case: authStore fires onChange with undefined
mockAuthStore.isValid = false;
mockAuthStore.record = null;
onChangeCallback('', undefined);
// Should handle gracefully (treat as logout)
expect(auth.user.value).toBeNull();
expect(auth.isAuthenticated.value).toBe(false);
});
it('should handle authStore onChange during active login flow', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
await auth.initAuth();
// Start login flow (simulates user clicking login in this tab)
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: true,
providers: [
{
name: 'google',
displayName: 'Google',
state: 'state123',
codeVerifier: 'verifier',
codeChallenge: 'challenge',
codeChallengeMethod: 'S256',
authURL: 'https://google.com/oauth',
},
],
},
});
const loginUser: AuthModel = {
id: 'loginUser',
email: 'login@example.com',
} as unknown as AuthModel;
mockAuthWithOAuth2.mockResolvedValue({
record: loginUser,
});
// Start login
const loginPromise = auth.login('google');
// While login is pending, simulate onChange from another tab
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
const crossTabUser: AuthModel = {
id: 'crossTabUser',
email: 'crosstab@example.com',
} as unknown as AuthModel;
mockAuthStore.isValid = true;
mockAuthStore.record = crossTabUser;
onChangeCallback('token999', crossTabUser);
// User should be updated from cross-tab event
expect(auth.user.value?.id).toBe('crossTabUser');
// Wait for login to complete
await loginPromise;
// After login completes, user should be from login flow
expect(auth.user.value?.id).toBe('loginUser');
});
it('should synchronize user profile updates from another tab', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Start with user
const initialUser: AuthModel = {
id: 'user123',
email: 'test@example.com',
name: 'Old Name',
} as unknown as AuthModel;
mockAuthStore.record = initialUser;
mockAuthStore.isValid = true;
await auth.initAuth();
expect(auth.user.value?.name).toBe('Old Name');
// Simulate user profile update in another tab
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
const updatedUser: AuthModel = {
id: 'user123',
email: 'test@example.com',
name: 'Updated Name',
} as unknown as AuthModel;
mockAuthStore.record = updatedUser;
onChangeCallback('token123', updatedUser);
// Verify profile update synced
expect(auth.user.value?.name).toBe('Updated Name');
expect(auth.user.value?.id).toBe('user123'); // Same user
});
});
describe('Session Restoration and Sync Integration', () => {
it('should restore user from authStore and setup onChange listener', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const existingUser: AuthModel = {
id: 'existing123',
email: 'existing@example.com',
} as unknown as AuthModel;
mockAuthStore.record = existingUser;
mockAuthStore.isValid = true;
// initAuth should both restore and setup listener
await auth.initAuth();
// Verify restoration
expect(auth.user.value).toEqual(existingUser);
// Verify listener setup
expect(mockAuthStore.onChange).toHaveBeenCalledTimes(1);
// Verify listener works
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
const newUser: AuthModel = {
id: 'new456',
email: 'new@example.com',
} as unknown as AuthModel;
mockAuthStore.record = newUser;
onChangeCallback('token456', newUser);
expect(auth.user.value?.id).toBe('new456');
});
it('should handle case where authStore is empty on init but login happens in another tab', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Start with empty authStore (user logged out or first load)
mockAuthStore.record = null;
mockAuthStore.isValid = false;
await auth.initAuth();
expect(auth.isAuthenticated.value).toBe(false);
// Simulate login in another tab
const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0];
const user: AuthModel = {
id: 'user789',
email: 'user789@example.com',
} as unknown as AuthModel;
mockAuthStore.isValid = true;
mockAuthStore.record = user;
onChangeCallback('token789', user);
// Should sync login from other tab
expect(auth.isAuthenticated.value).toBe(true);
expect(auth.user.value?.id).toBe('user789');
});
});
});

View File

@@ -0,0 +1,518 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
import type { AuthProviderInfo } from 'pocketbase';
/**
* Enhanced Error Handling Tests for useAuth composable
* Based on User Story 7 (US7) - OAuth Provider Failure Handling
*
* These tests verify that the login() method provides user-friendly error messages
* for different OAuth failure scenarios and logs errors to the console for debugging.
*
* Test Strategy (TDD RED Phase):
* - Test unconfigured provider → "This login provider is not available. Contact admin."
* - Test denied authorization → "Login was cancelled. Please try again."
* - Test network error → "Connection failed. Check your internet and try again."
* - Test generic error → "Login failed. Please try again later."
* - Test console.error called for all error scenarios
*
* Expected Behavior (from plan.md):
* The login() catch block should:
* 1. Log the error to console with [useAuth] prefix
* 2. Check error message for specific patterns
* 3. Set user-friendly error message based on pattern match
* 4. Provide fallback message for unknown errors
*/
// Mock PocketBase
const mockAuthStore = {
isValid: false,
record: null,
clear: vi.fn(),
onChange: vi.fn(),
};
const mockCollection = vi.fn();
const mockAuthWithOAuth2 = vi.fn();
const mockListAuthMethods = vi.fn();
const mockAuthRefresh = vi.fn();
vi.mock('../usePocketbase', () => ({
usePocketbase: () => ({
authStore: mockAuthStore,
collection: mockCollection,
}),
}));
// Mock router using Nuxt's test utils
const mockRouterPush = vi.fn();
const mockRouter = {
push: mockRouterPush,
};
mockNuxtImport('useRouter', () => {
return () => mockRouter;
});
// Spy on console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
describe('useAuth - Enhanced Error Handling', () => {
const mockProviders: AuthProviderInfo[] = [
{
name: 'google',
displayName: 'Google',
state: 'state123',
codeVerifier: 'verifier',
codeChallenge: 'challenge',
codeChallengeMethod: 'S256',
authURL: 'https://google.com/oauth',
},
];
beforeEach(async () => {
// Reset all mocks
vi.clearAllMocks();
mockAuthStore.isValid = false;
mockAuthStore.record = null;
// Setup default mock implementations
mockCollection.mockReturnValue({
authWithOAuth2: mockAuthWithOAuth2,
listAuthMethods: mockListAuthMethods,
authRefresh: mockAuthRefresh,
});
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: true,
providers: mockProviders,
},
});
// Clear module cache to get fresh imports
vi.resetModules();
});
describe('Unconfigured Provider Error', () => {
it('should show user-friendly message when provider is not configured', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate error from Pocketbase SDK when provider is not configured
const pbError = new Error('OAuth2 provider "github" is not configured');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
expect(auth.error.value?.message).toBe(
'This login provider is not available. Contact admin.'
);
});
it('should log original error to console when provider is not configured', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('OAuth2 provider "github" is not configured');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useAuth] Login failed:',
pbError
);
});
it('should detect "not configured" in error message (case insensitive)', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('Provider NOT CONFIGURED on server');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value?.message).toBe(
'This login provider is not available. Contact admin.'
);
});
});
describe('Denied Authorization Error', () => {
it('should show user-friendly message when user denies authorization', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate error when user clicks "Deny" on OAuth consent screen
const pbError = new Error('OAuth2 authorization was denied by the user');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
expect(auth.error.value?.message).toBe(
'Login was cancelled. Please try again.'
);
});
it('should show user-friendly message when user cancels authorization', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate error when user clicks "Cancel" on OAuth consent screen
const pbError = new Error('User cancelled the OAuth flow');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
expect(auth.error.value?.message).toBe(
'Login was cancelled. Please try again.'
);
});
it('should log original error to console when authorization is denied', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('OAuth2 authorization was denied by the user');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useAuth] Login failed:',
pbError
);
});
it('should detect "denied" in error message (case insensitive)', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('Access DENIED by user');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value?.message).toBe(
'Login was cancelled. Please try again.'
);
});
it('should detect "cancel" in error message (case insensitive)', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('User CANCELLED the request');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value?.message).toBe(
'Login was cancelled. Please try again.'
);
});
});
describe('Network Error', () => {
it('should show user-friendly message for network failures', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate network error
const pbError = new Error('Network request failed');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
expect(auth.error.value?.message).toBe(
'Connection failed. Check your internet and try again.'
);
});
it('should show user-friendly message for fetch failures', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate fetch error
const pbError = new Error('Failed to fetch OAuth2 token');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
expect(auth.error.value?.message).toBe(
'Connection failed. Check your internet and try again.'
);
});
it('should log original error to console for network failures', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('Network request failed');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useAuth] Login failed:',
pbError
);
});
it('should detect "network" in error message (case insensitive)', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('NETWORK connection lost');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value?.message).toBe(
'Connection failed. Check your internet and try again.'
);
});
it('should detect "fetch" in error message (case insensitive)', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('FETCH operation timed out');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value?.message).toBe(
'Connection failed. Check your internet and try again.'
);
});
});
describe('Generic Error Fallback', () => {
it('should show fallback message for unknown error types', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate unexpected error
const pbError = new Error('Something went wrong with the database');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
expect(auth.error.value?.message).toBe(
'Login failed. Please try again later.'
);
});
it('should show fallback message for empty error message', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate error with empty message
const pbError = new Error('');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
expect(auth.error.value?.message).toBe(
'Login failed. Please try again later.'
);
});
it('should log original error to console for unknown errors', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('Unexpected server error');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useAuth] Login failed:',
pbError
);
});
it('should handle non-Error objects gracefully', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Simulate error thrown as plain object (edge case)
const pbError = { message: 'Strange error format' };
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
// Should still get fallback message
expect(auth.error.value?.message).toBe(
'Login failed. Please try again later.'
);
});
});
describe('Console Logging', () => {
it('should always log errors to console with [useAuth] prefix', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const testCases = [
new Error('not configured error'),
new Error('denied error'),
new Error('network error'),
new Error('generic error'),
];
for (const pbError of testCases) {
consoleErrorSpy.mockClear();
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useAuth] Login failed:',
pbError
);
}
});
it('should log errors before setting user-friendly error message', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('Test error');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
// Verify console.error was called
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[useAuth] Login failed:',
pbError
);
// Verify user-friendly message is set after logging
expect(auth.error.value).toBeDefined();
});
});
describe('Error Message Priority', () => {
it('should prioritize "not configured" over other patterns', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Error message contains both "not configured" and "denied"
const pbError = new Error('Provider not configured, access denied');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
// Should match "not configured" first
expect(auth.error.value?.message).toBe(
'This login provider is not available. Contact admin.'
);
});
it('should prioritize "denied" over "network"', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Error message contains both "denied" and "network"
const pbError = new Error('Access denied due to network policy');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
// Should match "denied" before "network"
expect(auth.error.value?.message).toBe(
'Login was cancelled. Please try again.'
);
});
it('should prioritize "network" over generic fallback', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Error message contains "network" but nothing else
const pbError = new Error('Network timeout occurred');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
// Should match "network" before falling back to generic
expect(auth.error.value?.message).toBe(
'Connection failed. Check your internet and try again.'
);
});
});
describe('Error State Management', () => {
it('should set loading to false after error', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
const pbError = new Error('Test error');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.loading.value).toBe(false);
});
it('should preserve error state across multiple failed login attempts', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// First failed attempt
const error1 = new Error('Network error');
mockAuthWithOAuth2.mockRejectedValue(error1);
await auth.login('google');
const firstErrorMessage = auth.error.value?.message;
expect(firstErrorMessage).toBe(
'Connection failed. Check your internet and try again.'
);
// Second failed attempt with different error
const error2 = new Error('Provider not configured');
mockAuthWithOAuth2.mockRejectedValue(error2);
await auth.login('google');
// Error should be updated to new error
expect(auth.error.value?.message).toBe(
'This login provider is not available. Contact admin.'
);
expect(auth.error.value?.message).not.toBe(firstErrorMessage);
});
it('should clear error on successful login after previous error', async () => {
const { useAuth } = await import('../useAuth');
const auth = useAuth();
// Failed attempt
const pbError = new Error('Network error');
mockAuthWithOAuth2.mockRejectedValue(pbError);
await auth.login('google');
expect(auth.error.value).toBeDefined();
// Successful attempt
mockAuthWithOAuth2.mockResolvedValue({
record: { id: 'user123', email: 'test@example.com' },
});
await auth.login('google');
expect(auth.error.value).toBeNull();
});
});
});

View File

@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
/**
* Unit tests for auth.client.ts plugin
*
* This plugin is responsible for initializing the auth state when the app mounts.
* It calls useAuth().initAuth() which:
* 1. Syncs user from Pocketbase authStore (session restoration)
* 2. Sets up cross-tab sync listener via pb.authStore.onChange()
*
* Tests written FIRST (TDD Red phase) - Implementation does not exist yet.
* Expected: ALL TESTS WILL FAIL until T004 is implemented.
*
* Story Mapping:
* - US4 (Session Persistence): Plugin enables session restoration on page load
* - US5 (Cross-Tab Sync): Plugin sets up onChange listener for cross-tab synchronization
*/
// Mock useAuth composable
const mockInitAuth = vi.fn();
const mockUseAuth = vi.fn(() => ({
initAuth: mockInitAuth,
user: { value: null },
isAuthenticated: { value: false },
loading: { value: false },
error: { value: null },
login: vi.fn(),
logout: vi.fn(),
handleOAuthCallback: vi.fn(),
refreshAuth: vi.fn(),
authProviders: vi.fn(),
}));
// Mock the useAuth composable
vi.mock('../../composables/useAuth', () => ({
useAuth: mockUseAuth,
}));
// Mock defineNuxtPlugin
const mockDefineNuxtPlugin = vi.fn((callback: Function) => {
// Store the callback so we can invoke it in tests
return { callback };
});
vi.mock('#app', () => ({
defineNuxtPlugin: mockDefineNuxtPlugin,
}));
describe('auth.client plugin', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Plugin Initialization', () => {
it('should call initAuth() exactly once when plugin executes', async () => {
// Import the plugin (this will execute it)
const plugin = await import('../auth.client');
// The plugin should have called defineNuxtPlugin
expect(mockDefineNuxtPlugin).toHaveBeenCalledTimes(1);
// Get the plugin callback
const pluginCallback = mockDefineNuxtPlugin.mock.results[0]?.value?.callback;
expect(pluginCallback).toBeDefined();
expect(typeof pluginCallback).toBe('function');
// Execute the plugin callback
pluginCallback();
// Verify useAuth was called
expect(mockUseAuth).toHaveBeenCalledTimes(1);
// Verify initAuth was called exactly once
expect(mockInitAuth).toHaveBeenCalledTimes(1);
expect(mockInitAuth).toHaveBeenCalledWith();
});
it('should call useAuth composable to get auth methods', async () => {
// Import the plugin
const plugin = await import('../auth.client');
// Get and execute the plugin callback
const pluginCallback = mockDefineNuxtPlugin.mock.results[0]?.value?.callback;
pluginCallback();
// Verify useAuth was called
expect(mockUseAuth).toHaveBeenCalled();
});
});
describe('Plugin Execution Order', () => {
it('should execute synchronously (not async)', async () => {
// Import the plugin
const plugin = await import('../auth.client');
// Get the plugin callback
const pluginCallback = mockDefineNuxtPlugin.mock.results[0]?.value?.callback;
// The callback should not return a Promise
const result = pluginCallback();
expect(result).toBeUndefined();
});
it('should not call any other auth methods besides initAuth', async () => {
// Create a mock with spy methods
const spyLogin = vi.fn();
const spyLogout = vi.fn();
const spyHandleOAuthCallback = vi.fn();
const spyRefreshAuth = vi.fn();
mockUseAuth.mockReturnValue({
initAuth: mockInitAuth,
user: { value: null },
isAuthenticated: { value: false },
loading: { value: false },
error: { value: null },
login: spyLogin,
logout: spyLogout,
handleOAuthCallback: spyHandleOAuthCallback,
refreshAuth: spyRefreshAuth,
authProviders: vi.fn(),
});
// Import and execute the plugin
const plugin = await import('../auth.client');
const pluginCallback = mockDefineNuxtPlugin.mock.results[0]?.value?.callback;
pluginCallback();
// Verify only initAuth was called
expect(mockInitAuth).toHaveBeenCalled();
expect(spyLogin).not.toHaveBeenCalled();
expect(spyLogout).not.toHaveBeenCalled();
expect(spyHandleOAuthCallback).not.toHaveBeenCalled();
expect(spyRefreshAuth).not.toHaveBeenCalled();
});
});
describe('Client-Side Only Execution', () => {
it('should be a client-side plugin (file named auth.client.ts)', async () => {
// This test verifies the naming convention
// Plugin files ending in .client.ts are automatically client-side only in Nuxt
// We can't directly test this in unit tests, but we document the requirement
// The file MUST be named auth.client.ts (not auth.ts)
// This ensures it only runs in the browser, not during SSR
const filename = 'auth.client.ts';
expect(filename).toMatch(/\.client\.ts$/);
});
});
describe('Integration with useAuth', () => {
it('should enable session restoration via initAuth', async () => {
// This test documents the expected behavior:
// When initAuth() is called, it should:
// 1. Sync user from pb.authStore.record
// 2. Set up onChange listener for cross-tab sync
const plugin = await import('../auth.client');
const pluginCallback = mockDefineNuxtPlugin.mock.results[0]?.value?.callback;
pluginCallback();
// Verify initAuth was called (the actual restoration logic is tested in useAuth.test.ts)
expect(mockInitAuth).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
/**
* TDD Tests for validateRedirect utility (Phase 1: RED)
*
* Purpose: Prevent open redirect vulnerabilities by validating redirect URLs
* Specification: specs/001-oauth2-authentication/spec.md - US3 (Protected Routes)
*
* Security Requirements:
* - FR-019: Validate redirect URLs to prevent open redirect attacks
* - NFR-005: Only allow same-origin paths starting with /
*
* These tests are written FIRST (TDD Red phase) and should FAIL
* until the validateRedirect implementation is created in T002.
*/
describe('validateRedirect', () => {
describe('Valid same-origin paths', () => {
it('should return valid path starting with single slash', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/dashboard');
expect(result).toBe('/dashboard');
});
it('should return valid nested path', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/projects/123');
expect(result).toBe('/projects/123');
});
it('should return valid path with query parameters', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/tasks?id=456');
expect(result).toBe('/tasks?id=456');
});
it('should return valid path with hash fragment', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/dashboard#section');
expect(result).toBe('/dashboard#section');
});
it('should return root path', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/');
expect(result).toBe('/');
});
});
describe('Rejection of external URLs (open redirect protection)', () => {
it('should reject fully-qualified external URL with https', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('https://evil.com');
expect(result).toBe('/dashboard');
});
it('should reject fully-qualified external URL with http', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('http://evil.com');
expect(result).toBe('/dashboard');
});
it('should reject protocol-relative URL (double slash)', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('//evil.com');
expect(result).toBe('/dashboard');
});
it('should reject protocol-relative URL with path', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('//evil.com/dashboard');
expect(result).toBe('/dashboard');
});
it('should reject javascript: protocol', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('javascript:alert(1)');
expect(result).toBe('/dashboard');
});
it('should reject data: protocol', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('data:text/html,<script>alert(1)</script>');
expect(result).toBe('/dashboard');
});
});
describe('Invalid input handling', () => {
it('should return fallback when redirect is null', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(null);
expect(result).toBe('/dashboard');
});
it('should return fallback when redirect is undefined', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(undefined);
expect(result).toBe('/dashboard');
});
it('should return fallback when redirect is a number', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(123);
expect(result).toBe('/dashboard');
});
it('should return fallback when redirect is an object', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect({ path: '/dashboard' });
expect(result).toBe('/dashboard');
});
it('should return fallback when redirect is an empty string', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('');
expect(result).toBe('/dashboard');
});
it('should return fallback when redirect is only whitespace', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(' ');
expect(result).toBe('/dashboard');
});
it('should return fallback when redirect is an array', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(['/dashboard']);
expect(result).toBe('/dashboard');
});
});
describe('Custom fallback parameter', () => {
it('should use custom fallback when redirect is invalid', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('https://evil.com', '/projects');
expect(result).toBe('/projects');
});
it('should use custom fallback when redirect is null', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(null, '/home');
expect(result).toBe('/home');
});
it('should use custom fallback when redirect is undefined', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(undefined, '/login');
expect(result).toBe('/login');
});
it('should return valid redirect even when custom fallback is provided', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/dashboard', '/home');
expect(result).toBe('/dashboard');
});
});
describe('Edge cases', () => {
it('should reject URL with multiple slashes at start', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('///evil.com');
expect(result).toBe('/dashboard');
});
it('should handle path with encoded characters', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/dashboard%20test');
expect(result).toBe('/dashboard%20test');
});
it('should handle path with special characters', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/projects?name=test&id=123');
expect(result).toBe('/projects?name=test&id=123');
});
it('should reject backslash-based bypass attempt', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/\\evil.com');
expect(result).toBe('/\\evil.com'); // Valid local path (backslash is literal)
});
});
describe('Type safety', () => {
it('should accept string type for redirect parameter', async () => {
const { validateRedirect } = await import('../validateRedirect');
const redirect: string = '/dashboard';
const result = validateRedirect(redirect);
expect(result).toBe('/dashboard');
});
it('should accept unknown type for redirect parameter', async () => {
const { validateRedirect } = await import('../validateRedirect');
const redirect: unknown = '/dashboard';
const result = validateRedirect(redirect);
expect(result).toBe('/dashboard');
});
it('should return string type', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect('/dashboard');
expect(typeof result).toBe('string');
});
it('should accept optional fallback parameter', async () => {
const { validateRedirect } = await import('../validateRedirect');
// Should not throw when fallback is omitted
const result = validateRedirect('/dashboard');
expect(result).toBe('/dashboard');
});
it('should use default fallback when not provided', async () => {
const { validateRedirect } = await import('../validateRedirect');
const result = validateRedirect(null);
expect(result).toBe('/dashboard');
});
});
});