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, FakeRecipeRepository, fakeUserRepository, recipeDraft, source, } from './helpers/fakes.js'; function createDependencies(recipeRepository) { return { allowedEmailRepository: new FakeAllowedEmailRepository(), recipeRepository, userRepository: fakeUserRepository, sourceExtractor: { extract: async () => source }, recipeParser: { parse: async () => 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="access-menu-button"/); assert.match(pageResponse.body, /id="access-drawer"/); assert.match(pageResponse.body, /id="drawer-logout"/); assert.match(pageResponse.body, /id="access-overlay"/); assert.match(pageResponse.body, /id="scroll-to-top"/); const memberPageResponse = await app.inject({ method: 'GET', url: '/', headers: { cookie: authCookie(app, 'google-user-2') }, }); assert.match(memberPageResponse.body, /aria-label="메뉴 열기"/); 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/); 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'); }); 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); });