import assert from 'node:assert/strict'; import test from 'node:test'; import { buildApp } from '../src/app.js'; import { loadConfig } from '../src/config/env.js'; import { SESSION_COOKIE_NAME } from '../src/plugins/auth.js'; import { createTestConfig, FakeAllowedEmailRepository, FakeGroupRepository, FakeImportJobRepository, FakeRecipeRepository, fakeUserRepository, recipeDraft, source, } from './helpers/fakes.js'; function createDependencies( recipeRepository, groupRepository = new FakeGroupRepository(), importJobRepository = new FakeImportJobRepository(), ) { return { allowedEmailRepository: new FakeAllowedEmailRepository(), groupRepository, importJobRepository, recipeRepository, userRepository: fakeUserRepository, sourceExtractor: { extract: async () => source }, recipeParser: { parse: async () => recipeDraft, parseMany: async (entries) => entries.map(({ jobId }) => ({ jobId, recipe: recipeDraft })), }, imageStorage: { saveFromUrl: async (id) => `recipes/${id}/cover.webp`, removeRecipe: async () => {}, }, }; } function authCookie(app, sub = 'google-user-1') { const token = app.jwt.sign({ sub, email: `${sub}@example.com`, name: sub, picture: null, }); return `${SESSION_COOKIE_NAME}=${token}`; } test('로그인 세션 만료시간은 기본 24시간이며 환경변수로 변경할 수 있다', () => { assert.equal(loadConfig({}, { platform: 'win32' }).authSessionTtlHours, 24); assert.equal( loadConfig({ AUTH_SESSION_TTL_HOURS: '8' }, { platform: 'win32' }).authSessionTtlHours, 8, ); }); test('관리자는 이메일만으로 Google 로그인 허용 계정을 관리한다', async (t) => { const dependencies = createDependencies(new FakeRecipeRepository()); const app = await buildApp({ config: createTestConfig(), dependencies }); t.after(() => app.close()); const adminCookie = authCookie(app); const sessionResponse = await app.inject({ method: 'GET', url: '/auth/me', headers: { cookie: adminCookie }, }); assert.equal(sessionResponse.json().user.canManageAccess, true); const memberSessionResponse = await app.inject({ method: 'GET', url: '/auth/me', headers: { cookie: authCookie(app, 'google-user-2') }, }); assert.equal(memberSessionResponse.json().user.canManageAccess, false); const pageResponse = await app.inject({ method: 'GET', url: '/', headers: { cookie: adminCookie }, }); assert.match(pageResponse.body, /id="site-header"/); assert.match(pageResponse.body, /id="access-overlay"/); assert.match(pageResponse.body, /id="scroll-to-top"/); assert.match(pageResponse.body, /id="source-urls"/); assert.match(pageResponse.body, /id="import-queue"/); assert.match(pageResponse.body, /id="process-import-jobs"/); assert.doesNotMatch(pageResponse.body, /id="preview-section"/); const appBarResponse = await app.inject({ method: 'GET', url: '/js/app-bar.js' }); assert.equal(appBarResponse.statusCode, 200); assert.match(appBarResponse.body, /id="access-menu-button"/); assert.match(appBarResponse.body, /id="access-drawer"/); assert.match(appBarResponse.body, /id="drawer-logout"/); const memberPageResponse = await app.inject({ method: 'GET', url: '/', headers: { cookie: authCookie(app, 'google-user-2') }, }); assert.match(memberPageResponse.body, /id="site-header"/); const detailPageResponse = await app.inject({ method: 'GET', url: '/recipe/recipe-1', headers: { cookie: adminCookie }, }); assert.match(detailPageResponse.body, /id="site-header"/); assert.doesNotMatch(detailPageResponse.body, /id="logout-button"/); const addResponse = await app.inject({ method: 'POST', url: '/api/allowed-emails', headers: { cookie: adminCookie }, payload: { email: ' New.User@Example.com ' }, }); assert.equal(addResponse.statusCode, 201); assert.equal(addResponse.json().email, 'new.user@example.com'); const listResponse = await app.inject({ method: 'GET', url: '/api/allowed-emails', headers: { cookie: adminCookie }, }); assert.ok(listResponse.json().emails.includes('new.user@example.com')); const invitedSessionResponse = await app.inject({ method: 'GET', url: '/api/recipes', headers: { cookie: authCookie(app, 'new.user') }, }); assert.equal(invitedSessionResponse.statusCode, 200); const memberResponse = await app.inject({ method: 'POST', url: '/api/allowed-emails', headers: { cookie: authCookie(app, 'google-user-2') }, payload: { email: 'other@example.com' }, }); assert.equal(memberResponse.statusCode, 403); const selfDeleteResponse = await app.inject({ method: 'DELETE', url: '/api/allowed-emails/google-user-1%40example.com', headers: { cookie: adminCookie }, }); assert.equal(selfDeleteResponse.statusCode, 400); const deleteResponse = await app.inject({ method: 'DELETE', url: '/api/allowed-emails/new.user%40example.com', headers: { cookie: adminCookie }, }); assert.equal(deleteResponse.statusCode, 204); const revokedSessionResponse = await app.inject({ method: 'GET', url: '/api/recipes', headers: { cookie: authCookie(app, 'new.user') }, }); assert.equal(revokedSessionResponse.statusCode, 401); }); test('인증 cookie가 없으면 API를 거부하고 페이지는 로그인으로 보낸다', async (t) => { const app = await buildApp({ config: createTestConfig(), dependencies: createDependencies(new FakeRecipeRepository()), }); t.after(() => app.close()); const apiResponse = await app.inject({ method: 'GET', url: '/api/recipes' }); assert.equal(apiResponse.statusCode, 401); const pageResponse = await app.inject({ method: 'GET', url: '/' }); assert.equal(pageResponse.statusCode, 302); assert.equal(pageResponse.headers.location, '/login'); const loginResponse = await app.inject({ method: 'GET', url: '/login' }); assert.equal(loginResponse.statusCode, 200); assert.match(loginResponse.body, /Our Recipe Atlas/); assert.match(loginResponse.body, /id="login-alert"/); assert.match(loginResponse.body, /src="\/js\/login.js"/); const cssResponse = await app.inject({ method: 'GET', url: '/css/app.css' }); assert.equal(cssResponse.statusCode, 200); }); test('설정된 절대 만료시간보다 오래된 로그인 세션을 거부한다', async (t) => { const app = await buildApp({ config: createTestConfig({ authSessionTtlHours: 1 }), dependencies: createDependencies(new FakeRecipeRepository()), }); t.after(() => app.close()); const token = app.jwt.sign({ sub: 'google-user-1', email: 'google-user-1@example.com', name: 'google-user-1', picture: null, iat: Math.floor(Date.now() / 1000) - (60 * 60) - 1, }, { expiresIn: '7d' }); const cookie = `${SESSION_COOKIE_NAME}=${token}`; const apiResponse = await app.inject({ method: 'GET', url: '/api/recipes', headers: { cookie }, }); assert.equal(apiResponse.statusCode, 401); const pageResponse = await app.inject({ method: 'GET', url: '/', headers: { cookie }, }); assert.equal(pageResponse.statusCode, 302); assert.equal(pageResponse.headers.location, '/login'); }); test('Google 로그인 시작 경로는 PKCE S256 authorization 요청을 만든다', async (t) => { const app = await buildApp({ config: createTestConfig({ google: { clientId: 'google-client-id', clientSecret: 'google-client-secret', callbackUrl: 'http://localhost:3000/auth/google/callback', allowedEmails: new Set(['allowed@example.com']), }, }), dependencies: createDependencies(new FakeRecipeRepository()), }); t.after(() => app.close()); const response = await app.inject({ method: 'GET', url: '/auth/google' }); assert.equal(response.statusCode, 302); const location = new URL(response.headers.location); assert.equal(location.hostname, 'accounts.google.com'); assert.equal(location.searchParams.get('code_challenge_method'), 'S256'); assert.equal(location.searchParams.get('scope'), 'openid email profile'); const selectAccountResponse = await app.inject({ method: 'GET', url: '/auth/google/select-account', }); const selectAccountLocation = new URL(selectAccountResponse.headers.location); assert.equal(selectAccountLocation.searchParams.get('prompt'), 'select_account'); }); test('허용되지 않은 Google 계정은 안내 UI가 있는 로그인 페이지로 보낸다', async (t) => { const dependencies = createDependencies(new FakeRecipeRepository()); dependencies.googleClient = { async verifyIdToken() { return { getPayload: () => ({ sub: 'not-allowed-user', email: 'not-allowed@example.com', email_verified: true, }), }; }, }; const app = await buildApp({ config: createTestConfig({ google: { clientId: 'google-client-id', clientSecret: 'google-client-secret', callbackUrl: 'http://localhost:3000/auth/google/callback', allowedEmails: new Set(['allowed@example.com']), }, }), dependencies, }); t.after(() => app.close()); app.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow = async () => ({ token: { id_token: 'test-id-token' }, }); const response = await app.inject({ method: 'GET', url: '/auth/google/callback' }); assert.equal(response.statusCode, 302); assert.equal(response.headers.location, '/login?error=account-not-allowed'); }); test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) => { const recipeRepository = new FakeRecipeRepository(); const app = await buildApp({ config: createTestConfig(), dependencies: createDependencies(recipeRepository), }); t.after(() => app.close()); const cookie = authCookie(app); const previewResponse = await app.inject({ method: 'POST', url: '/api/import/preview', headers: { cookie }, payload: { url: source.sourceUrl }, }); assert.equal(previewResponse.statusCode, 200); assert.equal(previewResponse.json().recipe.title, recipeDraft.title); const createResponse = await app.inject({ method: 'POST', url: '/api/recipes', headers: { cookie }, payload: { recipe: recipeDraft, source, imagePreviewUrl: source.thumbnailUrl, }, }); assert.equal(createResponse.statusCode, 201); const created = createResponse.json().recipe; assert.equal(created.source.url, source.sourceUrl); assert.match(created.imagePath, /^recipes\/.+\/cover\.webp$/); const listResponse = await app.inject({ method: 'GET', url: '/api/recipes', headers: { cookie }, }); assert.equal(listResponse.json().recipes.length, 1); const otherOwnerResponse = await app.inject({ method: 'GET', url: `/api/recipes/${created._id}`, headers: { cookie: authCookie(app, 'google-user-2') }, }); assert.equal(otherOwnerResponse.statusCode, 404); const updateResponse = await app.inject({ method: 'PATCH', url: `/api/recipes/${created._id}`, headers: { cookie }, payload: { title: '수정한 김치찌개' }, }); assert.equal(updateResponse.json().recipe.title, '수정한 김치찌개'); const duplicateResponse = await app.inject({ method: 'POST', url: '/api/recipes', headers: { cookie }, payload: { recipe: recipeDraft, source }, }); assert.equal(duplicateResponse.statusCode, 409); const deleteResponse = await app.inject({ method: 'DELETE', url: `/api/recipes/${created._id}`, headers: { cookie }, }); assert.equal(deleteResponse.statusCode, 204); }); test('여러 URL을 대기열에 넣고 비동기로 일괄 처리하며 완료 작업은 숨긴다', async (t) => { const recipeRepository = new FakeRecipeRepository(); const importJobRepository = new FakeImportJobRepository(); const dependencies = createDependencies( recipeRepository, new FakeGroupRepository(), importJobRepository, ); const app = await buildApp({ config: createTestConfig(), dependencies }); t.after(() => app.close()); const cookie = authCookie(app); const enqueueResponse = await app.inject({ method: 'POST', url: '/api/import/jobs', headers: { cookie }, payload: { urls: [source.sourceUrl, source.sourceUrl] }, }); assert.equal(enqueueResponse.statusCode, 201); assert.equal(enqueueResponse.json().addedCount, 1); assert.equal(enqueueResponse.json().duplicateCount, 1); assert.equal(enqueueResponse.json().jobs[0].status, 'pending'); const processResponse = await app.inject({ method: 'POST', url: '/api/import/jobs/process', headers: { cookie }, }); assert.equal(processResponse.statusCode, 202); await app.importJobProcessor.waitForIdle('google-user-1'); const queueResponse = await app.inject({ method: 'GET', url: '/api/import/jobs', headers: { cookie }, }); assert.deepEqual(queueResponse.json().jobs, []); const recipesResponse = await app.inject({ method: 'GET', url: '/api/recipes', headers: { cookie }, }); assert.equal(recipesResponse.json().recipes.length, 1); const duplicateResponse = await app.inject({ method: 'POST', url: '/api/import/jobs', headers: { cookie }, payload: { urls: [source.sourceUrl] }, }); assert.equal(duplicateResponse.json().addedCount, 0); assert.equal(duplicateResponse.json().duplicateCount, 1); }); test('가져오기 실패 작업은 대기열에 남고 사용자가 재시도할 수 있다', async (t) => { const dependencies = createDependencies(new FakeRecipeRepository()); dependencies.sourceExtractor = { async extract() { throw new Error('원문 추출 실패'); }, }; const app = await buildApp({ config: createTestConfig(), dependencies }); t.after(() => app.close()); const cookie = authCookie(app); await app.inject({ method: 'POST', url: '/api/import/jobs', headers: { cookie }, payload: { urls: [source.sourceUrl] }, }); await app.inject({ method: 'POST', url: '/api/import/jobs/process', headers: { cookie }, }); await app.importJobProcessor.waitForIdle('google-user-1'); const failedResponse = await app.inject({ method: 'GET', url: '/api/import/jobs', headers: { cookie }, }); const [failedJob] = failedResponse.json().jobs; assert.equal(failedJob.status, 'failed'); assert.equal(failedJob.error.message, '가져오기 작업을 처리하지 못했습니다.'); const retryResponse = await app.inject({ method: 'POST', url: `/api/import/jobs/${failedJob.id}/retry`, headers: { cookie }, }); assert.equal(retryResponse.statusCode, 200); assert.equal(retryResponse.json().job.status, 'pending'); }); test('같은 그룹 사용자는 레시피를 함께 조회하지만 작성자만 수정하고 삭제한다', async (t) => { const recipeRepository = new FakeRecipeRepository(); const groupRepository = new FakeGroupRepository([ { memberGoogleSubs: ['google-user-1', 'google-user-2'] }, ]); const app = await buildApp({ config: createTestConfig(), dependencies: createDependencies(recipeRepository, groupRepository), }); t.after(() => app.close()); const createResponse = await app.inject({ method: 'POST', url: '/api/recipes', headers: { cookie: authCookie(app, 'google-user-1') }, payload: { recipe: recipeDraft, source }, }); assert.equal(createResponse.statusCode, 201); const created = createResponse.json().recipe; const sharedListResponse = await app.inject({ method: 'GET', url: '/api/recipes', headers: { cookie: authCookie(app, 'google-user-2') }, }); assert.deepEqual( sharedListResponse.json().recipes.map((recipe) => recipe._id), [created._id], ); const sharedDetailResponse = await app.inject({ method: 'GET', url: `/api/recipes/${created._id}`, headers: { cookie: authCookie(app, 'google-user-2') }, }); assert.equal(sharedDetailResponse.statusCode, 200); const updateResponse = await app.inject({ method: 'PATCH', url: `/api/recipes/${created._id}`, headers: { cookie: authCookie(app, 'google-user-2') }, payload: { title: '다른 사용자의 수정' }, }); assert.equal(updateResponse.statusCode, 404); const deleteResponse = await app.inject({ method: 'DELETE', url: `/api/recipes/${created._id}`, headers: { cookie: authCookie(app, 'google-user-2') }, }); assert.equal(deleteResponse.statusCode, 404); });