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 = (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; const tokens: Record = {}; 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 => { 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); }); });