fix(backend): 리뷰 반영 — 코드 남용 막기·인증 기본 닫힘·한도 동시성·파일 정리

브랜치 리뷰(main..14e2bdd)에서 나온 서버 쪽 문제를 고친다.
- 일정 연동 코드는 한 번 연동에 쓰이면(코드 주인), 나가면(나간 사람) 바꾼다 — 예전에 받은 코드로 다시 들어올 수 없다
- 코드 조회·참여 7개 엔드포인트가 회원 단위 한 바구니(분당 30회)를 같이 쓴다 (@InviteCodeLookup, 인증 뒤에 세는 가드)
- 펫 명함 상세히 공유는 마지막으로 공유한 뒤 7일만 — 그 뒤에는 간략히로 보여 주고 저장한다
- 개발용 로그인·개발용 서명 키는 NODE_ENV 가 development·test 일 때만 (그 밖은 JWT_SECRET 필수, 기동 차단)
- 다이어리 6권·명함 10장·받은 명함 한도를 회원별 advisory lock 으로 세어 동시 요청에 뚫리지 않게 한다
- 받은 명함: 같은 명함 중복(CARD_004)·보관함 100장(CARD_005), 사본 복사 실패 시 만든 사본 정리
- 탈퇴하면 내 다이어리의 다른 멤버 사진도 정리하고, 24시간 넘게 쓰이지 않는 파일을 매시간 지운다
- 대상 id 소문자 저장, GET /calendar-sharing 은 쓰지 않음(코드는 가입 때 만들고 기존 회원은 채움), 파일·작성자 FK 인덱스

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:21:04 +09:00
parent 043e14a3fa
commit 02c7ec0079
44 changed files with 1422 additions and 282 deletions
+49 -23
View File
@@ -55,7 +55,6 @@ describe('일정 연동 (e2e)', () => {
let app: INestApplication<App>;
const tokens: Record<string, string> = {};
const ids: Record<string, string> = {};
const codes: Record<string, string> = {};
const people = ['a', 'b', 'c', 'd', 'e'] as const;
type Person = (typeof people)[number];
@@ -95,6 +94,20 @@ describe('일정 연동 (e2e)', () => {
.expect(200),
).items;
const memberIds = (body: SharingBody) => body.members.map((m) => m.userId);
/** 지금의 초대 코드 — 연동·해제에 쓰이면 바뀌므로 매번 읽는다 */
const codeOf = async (who: Person): Promise<string> =>
(await sharing(who)).inviteCode;
/** 쓴 코드는 한 번뿐 — 예전 코드로는 찾을 수 없고(SCHED_001) 새 코드가 생긴다 */
const expectRotated = async (who: Person, previous: string) => {
const stale = await http()
.get(`/api/calendar-invitations/${previous}`)
.set(as(who))
.expect(404);
expect(errorCode(stale)).toBe('SCHED_001');
const current = await codeOf(who);
expect(current).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(current).not.toBe(previous);
};
beforeAll(async () => {
app = await createE2eApp();
@@ -116,7 +129,7 @@ describe('일정 연동 (e2e)', () => {
await app.close();
});
it('초대 코드는 처음 열 때 만들어지고 그대로 유지된다', async () => {
it('초대 코드는 가입할 때 생기고, 읽기만 해서는 바뀌지 않는다', async () => {
const first = await sharing('a');
const again = await sharing('a');
@@ -130,7 +143,6 @@ describe('일정 연동 (e2e)', () => {
{ userId: ids.a, nickname: 'a', photoFileId: null, isMe: true },
],
});
for (const who of people) codes[who] = (await sharing(who)).inviteCode;
});
it('A 가 자기 아이로 일정·반복 일정을 만든다', async () => {
@@ -198,9 +210,10 @@ describe('일정 연동 (e2e)', () => {
ruleId = data<RuleBody>(rule).id;
});
it('B 는 코드를 미리 보고 연동한다 (대소문자 무관)', async () => {
it('B 는 코드를 미리 보고 연동한다 (대소문자 무관) — 쓴 코드는 바뀐다', async () => {
const aCode = await codeOf('a');
const preview = await http()
.get(`/api/calendar-invitations/${codes.a.toLowerCase()}`)
.get(`/api/calendar-invitations/${aCode.toLowerCase()}`)
.set(as('b'))
.expect(200);
expect(data(preview)).toEqual({
@@ -211,13 +224,14 @@ describe('일정 연동 (e2e)', () => {
isMember: false,
});
const joined = await join('b', codes.a).expect(201);
const joined = await join('b', aCode).expect(201);
expect(data<SharingBody>(joined).linked).toBe(true);
expect(memberIds(data<SharingBody>(joined))).toEqual([ids.b, ids.a]);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b]);
await expectRotated('a', aCode);
const after = await http()
.get(`/api/calendar-invitations/${codes.a}`)
.get(`/api/calendar-invitations/${await codeOf('a')}`)
.set(as('b'))
.expect(200);
expect(data(after)).toMatchObject({ memberCount: 2, isMember: true });
@@ -310,28 +324,31 @@ describe('일정 연동 (e2e)', () => {
});
it('C 도 A 의 코드로 들어와 셋이 된다', async () => {
const joined = await join('c', codes.a).expect(201);
const joined = await join('c', await codeOf('a')).expect(201);
expect(memberIds(data<SharingBody>(joined))).toEqual([ids.c, ids.a, ids.b]);
});
it('이미 연동된 사람(SCHED_005)·내 코드(SCHED_003)는 거절한다', async () => {
for (const code of [codes.a, codes.c]) {
const again = await join('b', code).expect(409);
for (const owner of ['a', 'c'] as const) {
const again = await join('b', await codeOf(owner)).expect(409);
expect(errorCode(again)).toBe('SCHED_005');
}
const own = await join('a', codes.a).expect(400);
const own = await join('a', await codeOf('a')).expect(400);
expect(errorCode(own)).toBe('SCHED_003');
});
it('4명이 차면 SCHED_002, 다른 사람과 연동 중이면 SCHED_004', async () => {
await join('d', codes.a).expect(201);
const full = await join('e', codes.b).expect(409);
await join('d', await codeOf('a')).expect(201);
const full = await join('e', await codeOf('b')).expect(409);
expect(errorCode(full)).toBe('SCHED_002');
const busy = await join('b', codes.e).expect(409);
const busy = await join('b', await codeOf('e')).expect(409);
expect(errorCode(busy)).toBe('SCHED_004');
// 해제하면 나간 사람의 코드가 바뀐다
const dCode = await codeOf('d');
await leave('d');
await expectRotated('d', dCode);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b, ids.c]);
});
@@ -354,7 +371,9 @@ describe('일정 연동 (e2e)', () => {
const [bEvent] = data<EventBody[]>(mine);
expect((await eventsOf('a')).map((e) => e.id)).toContain(bEvent.id);
const bCode = await codeOf('b');
await leave('b');
await expectRotated('b', bCode);
const bSees = (await eventsOf('b')).map((e) => e.id);
expect(bSees).toContain(bEvent.id);
@@ -385,24 +404,31 @@ describe('일정 연동 (e2e)', () => {
>('SELECT count(*) FROM calendar_group_members WHERE user_id = ANY($1)', [[ids.a, ids.c]]);
expect(rows[0].count).toBe('0');
// 연동 중이 아니어도 해제는 204
// 연동 중이 아니어도 해제는 204 — 빠진 게 없으니 코드도 그대로
const cCode = await codeOf('c');
await leave('c');
expect(await codeOf('c')).toBe(cCode);
});
it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보고 정리한다', async () => {
await join('e', codes.d).expect(201);
it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보인다 — 정리는 해제할 때 (GET 은 쓰지 않는다)', async () => {
await join('e', await codeOf('d')).expect(201);
await http().delete('/api/users/me').set(as('d')).expect(204);
const countRows = async () =>
(
await app
.get(DataSource)
.query<
{ count: string }[]
>('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e])
)[0].count;
const e = await sharing('e');
expect(e.linked).toBe(false);
expect(memberIds(e)).toEqual([ids.e]);
expect(await countRows()).toBe('1');
const rows = await app
.get(DataSource)
.query<
{ count: string }[]
>('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e]);
expect(rows[0].count).toBe('0');
await leave('e');
expect(await countRows()).toBe('0');
});
it('없는 코드나 형식이 틀린 코드는 SCHED_001', async () => {
+2 -1
View File
@@ -12,5 +12,6 @@
"testRegex": "test/.*\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"testTimeout": 30000
}
+160
View File
@@ -0,0 +1,160 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { FileSweeper } from '@/modules/upload/file-sweeper';
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
/**
* 남용·찌꺼기 막기 — 코드 조회 한도(회원별 한 통), 탈퇴할 때 남의 사진 정리, 쓰이지 않는 파일 정리.
* 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다.
*/
/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
/** 코드 조회 한도 (common/decorators/invite-code-lookup.decorator.ts) */
const INVITE_LIMIT = 30;
const data = <T>(res: Response): T => (res.body as { data: T }).data;
const errorCode = (res: Response): string | undefined =>
(res.body as { error?: { code?: string } }).error?.code;
describe('한도·정리 (e2e)', () => {
let app: INestApplication<App>;
const tokens: Record<string, string> = {};
const people = ['guesser', 'bystander', 'owner', 'member', 'uploader'];
const as = (who: string) => ({ Authorization: `Bearer ${tokens[who]}` });
const http = () => request(app.getHttpServer());
const uploadPng = async (who: string): Promise<string> => {
const res = await http()
.post('/api/files')
.set(as(who))
.attach('file', TINY_PNG, {
filename: 'photo.png',
contentType: 'image/png',
})
.expect(201);
return data<{ id: string }>(res).id;
};
beforeAll(async () => {
app = await createE2eApp();
for (const who of people) {
tokens[who] = await devLogin(app, uniqueDeviceKey(`lim-${who}`));
}
});
afterAll(async () => {
for (const who of people) {
await http().delete('/api/users/me').set(as(who));
}
await app.close();
});
it('코드로 찾는 엔드포인트는 회원별로 한 통에서 센다 — 넘으면 어느 엔드포인트든 REQ_001', async () => {
// 형식이 틀린 코드라 DB 의 실제 코드와 겹치지 않는다 — 실패해도 한도에는 센다
const lookups = [
() => http().get('/api/diary-invitations/RUP-000000'),
() => http().get('/api/calendar-invitations/RUP-000000'),
() => http().get('/api/shared-pet-cards/not-a-code'),
() => http().get('/api/shared-pet-cards/not-a-code/images/pet'),
() => http().post('/api/diary-memberships').send({ code: 'nope' }),
() => http().post('/api/calendar-memberships').send({ code: 'nope' }),
];
for (let i = 0; i < INVITE_LIMIT; i += 1) {
const res = await lookups[i % lookups.length]().set(as('guesser'));
expect(res.status).not.toBe(429);
}
for (const lookup of [
() => http().post('/api/received-cards').send({ code: '0000-000' }),
() => http().get('/api/calendar-invitations/RUP-000000'),
]) {
const blocked = await lookup().set(as('guesser')).expect(429);
expect(errorCode(blocked)).toBe('REQ_001');
}
// 다른 회원은 자기 통을 쓴다 (같은 IP 라도)
const other = await http()
.get('/api/diary-invitations/RUP-000000')
.set(as('bystander'))
.expect(404);
expect(errorCode(other)).toBe('DIARY_003');
});
it('다이어리 주인이 탈퇴하면 함께하는 사람이 그 다이어리에 올린 사진도 정리된다', async () => {
const created = await http()
.post('/api/diaries')
.set(as('owner'))
.send({
name: '탈퇴 정리',
targetIds: ['self'],
ribbon: '#FF7500',
tint: '#FF8A5B',
shared: true,
})
.expect(201);
const diary = data<{ id: string; inviteCode: string }>(created);
await http()
.post('/api/diary-memberships')
.set(as('member'))
.send({ code: diary.inviteCode })
.expect(201);
const postPhotoId = await uploadPng('member');
const profilePhotoId = await uploadPng('member');
await http()
.patch('/api/users/me')
.set(as('member'))
.send({ photoFileId: profilePhotoId })
.expect(200);
await http()
.post(`/api/diaries/${diary.id}/posts`)
.set(as('member'))
.send({ date: '2026-10-11', text: '사진', photoFileId: postPhotoId })
.expect(201);
await http().delete('/api/users/me').set(as('owner')).expect(204);
const gone = await http()
.get(`/api/files/${postPhotoId}`)
.set(as('member'))
.expect(404);
expect(errorCode(gone)).toBe('RES_001');
// 다이어리와 상관없는 내 사진은 그대로
await http()
.get(`/api/files/${profilePhotoId}`)
.set(as('member'))
.expect(200);
});
it('하루가 지나도 아무도 쓰지 않는 파일은 정리하고, 쓰는 파일은 남긴다', async () => {
const orphanId = await uploadPng('uploader');
const usedId = await uploadPng('uploader');
await http()
.patch('/api/users/me')
.set(as('uploader'))
.send({ photoFileId: usedId })
.expect(200);
// 올린 지 이틀이 지난 것으로 만든다
await app
.get(DataSource)
.query(
`UPDATE files SET created_at = now() - interval '2 days' WHERE id = ANY($1)`,
[[orphanId, usedId]],
);
const released = await app.get(FileSweeper).sweep();
expect(released).toBeGreaterThanOrEqual(1);
await http().get(`/api/files/${orphanId}`).set(as('uploader')).expect(404);
await http().get(`/api/files/${usedId}`).set(as('uploader')).expect(200);
});
});