504 lines
16 KiB
JavaScript
504 lines
16 KiB
JavaScript
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);
|
|
});
|