feat(backend): 업데이트 내역 읽기 API 와 첫 글을 더한다

직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판의 서버 몫이다. 글은 운영자가 backend/release-notes/ 에 글마다 파일 하나로 쓰고
(올리는 API · DB 표 · 새 에러 코드 없음), GET /api/release-notes(커서 목록)와 GET /api/release-notes/{id}(글 + 바로 옛 글 · 새 글)로 웹 · 앱에 똑같이 준다.

- 글 폴더는 src 밖이라 개발 컨테이너(바인드 마운트)는 다시 띄우지 않아도 새 글이 보이고, 운영 이미지는 Dockerfile 이 폴더를 함께 복사한다
- 틀린 글은 실행 때 그 글만 건너뛰고 error 로그(파일 이름 · 줄 · 까닭만 — 기동 때 한 번 읽어 배포 직후 보인다), 실제 폴더를 엄격하게 읽는 시험이 CI 에서 운영자의 오타를 잡는다
- 파일은 O_NOFOLLOW 로 한 번 열어 일반 파일 · 256KB 까지만 읽고, 모양이 틀린 id 도 없는 글과 같은 404 RES_001 이다
- 첫 글(2026-11-18.md)은 다음 배포 소식이다 — 파일 이름(날짜)은 배포 날로 고친다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 19:36:59 +09:00
parent abe4c1364c
commit cd958f3bf4
19 changed files with 2679 additions and 1 deletions
+281 -1
View File
@@ -3,7 +3,14 @@ import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, createHmac, randomBytes, randomUUID } from 'crypto';
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs';
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { connect } from 'net';
import { tmpdir } from 'os';
@@ -179,6 +186,7 @@ import {
type PushSendOutcome,
type PushSender,
} from '@/modules/push/push-sender.port';
import { ReleaseNotesFolder } from '@/modules/release-notes/release-notes-folder';
import {
SystemSettingsService,
type SystemSettingsPatch,
@@ -2114,6 +2122,9 @@ describe('응답 계약 (e2e)', () => {
// 휴대폰 앱 받기 — APK 파일은 이 시험의 임시 폴더에 (개발 업로드 볼륨에 남기지 않는다)
.overrideProvider(AppReleaseStorage)
.useValue(new AppReleaseStorage(join(fileDir, 'app-releases')))
// 업데이트 내역 — 글은 이 시험의 임시 폴더에서 읽는다 (실제 backend/release-notes 의 글이 바뀌어도 시험은 그대로)
.overrideProvider(ReleaseNotesFolder)
.useValue(new ReleaseNotesFolder(join(fileDir, 'release-notes')))
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -11354,6 +11365,275 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 업데이트 내역 (계약 7절) — 글은 이 시험의 임시 폴더(위 overrideProvider 의 ReleaseNotesFolder)에 쓴다.
* 해석 규칙(형식 · 한도 · 이어 붙임 · 메모 · 날짜)은 release-note-format.spec.ts, 캐시 · 로그는 release-notes-folder.spec.ts,
* 실제 backend/release-notes 의 글은 release-notes.content.spec.ts 가 본다 — 여기서는 HTTP 형식 · 상태 코드를 본다
*/
describe('업데이트 내역 (/api/release-notes · /api/release-notes/{id})', () => {
const ME = 'release.notes@e2e.arasystem.test';
const LIST = '/api/release-notes';
let notesDir = '';
interface NoteSummary {
id: string;
date: string;
title: string;
}
interface NotePage {
items: NoteSummary[];
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
}
interface NoteDetail extends NoteSummary {
sections: { heading: string | null; items: string[] }[];
previous: NoteSummary | null;
next: NoteSummary | null;
}
const as = (test: HttpTest) => test.set('Cookie', cookieOf(ME));
const writeNote = (name: string, text: string) =>
writeFileSync(join(notesDir, name), text);
const TOP: NoteSummary = {
id: '2026-11-18-2',
date: '2026-11-18',
title: '같은 날 두 번째 글',
};
const RELEASE: NoteSummary = {
id: '2026-11-18',
date: '2026-11-18',
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
};
const COPY: NoteSummary = {
id: '2026-11-05',
date: '2026-11-05',
title: '화면의 안내 글을 다듬었습니다',
};
const OLDEST: NoteSummary = {
id: '2026-11-03',
date: '2026-11-03',
title: '2단계 인증과 앱 비밀번호를 쓸 수 있습니다',
};
beforeAll(() => {
notesDir = join(fileDir, 'release-notes');
mkdirSync(notesDir, { recursive: true });
writeNote('README.md', '# 쓰는 사람용 안내\n아무 글이나\n');
writeNote('2026-11-03.md', `# ${OLDEST.title}\n- 한 줄\n`);
writeNote(
'2026-11-18.md',
[
`# ${RELEASE.title}`,
'<!-- 메모는 화면에 나오지 않는다 -->',
'',
'## 휴대폰 앱',
'- 안드로이드 휴대폰 앱이 나왔습니다.',
'- 새 메일이 오면 휴대폰으로 알려 드립니다.',
' 알림에는 새 메일이 몇 통인지만 나옵니다.',
'',
'## 메일 검색',
'- 찾은 말에는 노란 표시가 붙습니다.',
'',
].join('\n'),
);
writeNote('2026-11-18-2.md', `# ${TOP.title}\n- 머리 없는 줄\n`);
writeNote('2026-11-05.md', `# ${COPY.title}\n- 한 줄\n`);
// 틀린 글 · 글이 아닌 파일 — 서버는 건너뛴다(error 로그만 — 기동 · 다른 글은 그대로)
writeNote('2026-11-10.md', '# 줄이 하나도 없는 글\n');
writeNote('notes.txt', '글 파일이 아니다');
});
it('로그인하지 않으면 401 AUTH_001 (목록 · 글)', async () => {
for (const path of [LIST, `${LIST}/2026-11-18`]) {
const res = await request(server).get(path).expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('목록 200 { items: [{ id, date, title }], pageInfo } — 최근 글부터(같은 날은 뒤 번호가 먼저), total 은 글 수, 틀린 글 · 글이 아닌 파일은 없다 · private, no-store', async () => {
const res = await as(request(server).get(LIST)).expect(200);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(body(res).success).toBe(true);
expect(dataOf<NotePage>(res)).toEqual({
items: [TOP, RELEASE, COPY, OLDEST],
pageInfo: { hasNext: false, nextCursor: null, total: 4 },
});
});
it('쪽 나누기 — limit 과 nextCursor 로 끝까지, 마지막 쪽은 hasNext false · nextCursor null', async () => {
const first = dataOf<NotePage>(
await as(request(server).get(LIST).query({ limit: 3 })).expect(200),
);
expect(first.items).toEqual([TOP, RELEASE, COPY]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
expect(typeof first.pageInfo.nextCursor).toBe('string');
const second = await as(
request(server)
.get(LIST)
.query({ limit: 3, cursor: first.pageInfo.nextCursor ?? '' }),
).expect(200);
expect(dataOf<NotePage>(second)).toEqual({
items: [OLDEST],
pageInfo: { hasNext: false, nextCursor: null, total: 4 },
});
});
it('틀린 커서 · limit 범위 밖(1~100) · 모르는 쿼리는 400 VAL_001', async () => {
for (const query of [
{ cursor: 'not-a-cursor' },
{ limit: 0 },
{ limit: 101 },
{ limit: 'many' },
{ q: '검색' },
]) {
const res = await as(request(server).get(LIST).query(query)).expect(
400,
);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('글 200 — 묶음(메모 없음 · 이어 쓴 줄은 띄어쓰기 하나로) · previous(바로 더 옛 글) · next(바로 더 새 글) · private, no-store', async () => {
const res = await as(request(server).get(`${LIST}/2026-11-18`)).expect(
200,
);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(dataOf<NoteDetail>(res)).toEqual({
...RELEASE,
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다.',
'새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나옵니다.',
],
},
{
heading: '메일 검색',
items: ['찾은 말에는 노란 표시가 붙습니다.'],
},
],
previous: COPY,
next: TOP,
});
});
it('맨 위 글은 next null(머리 없는 묶음은 heading null), 맨 아래 글은 previous null', async () => {
const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect(
200,
);
expect(dataOf<NoteDetail>(top)).toEqual({
...TOP,
sections: [{ heading: null, items: ['머리 없는 줄'] }],
previous: RELEASE,
next: null,
});
const oldest = await as(
request(server).get(`${LIST}/${OLDEST.id}`),
).expect(200);
expect(dataOf<NoteDetail>(oldest)).toMatchObject({
previous: null,
next: COPY,
});
});
it('없는 글 · 건너뛴 틀린 글 · 모양이 틀린 id 는 모두 404 RES_001 — id 를 파일 경로로 쓰지 않는다', async () => {
for (const id of [
'2026-11-19',
'2026-11-10',
'2026-11-18-1',
'2026-11-18.md',
'README',
'latest',
'..%2F..%2Fpackage.json',
'x'.repeat(40),
]) {
const res = await as(request(server).get(`${LIST}/${id}`)).expect(404);
expect(body(res).error?.code).toBe('RES_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
});
it('글 파일을 더하면 서버를 다시 띄우지 않아도 다음 요청이 본다', async () => {
const added: NoteSummary = {
id: '2026-11-25',
date: '2026-11-25',
title: '새로 더한 글',
};
writeNote(`${added.id}.md`, `# ${added.title}\n- 한 줄\n`);
try {
const list = await as(
request(server).get(LIST).query({ limit: 1 }),
).expect(200);
expect(dataOf<NotePage>(list).items).toEqual([added]);
expect(dataOf<NotePage>(list).pageInfo).toMatchObject({
hasNext: true,
total: 5,
});
const top = await as(request(server).get(`${LIST}/${TOP.id}`)).expect(
200,
);
expect(dataOf<NoteDetail>(top).next).toEqual(added);
} finally {
unlinkSync(join(notesDir, `${added.id}.md`));
}
const after = await as(request(server).get(LIST)).expect(200);
expect(dataOf<NotePage>(after).pageInfo.total).toBe(4);
});
it('앱의 Bearer 토큰으로도 받는다 (웹 쿠키와 같은 가드)', async () => {
const token = sign(
{
sub: ME,
typ: 'access',
name: '테스트 직원',
cli: 'app',
sid: randomUUID(),
},
600,
);
const res = await request(server)
.get(`${LIST}/2026-11-18`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(dataOf<NoteDetail>(res).id).toBe('2026-11-18');
});
it('제한된 세션(새 비밀번호를 정하기 전)은 403 AUTH_010 — 허용 목록에 넣지 않는다', async () => {
const RESTRICTED = 'release.notes.expired@e2e.arasystem.test';
expiredPasswords.add(RESTRICTED);
try {
const login = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.41.1')
.send({ email: RESTRICTED, password: CORRECT_PASSWORD })
.expect(200);
expect(body(login).data).toMatchObject({
restriction: 'passwordChange',
});
const cookie = `access_token=${cookieValue(setCookie(login, 'access_token'))}`;
for (const path of [LIST, `${LIST}/2026-11-18`]) {
const res = await request(server)
.get(path)
.set('Cookie', cookie)
.expect(403);
expect(body(res).error?.code).toBe('AUTH_010');
}
} finally {
expiredPasswords.delete(RESTRICTED);
}
});
});
/**
* 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절).
* 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다