Files
timmal/app/composables/__tests__/useAuth.cross-tab.test.ts

471 lines
14 KiB
TypeScript
Raw Permalink Normal View History

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();
// Clear the call from auto-initialization that happens on first useAuth() import
mockAuthStore.onChange.mockClear();
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();
// Clear the call from auto-initialization
mockAuthStore.onChange.mockClear();
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;
// Clear mock from auto-initialization that happens on first useAuth() call
mockAuthStore.onChange.mockClear();
// initAuth should both restore and setup listener
await auth.initAuth();
// Verify restoration
expect(auth.user.value).toEqual(existingUser);
// Verify listener setup (only counting explicit initAuth call)
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');
});
});
});