diff --git a/src/lib/types/auth_forms.ts b/src/lib/types/auth_forms.ts new file mode 100644 index 000000000..39e3b6c86 --- /dev/null +++ b/src/lib/types/auth_forms.ts @@ -0,0 +1,84 @@ +/** + * Defines validation constraints that can be applied to form fields. + * + * @interface FieldConstraints + * @property {number} [minlength] - Minimum number of characters required for the field value + * @property {number} [maxlength] - Maximum number of characters allowed for the field value + * @property {boolean} [required] - Whether the field is mandatory and must have a value + * @property {string} [pattern] - Regular expression pattern that the field value must match + */ +export type FieldConstraints = { + minlength?: number; + maxlength?: number; + required?: boolean; + pattern?: string; +}; + +export type AuthFormConstraints = { + username?: FieldConstraints; + password?: FieldConstraints; +}; + +/** + * Represents the state and data structure for authentication forms. + * + * @interface AuthForm + * @property {string} id - Unique identifier for the form instance + * @property {boolean} valid - Indicates whether the form data passes validation + * @property {boolean} posted - Indicates whether the form has been submitted + * @property {Object} data - The form input data + * @property {string} data.username - The username field value + * @property {string} data.password - The password field value + * @property {Record} errors - Collection of validation errors keyed by field name + * @property {AuthFormConstraints} [constraints] - Optional validation constraints for the form + * @property {Record} [shape] - Optional form schema or structure definition + * @property {string} message - General message associated with the form (success, error, etc.) + */ +export type AuthForm = { + id: string; + valid: boolean; + posted: boolean; + data: { username: string; password: string }; + errors: Record; + constraints?: AuthFormConstraints; + shape?: Record; + message: string; +}; + +/** + * Represents a strategy for creating authentication forms. + * + * @interface AuthFormCreationStrategy + * @property {string} name - The unique identifier or display name for this creation strategy + * @property {() => Promise<{ form: AuthForm }>} run - Asynchronous function that executes the strategy and returns the created authentication form + */ +export type AuthFormCreationStrategy = { + name: string; + run: () => Promise<{ form: AuthForm }>; +}; + +export type AuthFormCreationStrategies = AuthFormCreationStrategy[]; + +/** + * Defines a validation strategy for authentication forms. + * + * A validation strategy encapsulates the logic for validating authentication + * form data from HTTP requests and returning the processed form object. + * + * @example + * ```typescript + * const loginStrategy: AuthFormValidationStrategy = { + * name: 'login', + * run: async (request) => { + * // Validation logic here + * return { form: validatedForm }; + * } + * }; + * ``` + */ +export type AuthFormValidationStrategy = { + name: string; + run: (request: Request) => Promise<{ form: AuthForm }>; +}; + +export type AuthFormValidationStrategies = AuthFormValidationStrategy[]; diff --git a/src/lib/utils/auth_forms.ts b/src/lib/utils/auth_forms.ts new file mode 100644 index 000000000..4d5e71fb2 --- /dev/null +++ b/src/lib/utils/auth_forms.ts @@ -0,0 +1,193 @@ +import { redirect } from '@sveltejs/kit'; + +import { superValidate } from 'sveltekit-superforms/server'; +import { zod } from 'sveltekit-superforms/adapters'; + +import type { + AuthForm, + AuthFormCreationStrategies, + AuthFormValidationStrategies, +} from '$lib/types/auth_forms'; + +import { authSchema } from '$lib/zod/schema'; +import { SEE_OTHER } from '$lib/constants/http-response-status-codes'; +import { HOME_PAGE } from '$lib/constants/navbar-links'; + +/** + * Initialize authentication form pages (login/signup) + * Redirects to home page if already logged in, + * otherwise initializes the authentication form for unauthenticated users + * @param locals - The application locals containing authentication state + * @returns { form: AuthForm } - The initialized authentication form + */ +export const initializeAuthForm = async (locals: App.Locals): Promise<{ form: AuthForm }> => { + const session = await locals.auth.validate(); + + if (session) { + redirect(SEE_OTHER, HOME_PAGE); + } + + return await createAuthFormWithFallback(); +}; + +/** + * Create authentication form with comprehensive fallback handling + * Tries multiple strategies until one succeeds + */ +export const createAuthFormWithFallback = async (): Promise<{ form: AuthForm }> => { + for (const strategy of formCreationStrategies) { + try { + const result = await strategy.run(); + + return result; + } catch (error) { + if (isDevelopmentMode()) { + console.warn(`Create authForm strategy: Failed to ${strategy.name}`); + console.warn(error instanceof Error ? (error.stack ?? error.message) : error); + } + } + } + + // This should never be reached due to manual creation strategy + throw new Error('Failed to create form for authentication.'); +}; + +/** + * Form creation strategies in order of preference + * Each strategy attempts a different approach to create a valid form + * + * See: + * https://superforms.rocks/migration-v2#supervalidate + * https://superforms.rocks/concepts/client-validation + * https://superforms.rocks/api#supervalidate-options + */ +const formCreationStrategies: AuthFormCreationStrategies = [ + { + name: '(Basic case) Use standard superValidate', + async run() { + const form = await superValidate(zod(authSchema)); + return { form: { ...form, message: '' } }; + }, + }, + { + name: 'Create form by manually defining structure', + async run() { + const defaultForm = { + valid: false, + posted: false, + errors: {}, + message: '', + ...createBaseAuthForm(), + }; + + return { form: { ...defaultForm, message: '' } }; + }, + }, +]; + +/** + * Validate authentication form data with comprehensive fallback handling + * Tries multiple strategies until one succeeds + * + * @param request - The incoming request containing form data + * @returns The validated form object (bare form, suitable for actions: fail(..., { form })) + */ +export const validateAuthFormWithFallback = async (request: Request): Promise => { + for (const strategy of formValidationStrategies) { + try { + const result = await strategy.run(request); + + return result.form; + } catch (error) { + if (isDevelopmentMode()) { + console.warn(`Validate authForm strategy: Failed to ${strategy.name}`); + console.warn(error instanceof Error ? (error.stack ?? error.message) : error); + } + } + } + + // This should never be reached due to fallback strategy + throw new Error('Failed to validate form for authentication.'); +}; + +/** + * Form validation strategies for action handlers + * Each strategy attempts a different approach to validate form data from requests + */ +const formValidationStrategies: AuthFormValidationStrategies = [ + { + name: '(Basic Case) Use standard superValidate with request', + async run(request: Request) { + const form = await superValidate(request, zod(authSchema)); + return { form: { ...form, message: '' } }; + }, + }, + { + name: 'Create fallback form manually', + async run(_request: Request) { + // Create a fallback form with error state + // This maintains consistency with other strategies by returning { form } + const fallbackForm = { + valid: false, + posted: true, + errors: { _form: ['ログインできませんでした。'] }, + message: 'サーバでエラーが発生しました。本サービスの開発・運営チームまでご連絡ください。', + ...createBaseAuthForm(), + }; + + return { form: fallbackForm }; + }, + }, +]; + +/** + * Helper function to validate if we're in development mode + * This can be mocked in tests to control logging behavior + */ +export const isDevelopmentMode = (): boolean => { + return import.meta.env.DEV; +}; + +/** + * Common form structure for authentication forms + * Contains constraints and shape definitions used across different form strategies + */ +const createBaseAuthForm = () => ({ + id: getBaseAuthFormId(), + data: { username: '', password: '' }, + constraints: { + username: { minlength: 3, maxlength: 24, required: true, pattern: '[\\w]*' }, + password: { + minlength: 8, + maxlength: 128, + required: true, + pattern: '(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d)[a-zA-Z\\d]{8,128}', + }, + }, + shape: { + username: { type: 'string' }, + password: { type: 'string' }, + }, +}); + +/** + * Generates a unique identifier for authentication form elements. + * + * Uses Web Crypto API's randomUUID() when available, falling back to a + * timestamp-based random string for environments where crypto is unavailable. + * + * @returns A unique string identifier prefixed with 'error-fallback-form-' + * + * @example + * ```typescript + * const formId = getBaseAuthFormId(); + * // Returns: "error-fallback-form-550e8400-e29b-41d4-a716-446655440000" + * // or: "error-fallback-form-1703875200000-abc123def" + * ``` + */ +const getBaseAuthFormId = () => { + return ( + 'error-fallback-form-' + + (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`) + ); // Fallback when Web Crypto is unavailable +}; diff --git a/src/lib/utils/authorship.ts b/src/lib/utils/authorship.ts index 1d36c7843..382568a27 100644 --- a/src/lib/utils/authorship.ts +++ b/src/lib/utils/authorship.ts @@ -3,39 +3,75 @@ import { redirect } from '@sveltejs/kit'; import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes'; import { Roles } from '$lib/types/user'; +/** + * Ensure user has a valid session or redirect to login + * @param locals - The application locals containing auth and user information + * @returns {Promise} + */ export const ensureSessionOrRedirect = async (locals: App.Locals): Promise => { const session = await locals.auth.validate(); if (!session) { - throw redirect(TEMPORARY_REDIRECT, '/login'); + redirect(TEMPORARY_REDIRECT, '/login'); } }; +/** + * Get the current logged-in user or redirect to login + * @param locals - The application locals containing auth and user information + * @returns {Promise} - The logged-in user or null + */ export const getLoggedInUser = async (locals: App.Locals): Promise => { await ensureSessionOrRedirect(locals); const loggedInUser = locals.user; if (!loggedInUser) { - throw redirect(TEMPORARY_REDIRECT, '/login'); + redirect(TEMPORARY_REDIRECT, '/login'); } return loggedInUser; }; +/** + * Validate if the user has admin role + * @param role - User role + * @returns {boolean} - True if user is admin, false otherwise + */ export const isAdmin = (role: Roles): boolean => { return role === Roles.ADMIN; }; +/** + * Validate if the user has authority (is the author) + * @param userId - The user id + * @param authorId - The author id + * @returns {boolean} - True if user has authority, false otherwise + */ export const hasAuthority = (userId: string, authorId: string): boolean => { - return userId.toLocaleLowerCase() === authorId.toLocaleLowerCase(); + return userId.toLowerCase() === authorId.toLowerCase(); }; -// Note: 公開 + 非公開(本人のみ)の問題集が閲覧できる +/** + * Validate if user can read the workbook + * Public workbooks can be read by anyone, private workbooks only by the author + * @param isPublished - Whether the workbook is published + * @param userId - The user id + * @param authorId - The author id + * @returns {boolean} - True if user can read, false otherwise + */ export const canRead = (isPublished: boolean, userId: string, authorId: string): boolean => { return isPublished || hasAuthority(userId, authorId); }; -// Note: 特例として、管理者はユーザが公開している問題集を編集できる +/** + * Validate if user can edit the workbook + * Authors can always edit their workbooks + * Admins can edit public workbooks as a special case + * @param userId - The user id + * @param authorId - The author id + * @param role - User role + * @returns {boolean} - True if user can edit, false otherwise + */ export const canEdit = ( userId: string, authorId: string, @@ -45,7 +81,13 @@ export const canEdit = ( return hasAuthority(userId, authorId) || (isAdmin(role) && isPublished); }; -// Note: 本人のみ削除可能 +/** + * Validate if user can delete the workbook + * Only the author can delete their workbooks + * @param userId - The user id + * @param authorId - The author id + * @returns {boolean} - True if user can delete, false otherwise + */ export const canDelete = (userId: string, authorId: string): boolean => { return hasAuthority(userId, authorId); }; diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 4a0636aa9..b48892d11 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -1,12 +1,12 @@ // See: // https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ -// https://superforms.rocks/get-started -import { superValidate } from 'sveltekit-superforms/server'; -import { zod } from 'sveltekit-superforms/adapters'; + +// This route uses centralized helpers with fallback validation strategies. +// See src/lib/utils/auth_forms.ts for the current form handling approach. import { fail, redirect } from '@sveltejs/kit'; import { LuciaError } from 'lucia'; -import { authSchema } from '$lib/zod/schema'; +import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms'; import { auth } from '$lib/server/auth'; import { @@ -19,20 +19,12 @@ import { HOME_PAGE } from '$lib/constants/navbar-links'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - - if (session) { - redirect(SEE_OTHER, HOME_PAGE); - } - - const form = await superValidate(null, zod(authSchema)); - - return { form: { ...form, message: '' } }; + return initializeAuthForm(locals); }; export const actions: Actions = { default: async ({ request, locals }) => { - const form = await superValidate(request, zod(authSchema)); + const form = await validateAuthFormWithFallback(request); if (!form.valid) { return fail(BAD_REQUEST, { diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts index 809711859..9f8b4feb9 100644 --- a/src/routes/(auth)/signup/+page.server.ts +++ b/src/routes/(auth)/signup/+page.server.ts @@ -1,13 +1,13 @@ // See: // https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ -// https://superforms.rocks/get-started -import { superValidate } from 'sveltekit-superforms/server'; -import { zod } from 'sveltekit-superforms/adapters'; + +// This route uses centralized helpers with fallback validation strategies. +// See src/lib/utils/auth_forms.ts for the current form handling approach. import { fail, redirect } from '@sveltejs/kit'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { LuciaError } from 'lucia'; -import { authSchema } from '$lib/zod/schema'; +import { initializeAuthForm, validateAuthFormWithFallback } from '$lib/utils/auth_forms'; import { auth } from '$lib/server/auth'; import { @@ -20,21 +20,13 @@ import { HOME_PAGE } from '$lib/constants/navbar-links'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth.validate(); - - if (session) { - redirect(SEE_OTHER, HOME_PAGE); - } - - const form = await superValidate(null, zod(authSchema)); - - return { form: { ...form, message: '' } }; + return initializeAuthForm(locals); }; // FIXME: エラー処理に共通部分があるため、リファクタリングをしましょう。 export const actions: Actions = { default: async ({ request, locals }) => { - const form = await superValidate(request, zod(authSchema)); + const form = await validateAuthFormWithFallback(request); if (!form.valid) { return fail(BAD_REQUEST, { @@ -94,6 +86,6 @@ export const actions: Actions = { // redirect to // make sure you don't throw inside a try/catch block! - redirect(SEE_OTHER, HOME_PAGE); + return redirect(SEE_OTHER, HOME_PAGE); }, }; diff --git a/src/test/lib/utils/auth_forms.test.ts b/src/test/lib/utils/auth_forms.test.ts new file mode 100644 index 000000000..ef634e0dc --- /dev/null +++ b/src/test/lib/utils/auth_forms.test.ts @@ -0,0 +1,460 @@ +import { vi, expect, describe, beforeEach, afterEach, test } from 'vitest'; +import type { SuperValidated } from 'sveltekit-superforms'; + +// Mock external dependencies BEFORE importing the module under test +vi.mock('@sveltejs/kit', () => { + const redirectImpl = (status: number, location: string) => { + const error = new Error('Redirect'); + + (error as any).name = 'Redirect'; + (error as any).status = status; + (error as any).location = location; + + throw error; + }; + + return { redirect: vi.fn(redirectImpl) }; +}); + +vi.mock('sveltekit-superforms/server', () => ({ + superValidate: vi.fn(), +})); + +vi.mock('sveltekit-superforms/adapters', () => ({ + zod: vi.fn(), +})); + +vi.mock('$lib/zod/schema', () => ({ + authSchema: { + _type: 'ZodObject', + shape: { + username: { type: 'string' }, + password: { type: 'string' }, + }, + }, +})); + +vi.mock('$lib/constants/http-response-status-codes', () => ({ + SEE_OTHER: 303, +})); + +vi.mock('$lib/constants/navbar-links', () => ({ + HOME_PAGE: '/', +})); + +// Import AFTER mocking +import { redirect } from '@sveltejs/kit'; +import { superValidate } from 'sveltekit-superforms/server'; +import { zod } from 'sveltekit-superforms/adapters'; + +import { + initializeAuthForm, + createAuthFormWithFallback, + validateAuthFormWithFallback, +} from '$lib/utils/auth_forms'; + +// Mock console methods +const mockConsoleWarn = vi.fn(); +const mockConsoleError = vi.fn(); + +// Helper function to create mock Request object +const createMockRequest = (username: string = '', password: string = '') => { + return new Request('http://localhost:3000', { + method: 'POST', + body: new URLSearchParams({ username, password }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); +}; + +// Mock locals object +const createMockLocals = (hasSession: boolean = false) => + ({ + auth: { + validate: vi.fn().mockResolvedValue(hasSession ? { user: { id: 'test-user' } } : null), + }, + }) as unknown as App.Locals; + +describe('auth_forms', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock console methods + vi.stubGlobal('console', { + ...console, + warn: mockConsoleWarn, + error: mockConsoleError, + }); + + // Mock crypto.randomUUID for consistent testing + vi.stubGlobal('crypto', { + randomUUID: vi.fn(() => 'test-uuid-12345'), + }); + + // Improved request-aware superValidate mock + vi.mocked(superValidate).mockImplementation(async (arg: unknown) => { + let data = { username: '', password: '' }; + let posted = false; + + // If arg is a Request, parse its form data + if (arg instanceof Request) { + posted = arg.method?.toUpperCase() === 'POST'; + + try { + const formData = await arg.clone().formData(); + + data = { + username: formData.get('username')?.toString() || '', + password: formData.get('password')?.toString() || '', + }; + } catch { + // If parsing fails, use default data + data = { username: '', password: '' }; + } + } + + return { + id: 'test-form-id', + valid: true, + posted, + data, + errors: {}, + constraints: { + username: { minlength: 3, maxlength: 24, required: true, pattern: '[\\w]*' }, + password: { + minlength: 8, + maxlength: 128, + required: true, + pattern: '(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d)[a-zA-Z\\d]{8,128}', + }, + }, + shape: { + username: { type: 'string' }, + password: { type: 'string' }, + } as unknown, + message: '', + } as unknown as SuperValidated, string>; + }); + + vi.mocked(zod).mockImplementation((schema: unknown) => schema as any); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('initializeAuthForm', () => { + test('expect to redirect to home page if user is already logged in', async () => { + const mockLocals = createMockLocals(true); + + await expect(initializeAuthForm(mockLocals)).rejects.toMatchObject({ + name: 'Redirect', + location: '/', + }); + expect(redirect).toHaveBeenCalledWith(303, '/'); + }); + + test('expect to create auth form if user is not logged in', async () => { + const mockLocals = createMockLocals(false); + + const result = await initializeAuthForm(mockLocals); + + expect(result).toBeDefined(); + expect(result.form).toBeDefined(); + expect(result.form.data).toEqual({ username: '', password: '' }); + expect(result.form.message).toBe(''); + }); + }); + + describe('createAuthFormWithFallback', () => { + test('expect to create form successfully with primary strategy', async () => { + const result = await createAuthFormWithFallback(); + + expect(result).toBeDefined(); + expect(result.form).toBeDefined(); + expect(result.form.data).toEqual({ username: '', password: '' }); + expect(result.form.valid).toBe(true); + expect(result.form.posted).toBe(false); + expect(result.form.errors).toEqual({}); + expect(result.form.message).toBe(''); + }); + + test('expect to include constraints in the form', async () => { + const result = await createAuthFormWithFallback(); + + expect(result.form.constraints).toBeDefined(); + + if (result.form.constraints) { + expect(result.form.constraints.username).toEqual({ + minlength: 3, + maxlength: 24, + required: true, + pattern: '[\\w]*', + }); + expect(result.form.constraints.password).toEqual({ + minlength: 8, + maxlength: 128, + required: true, + pattern: '(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d)[a-zA-Z\\d]{8,128}', + }); + } + }); + + test('expect to include shape property for production compatibility', async () => { + const result = await createAuthFormWithFallback(); + + expect(result.form.shape).toBeDefined(); + expect(result.form.shape).toEqual({ + username: { type: 'string' }, + password: { type: 'string' }, + }); + }); + + test('expect to generate unique form ID using crypto.randomUUID', async () => { + // Mock superValidate to fail so it falls back to createBaseAuthForm which uses crypto.randomUUID + vi.mocked(superValidate).mockRejectedValueOnce(new Error('Primary strategy failed')); + + const result = await createAuthFormWithFallback(); + + expect(result.form.id).toContain('error-fallback-form-'); + expect(globalThis.crypto.randomUUID).toHaveBeenCalled(); + }); + + test('expect to use fallback ID generation when crypto.randomUUID is unavailable', async () => { + // Mock crypto.randomUUID to be undefined (preserve other properties) + const prevCrypto = globalThis.crypto; + vi.stubGlobal('crypto', { ...(prevCrypto ?? {}), randomUUID: undefined }); + + vi.mocked(superValidate).mockRejectedValueOnce(new Error('Primary strategy failed')); + + const result = await createAuthFormWithFallback(); + + expect(result.form.id).toMatch(/^error-fallback-form-\d+-[a-z0-9]+$/); + }); + + test('expect to use fallback strategy when primary strategy fails', async () => { + // Mock superValidate to fail + vi.mocked(superValidate).mockRejectedValueOnce(new Error('SuperValidate failed')); + + const result = await createAuthFormWithFallback(); + + expect(result).toBeDefined(); + expect(result.form).toBeDefined(); + expect(result.form.data).toEqual({ username: '', password: '' }); + expect(result.form.id).toContain('error-fallback-form-'); + + if (import.meta.env.DEV) { + expect(mockConsoleWarn).toHaveBeenCalled(); + } else { + expect(mockConsoleWarn).not.toHaveBeenCalled(); + } + }); + }); + + describe('validateAuthFormWithFallback', () => { + test('expect to validate form successfully with valid request', async () => { + const mockRequest = createMockRequest('testuser', 'TestPass123'); + + const result = await validateAuthFormWithFallback(mockRequest); + + expect(result).toBeDefined(); + expect(result.data).toEqual({ + username: 'testuser', + password: 'TestPass123', + }); + expect(result.posted).toBe(true); + expect(result.message).toBe(''); + }); + + test('expect to handle validation with empty form data', async () => { + const mockRequest = createMockRequest('', ''); + + const result = await validateAuthFormWithFallback(mockRequest); + + expect(result).toBeDefined(); + expect(result.data).toEqual({ + username: '', + password: '', + }); + expect(result.posted).toBe(true); + }); + + test('expect to distinguish between POST and GET requests', async () => { + // Test POST request + const postRequest = createMockRequest('testuser', 'testpass'); + const postResult = await validateAuthFormWithFallback(postRequest); + + expect(postResult.posted).toBe(true); + expect(postResult.data).toEqual({ + username: 'testuser', + password: 'testpass', + }); + + // Test GET request (no form data) + const getRequest = new Request('http://localhost:3000', { method: 'GET' }); + const getResult = await validateAuthFormWithFallback(getRequest); + + expect(getResult.posted).toBe(false); + expect(getResult.data).toEqual({ + username: '', + password: '', + }); + }); + + test('expect to use fallback strategy when primary validation fails', async () => { + // Mock superValidate to fail + vi.mocked(superValidate).mockRejectedValueOnce(new Error('Validation failed')); + + const mockRequest = createMockRequest('testuser', 'TestPass123'); + + const result = await validateAuthFormWithFallback(mockRequest); + + expect(result).toBeDefined(); + // When validation fails, but another strategy succeeds, valid could be true + expect(result.posted).toBe(true); + + if (import.meta.env.DEV) { + expect(mockConsoleWarn).toHaveBeenCalled(); + } else { + expect(mockConsoleWarn).not.toHaveBeenCalled(); + } + }); + + test('expect to handle complex form data', async () => { + const mockRequest = createMockRequest('complexuser123', 'ComplexPass123!'); + + const result = await validateAuthFormWithFallback(mockRequest); + + expect(result).toBeDefined(); + expect(result.data).toEqual({ + username: 'complexuser123', + password: 'ComplexPass123!', + }); + expect(result.posted).toBe(true); + }); + }); + + describe('Strategy Pattern Implementation', () => { + test('expect to log warnings in development mode when strategies fail', async () => { + // Mock superValidate to fail for the first strategy + vi.mocked(superValidate).mockRejectedValueOnce(new Error('First strategy failed')); + + await createAuthFormWithFallback(); + + if (import.meta.env.DEV) { + expect(mockConsoleWarn).toHaveBeenCalled(); + } else { + expect(mockConsoleWarn).not.toHaveBeenCalled(); + } + }); + + test('expect to handle Error objects correctly in strategy failure logging', async () => { + const testError = new Error('Test error message'); + + // Mock superValidate to fail with specific error + vi.mocked(superValidate).mockRejectedValueOnce(testError); + + await createAuthFormWithFallback(); + + if (import.meta.env.DEV) { + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'Create authForm strategy: Failed to (Basic case) Use standard superValidate', + ); + // Error objects are converted to string and include stack trace + expect(mockConsoleWarn).toHaveBeenCalledWith( + expect.stringContaining('Error: Test error message'), + ); + } else { + expect(mockConsoleWarn).not.toHaveBeenCalled(); + } + }); + + // Note: "expect to not log warnings in production mode" was removed + // The test case is omitted because warnings are not recorded in the log, + // and since this condition rarely occurs in the production environment. + + test('expect to handle non-Error objects in strategy failure logging', async () => { + const testError = 'String error'; + + // Mock superValidate to fail with non-Error object + vi.mocked(superValidate).mockRejectedValueOnce(testError); + + await createAuthFormWithFallback(); + + if (import.meta.env.DEV) { + expect(mockConsoleWarn).toHaveBeenCalledWith( + 'Create authForm strategy: Failed to (Basic case) Use standard superValidate', + ); + expect(mockConsoleWarn).toHaveBeenCalledWith('String error'); + } else { + expect(mockConsoleWarn).not.toHaveBeenCalled(); + } + }); + }); + + describe('Form Structure Consistency', () => { + test('expect to maintain consistent form structure across all strategies', async () => { + const result = await createAuthFormWithFallback(); + + // Validate required form properties + expect(result.form).toHaveProperty('id'); + expect(result.form).toHaveProperty('data'); + expect(result.form).toHaveProperty('constraints'); + expect(result.form).toHaveProperty('shape'); + expect(result.form).toHaveProperty('valid'); + expect(result.form).toHaveProperty('posted'); + expect(result.form).toHaveProperty('errors'); + expect(result.form).toHaveProperty('message'); + }); + + test('expect to ensure data structure is consistent', async () => { + const result = await createAuthFormWithFallback(); + + expect(result.form.data).toEqual({ + username: '', + password: '', + }); + }); + + test('expect to validate that constraints follow expected pattern', async () => { + const result = await createAuthFormWithFallback(); + + const constraints = result.form.constraints; + expect(constraints).toBeDefined(); + + if (constraints) { + // Username constraints + expect(constraints.username?.minlength).toBe(3); + expect(constraints.username?.maxlength).toBe(24); + expect(constraints.username?.required).toBe(true); + expect(constraints.username?.pattern).toBe('[\\w]*'); + + // Password constraints + expect(constraints.password?.minlength).toBe(8); + expect(constraints.password?.maxlength).toBe(128); + expect(constraints.password?.required).toBe(true); + expect(constraints.password?.pattern).toBe( + '(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d)[a-zA-Z\\d]{8,128}', + ); + } + }); + }); + + describe('Error Handling', () => { + test('expect to handle validation fallback correctly', async () => { + // Mock superValidate to fail + vi.mocked(superValidate).mockRejectedValue(new Error('Validation failed')); + + const mockRequest = createMockRequest('testuser', 'TestPass123'); + const result = await validateAuthFormWithFallback(mockRequest); + + expect(result.valid).toBe(false); + expect(result.posted).toBe(true); + expect(result.errors).toEqual({ _form: ['ログインできませんでした。'] }); + expect(result.message).toBe( + 'サーバでエラーが発生しました。本サービスの開発・運営チームまでご連絡ください。', + ); + }); + }); +}); diff --git a/src/test/lib/utils/authorship.test.ts b/src/test/lib/utils/authorship.test.ts index 3793325d2..50be82597 100644 --- a/src/test/lib/utils/authorship.test.ts +++ b/src/test/lib/utils/authorship.test.ts @@ -1,5 +1,33 @@ -import { expect, test } from 'vitest'; -import { hasAuthority, canRead, canEdit, canDelete } from '$lib/utils/authorship'; +import { expect, test, describe, vi, afterEach } from 'vitest'; + +// Mock modules +vi.mock('@sveltejs/kit', () => { + const redirectImpl = (status: number, location: string) => { + const error = new Error('Redirect'); + + (error as any).name = 'Redirect'; + (error as any).status = status; + (error as any).location = location; + + throw error; + }; + + return { redirect: vi.fn(redirectImpl) }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +import { + ensureSessionOrRedirect, + getLoggedInUser, + isAdmin, + hasAuthority, + canRead, + canEdit, + canDelete, +} from '$lib/utils/authorship'; import type { Authorship, AuthorshipForRead, @@ -15,25 +43,110 @@ const userId2 = '3'; // See: // https://vitest.dev/api/#describe // https://vitest.dev/api/#test-each +describe('ensureSessionOrRedirect', () => { + test('expect not to throw when user has valid session', async () => { + const mockLocals = { + auth: { + validate: vi.fn().mockResolvedValue({ user: { id: 'test-user' } }), + }, + } as unknown as App.Locals; + + await expect(ensureSessionOrRedirect(mockLocals)).resolves.toBeUndefined(); + expect(mockLocals.auth.validate).toHaveBeenCalledTimes(1); + }); + + test('expect to redirect when user has no session', async () => { + const mockLocals = { + auth: { + validate: vi.fn().mockResolvedValue(null), + }, + } as unknown as App.Locals; + + await expect(ensureSessionOrRedirect(mockLocals)).rejects.toMatchObject({ + name: 'Redirect', + status: expect.any(Number), + location: '/login', + }); + }); +}); + +describe('getLoggedInUser', () => { + test('expect to return user when session and user exist', async () => { + const mockUser = { id: 'test-user', name: 'Test User' }; + const mockLocals = { + auth: { + validate: vi.fn().mockResolvedValue({ user: mockUser }), + }, + user: mockUser, + } as unknown as App.Locals; + + const result = await getLoggedInUser(mockLocals); + + expect(result).toEqual(mockUser); + expect(mockLocals.auth.validate).toHaveBeenCalledTimes(1); + }); + + test('expect to redirect when no session', async () => { + const mockLocals = { + auth: { + validate: vi.fn().mockResolvedValue(null), + }, + } as unknown as App.Locals; + + await expect(getLoggedInUser(mockLocals)).rejects.toMatchObject({ + name: 'Redirect', + status: expect.any(Number), + location: '/login', + }); + }); + + test('expect to redirect when session exists but no user', async () => { + const mockLocals = { + auth: { + validate: vi.fn().mockResolvedValue({ user: { id: 'test-user' } }), + }, + user: null, + } as unknown as App.Locals; + + await expect(getLoggedInUser(mockLocals)).rejects.toMatchObject({ + name: 'Redirect', + status: expect.any(Number), + location: '/login', + }); + }); +}); + +describe('isAdmin', () => { + test('expect to return true for ADMIN role', () => { + expect(isAdmin(Roles.ADMIN)).toBe(true); + }); + + test('expect to return false for USER role', () => { + expect(isAdmin(Roles.USER)).toBe(false); + }); +}); + describe('Logged-in user id', () => { describe('has authority', () => { describe('when userId and authorId are the same', () => { const testCases = [ { userId: adminId, authorId: adminId }, { userId: userId1, authorId: userId1 }, + { userId: 'USER123', authorId: 'user123' }, + { userId: 'AuthorX', authorId: 'authorx' }, ]; runTests('hasAuthority', testCases, ({ userId, authorId }: Authorship) => { - expect(hasAuthority(userId, authorId)).toBeTruthy(); + expect(hasAuthority(userId, authorId)).toBe(true); }); }); - describe('when userId and authorId are not same ', () => { + describe('when userId and authorId are not the same', () => { const testCases = [ { userId: adminId, authorId: userId1 }, { userId: userId1, authorId: adminId }, ]; runTests('hasAuthority', testCases, ({ userId, authorId }: Authorship) => { - expect(hasAuthority(userId, authorId)).toBeFalsy(); + expect(hasAuthority(userId, authorId)).toBe(false); }); }); @@ -50,40 +163,44 @@ describe('Logged-in user id', () => { describe('when the workbook is published', () => { const testCases = [ { isPublished: true, userId: adminId, authorId: adminId }, + { isPublished: true, userId: adminId, authorId: userId1 }, + { isPublished: true, userId: adminId, authorId: userId2 }, { isPublished: true, userId: userId1, authorId: adminId }, { isPublished: true, userId: userId1, authorId: userId1 }, { isPublished: true, userId: userId2, authorId: userId1 }, { isPublished: true, userId: userId1, authorId: userId2 }, - { isPublished: true, userId: adminId, authorId: userId1 }, - { isPublished: true, userId: adminId, authorId: userId2 }, ]; runTests('canRead', testCases, ({ isPublished, userId, authorId }: AuthorshipForRead) => { - expect(canRead(isPublished, userId, authorId)).toBeTruthy(); + expect(canRead(isPublished, userId, authorId)).toBe(true); }); }); - describe('when the workbook is unpublished but created by oneself', () => { - const testCases = [ - { isPublished: false, userId: adminId, authorId: adminId }, - { isPublished: false, userId: userId1, authorId: userId1 }, - { isPublished: false, userId: userId2, authorId: userId2 }, - ]; - runTests('canRead', testCases, ({ isPublished, userId, authorId }: AuthorshipForRead) => { - expect(canRead(isPublished, userId, authorId)).toBeTruthy(); + describe('when the workbook is not published', () => { + describe('but the user is the author', () => { + const testCases = [ + { isPublished: false, userId: adminId, authorId: adminId }, + { isPublished: false, userId: userId1, authorId: userId1 }, + { isPublished: false, userId: userId2, authorId: userId2 }, + { isPublished: false, userId: 'USER123', authorId: 'user123' }, + { isPublished: false, userId: 'AuthorX', authorId: 'authorx' }, + ]; + runTests('canRead', testCases, ({ isPublished, userId, authorId }: AuthorshipForRead) => { + expect(canRead(isPublished, userId, authorId)).toBe(true); + }); }); - }); - describe('when the workbook is unpublished and created by others', () => { - const testCases = [ - { isPublished: false, userId: userId1, authorId: adminId }, - { isPublished: false, userId: userId2, authorId: adminId }, - { isPublished: false, userId: adminId, authorId: userId1 }, - { isPublished: false, userId: adminId, authorId: userId2 }, - { isPublished: false, userId: userId1, authorId: userId2 }, - { isPublished: false, userId: userId2, authorId: userId1 }, - ]; - runTests('canRead', testCases, ({ isPublished, userId, authorId }: AuthorshipForRead) => { - expect(canRead(isPublished, userId, authorId)).toBeFalsy(); + describe('and the user is not the author', () => { + const testCases = [ + { isPublished: false, userId: userId1, authorId: adminId }, + { isPublished: false, userId: userId2, authorId: adminId }, + { isPublished: false, userId: adminId, authorId: userId1 }, + { isPublished: false, userId: adminId, authorId: userId2 }, + { isPublished: false, userId: userId1, authorId: userId2 }, + { isPublished: false, userId: userId2, authorId: userId1 }, + ]; + runTests('canRead', testCases, ({ isPublished, userId, authorId }: AuthorshipForRead) => { + expect(canRead(isPublished, userId, authorId)).toBe(false); + }); }); }); @@ -100,7 +217,7 @@ describe('Logged-in user id', () => { }); describe('can edit', () => { - describe('when the workbook is created by oneself', () => { + describe('when the user is the author', () => { const testCases = [ { userId: adminId, authorId: adminId, role: Roles.ADMIN, isPublished: true }, { userId: adminId, authorId: adminId, role: Roles.ADMIN, isPublished: false }, @@ -113,12 +230,12 @@ describe('Logged-in user id', () => { 'canEdit', testCases, ({ userId, authorId, role, isPublished }: AuthorshipForEdit) => { - expect(canEdit(userId, authorId, role, isPublished)).toBeTruthy(); + expect(canEdit(userId, authorId, role, isPublished)).toBe(true); }, ); }); - describe('(special case) admin can edit workbooks created by users', () => { + describe('when the user is not the author but is admin and workbook is published', () => { const testCases = [ { userId: adminId, authorId: userId1, role: Roles.ADMIN, isPublished: true }, { userId: adminId, authorId: userId2, role: Roles.ADMIN, isPublished: true }, @@ -127,31 +244,45 @@ describe('Logged-in user id', () => { 'canEdit', testCases, ({ userId, authorId, role, isPublished }: AuthorshipForEdit) => { - expect(canEdit(userId, authorId, role, isPublished)).toBeTruthy(); + expect(canEdit(userId, authorId, role, isPublished)).toBe(true); }, ); }); - describe('when the workbook is created by others', () => { - const testCases = [ - { userId: userId1, authorId: adminId, role: Roles.USER, isPublished: true }, - { userId: userId1, authorId: adminId, role: Roles.USER, isPublished: false }, - { userId: userId2, authorId: adminId, role: Roles.USER, isPublished: true }, - { userId: userId2, authorId: adminId, role: Roles.USER, isPublished: false }, - { userId: adminId, authorId: userId1, role: Roles.ADMIN, isPublished: false }, - { userId: adminId, authorId: userId2, role: Roles.ADMIN, isPublished: false }, - { userId: userId1, authorId: userId2, role: Roles.USER, isPublished: true }, - { userId: userId1, authorId: userId2, role: Roles.USER, isPublished: false }, - { userId: userId2, authorId: userId1, role: Roles.USER, isPublished: true }, - { userId: userId2, authorId: userId1, role: Roles.USER, isPublished: false }, - ]; - runTests( - 'canEdit', - testCases, - ({ userId, authorId, role, isPublished }: AuthorshipForEdit) => { - expect(canEdit(userId, authorId, role, isPublished)).toBeFalsy(); - }, - ); + describe('when the user is not the author', () => { + describe('and the user is not admin', () => { + const testCases = [ + { userId: userId1, authorId: adminId, role: Roles.USER, isPublished: true }, + { userId: userId1, authorId: adminId, role: Roles.USER, isPublished: false }, + { userId: userId2, authorId: adminId, role: Roles.USER, isPublished: true }, + { userId: userId2, authorId: adminId, role: Roles.USER, isPublished: false }, + { userId: userId1, authorId: userId2, role: Roles.USER, isPublished: true }, + { userId: userId1, authorId: userId2, role: Roles.USER, isPublished: false }, + { userId: userId2, authorId: userId1, role: Roles.USER, isPublished: true }, + { userId: userId2, authorId: userId1, role: Roles.USER, isPublished: false }, + ]; + runTests( + 'canEdit', + testCases, + ({ userId, authorId, role, isPublished }: AuthorshipForEdit) => { + expect(canEdit(userId, authorId, role, isPublished)).toBe(false); + }, + ); + }); + + describe('or the user is admin but workbook is not published', () => { + const testCases = [ + { userId: adminId, authorId: userId1, role: Roles.ADMIN, isPublished: false }, + { userId: adminId, authorId: userId2, role: Roles.ADMIN, isPublished: false }, + ]; + runTests( + 'canEdit', + testCases, + ({ userId, authorId, role, isPublished }: AuthorshipForEdit) => { + expect(canEdit(userId, authorId, role, isPublished)).toBe(false); + }, + ); + }); }); function runTests( @@ -167,18 +298,19 @@ describe('Logged-in user id', () => { }); describe('can delete', () => { - describe('when the workbook is created by oneself', () => { + describe('when the user is the author', () => { const testCases = [ { userId: adminId, authorId: adminId }, { userId: userId1, authorId: userId1 }, { userId: userId2, authorId: userId2 }, + { userId: 'UserX', authorId: 'userx' }, ]; runTests('canDelete', testCases, ({ userId, authorId }: AuthorshipForDelete) => { - expect(canDelete(userId, authorId)).toBeTruthy(); + expect(canDelete(userId, authorId)).toBe(true); }); }); - describe('when the workbook is created by others', () => { + describe('when the user is not the author', () => { const testCases = [ { userId: adminId, authorId: userId1 }, { userId: adminId, authorId: userId2 }, @@ -188,7 +320,7 @@ describe('Logged-in user id', () => { { userId: userId2, authorId: userId1 }, ]; runTests('canDelete', testCases, ({ userId, authorId }: AuthorshipForDelete) => { - expect(canDelete(userId, authorId)).toBeFalsy(); + expect(canDelete(userId, authorId)).toBe(false); }); });