generated from ttipo/project-base
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:
@@ -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 () => {
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"testRegex": "test/.*\\.e2e-spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
},
|
||||
"testTimeout": 30000
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user