generated from ttipo/project-base
test: 부하에 흔들리던 시간 검사를 계산량·조건 대기로 고정
다른 작업과 함께 돌면 혼자 0.8초인 정화가 9초로 재지는 등, 벽시계 한도와 고정 대기를 쓰던 테스트가 가끔 실패했다. - 백엔드 성능 단언은 벽시계 대신 이 프로세스의 CPU 시간으로 잰다 (backend/test/cpu-time.ts 의 startCpuTimer). 부하 중에도 1.4배쯤만 늘고, 예전 제곱 시간 경로(15~172초)는 그대로 한도를 넘는다 - 임시 업로드 삭제 테스트는 고정 20ms 대신 파일이 지워질 때까지 기다린다 (최대 2초, 지워지지 않으면 실패) - 프론트 테스트 시간 한도를 20초로 (jsdom·편집기 테스트가 부하에서 5~13초), 긴 초안을 맞추는 테스트는 30초 (src/test-utils/timeouts.ts) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,25 @@ function contextWith(file?: { path: string }): ExecutionContext {
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
/** finalize 의 비동기 삭제가 끝날 때까지 잠깐 기다린다 */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
/** 임시 파일이 지워지기를 기다리는 최대 시간 — 바쁜 CI 에서도 넉넉하게 */
|
||||
const REMOVAL_DEADLINE_MS = 2_000;
|
||||
/** 지워졌는지 다시 보는 간격 */
|
||||
const REMOVAL_POLL_MS = 10;
|
||||
|
||||
/**
|
||||
* 파일이 지워질 때까지 기다린다 — 기한 안에 지워졌으면 true, 기한이 지나도 남아 있으면 false.
|
||||
* 인터셉터는 finalize 안에서 삭제를 시작만 하고 기다리지 않는다(`void this.remove(...)`) — 응답이 끝난 뒤에도 rm 이 진행 중일 수 있다.
|
||||
* 예전에는 고정으로 20ms 만 기다렸는데, 부하가 큰 CI 에서 rm 이 그보다 늦어 가끔 실패했다 (2026-10-27).
|
||||
* 그래서 지워질 때까지 자주 확인하고, 기한 안에 지워졌는지를 검사한다 — 삭제가 빠지면 기한 뒤에 그대로 실패한다.
|
||||
*/
|
||||
async function waitUntilRemoved(path: string): Promise<boolean> {
|
||||
const deadline = Date.now() + REMOVAL_DEADLINE_MS;
|
||||
while (existsSync(path)) {
|
||||
if (Date.now() >= deadline) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, REMOVAL_POLL_MS));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('DeleteUploadedFileInterceptor', () => {
|
||||
let dir: string;
|
||||
@@ -35,9 +52,8 @@ describe('DeleteUploadedFileInterceptor', () => {
|
||||
await expect(
|
||||
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
|
||||
).resolves.toEqual({ id: 'blob' });
|
||||
await settle();
|
||||
|
||||
expect(existsSync(file)).toBe(false);
|
||||
await expect(waitUntilRemoved(file)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('핸들러가 실패해도 임시 파일을 지운다', async () => {
|
||||
@@ -48,9 +64,8 @@ describe('DeleteUploadedFileInterceptor', () => {
|
||||
await expect(
|
||||
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
|
||||
).rejects.toThrow('engine down');
|
||||
await settle();
|
||||
|
||||
expect(existsSync(file)).toBe(false);
|
||||
await expect(waitUntilRemoved(file)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('파일이 없는 요청은 그냥 통과한다', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Parser } from 'htmlparser2';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import {
|
||||
MAX_HTML_PARSE_COST,
|
||||
MAX_HTML_PARSE_LENGTH,
|
||||
@@ -9,8 +10,11 @@ import {
|
||||
import { sanitizeMailHtml } from './sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
|
||||
|
||||
/** 시간 단언은 넉넉하게 — 이 PC 에서 검사는 2MB 에 0.1초 안쪽이다 (느린 CI 도 견디게) */
|
||||
const GENEROUS_MS = 2_000;
|
||||
/**
|
||||
* CPU 시간 한도 (벽시계가 아니다 — test/cpu-time.ts). 지금 검사는 2MB 를 끝까지 읽어도 CPU 0.3초 안쪽이고 공격 모양은 0.15초 안쪽에 멈춘다.
|
||||
* 막으려는 제곱 경로는 같은 공격 입력을 정화하는 것 — '<b>' 2MB 에 172초다.
|
||||
*/
|
||||
const GENEROUS_CPU_MS = 2_000;
|
||||
|
||||
/** 보내는 서식 본문 한도 (계약 2절 — DTO 가 막는다) */
|
||||
const OUTGOING_MAX_CHARS = 500_000;
|
||||
@@ -19,10 +23,11 @@ const OUTGOING_MAX_CHARS = 500_000;
|
||||
const fillTo = (unit: string, chars = MAX_HTML_PARSE_LENGTH) =>
|
||||
unit.repeat(Math.floor(chars / unit.length));
|
||||
|
||||
function elapsedMs<T>(run: () => T): [number, T] {
|
||||
const started = performance.now();
|
||||
/** run 이 쓴 CPU 시간(ms)과 그 결과 */
|
||||
function cpuTimed<T>(run: () => T): [number, T] {
|
||||
const cpuMs = startCpuTimer();
|
||||
const result = run();
|
||||
return [performance.now() - started, result];
|
||||
return [cpuMs(), result];
|
||||
}
|
||||
|
||||
/** htmlparser2 Parser 의 빈 요소 (12.0.0 src/Parser.ts) — 스택에 올라가지 않으므로 비교에서 뺀다 */
|
||||
@@ -227,10 +232,11 @@ describe('checkHtmlParseBudget (정화 전 사전 검사)', () => {
|
||||
const html = make(MAX_HTML_PARSE_LENGTH);
|
||||
expect(html.length).toBeLessThanOrEqual(MAX_HTML_PARSE_LENGTH);
|
||||
|
||||
const [ms, result] = elapsedMs(() => checkHtmlParseBudget(html));
|
||||
const [cpuMs, result] = cpuTimed(() => checkHtmlParseBudget(html));
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'too-complex' });
|
||||
expect(ms).toBeLessThan(GENEROUS_MS);
|
||||
// 지금 CPU 0.15초 안쪽에 멈춘다 — 정화까지 갔다면 '<b>' 2MB 에 172초
|
||||
expect(cpuMs).toBeLessThan(GENEROUS_CPU_MS);
|
||||
});
|
||||
|
||||
it.each(ATTACKS)('보내는 서식 본문 500,000자 — %s', (_name, make) => {
|
||||
@@ -265,10 +271,11 @@ describe('checkHtmlParseBudget (정화 전 사전 검사)', () => {
|
||||
const html = make();
|
||||
expect(html.length).toBeLessThanOrEqual(MAX_HTML_PARSE_LENGTH);
|
||||
|
||||
const [ms, result] = elapsedMs(() => checkHtmlParseBudget(html));
|
||||
const [cpuMs, result] = cpuTimed(() => checkHtmlParseBudget(html));
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(ms).toBeLessThan(GENEROUS_MS);
|
||||
// 끝까지 읽어도 지금 CPU 0.3초 안쪽 (검사는 사건마다 O(1) — 스택을 훑지 않는다)
|
||||
expect(cpuMs).toBeLessThan(GENEROUS_CPU_MS);
|
||||
});
|
||||
|
||||
it('300단 인용 스레드도 상한에 3배 넘게 여유가 있다', () => {
|
||||
@@ -294,12 +301,12 @@ describe('checkHtmlParseBudget (정화 전 사전 검사)', () => {
|
||||
const html = '<b>'.repeat(1_000) + '</i>'.repeat(closes);
|
||||
expect(checkHtmlParseBudget(html)).toEqual({ ok: true });
|
||||
|
||||
const [receivedMs] = elapsedMs(() => sanitizeMailHtml(html));
|
||||
const [outgoingMs] = elapsedMs(() => sanitizeOutgoingHtml(html));
|
||||
const [receivedCpuMs] = cpuTimed(() => sanitizeMailHtml(html));
|
||||
const [outgoingCpuMs] = cpuTimed(() => sanitizeOutgoingHtml(html));
|
||||
|
||||
// 이 PC 에서 0.1~0.2초 — 상한이 없으면 같은 모양 2MB 가 0.6초, 깊이 3000 이면 1.7초다
|
||||
expect(receivedMs).toBeLessThan(GENEROUS_MS);
|
||||
expect(outgoingMs).toBeLessThan(GENEROUS_MS);
|
||||
// 지금 CPU 0.2~0.4초 — 상한이 없으면 같은 모양 2MB 가 0.6초, 깊이 3000 이면 1.7초다 (벽시계로 잰 값)
|
||||
expect(receivedCpuMs).toBeLessThan(GENEROUS_CPU_MS);
|
||||
expect(outgoingCpuMs).toBeLessThan(GENEROUS_CPU_MS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,12 +332,12 @@ describe('measureHtmlParseCost', () => {
|
||||
const html = '<b>'.repeat(600_000);
|
||||
const limit = 1_000_000;
|
||||
|
||||
const [ms, cost] = elapsedMs(() => measureHtmlParseCost(html, { limit }));
|
||||
const [cpuMs, cost] = cpuTimed(() => measureHtmlParseCost(html, { limit }));
|
||||
|
||||
// 깊이 1414 쯤에서 넘는다 (끝까지 세면 1.8×10¹¹)
|
||||
// 깊이 1414 쯤에서 넘는다 (끝까지 세면 1.8×10¹¹) — 멈췄는지는 비용 단언이 본다. 지금 CPU 수 ms
|
||||
expect(cost).toBeGreaterThan(limit);
|
||||
expect(cost).toBeLessThan(limit + 2_000);
|
||||
expect(ms).toBeLessThan(GENEROUS_MS);
|
||||
expect(cpuMs).toBeLessThan(GENEROUS_CPU_MS);
|
||||
});
|
||||
|
||||
describe('htmlparser2 Parser 와 같은 순서로 열고 닫는다 — 판을 올려 규칙이 바뀌면 여기서 걸린다', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { sanitizeMailHtml } from './sanitize-mail-html';
|
||||
|
||||
const clean = (html: string, inline?: ReadonlyMap<string, string>) =>
|
||||
@@ -99,14 +100,15 @@ describe('sanitizeMailHtml', () => {
|
||||
});
|
||||
|
||||
it('url( 뒤 공백이 아주 길어도 한 번만 훑는다 (예전: 공백 4만 개에 0.6초 — 공백 수의 제곱)', () => {
|
||||
const started = performance.now();
|
||||
const cpuMs = startCpuTimer();
|
||||
|
||||
const result = sanitizeMailHtml(
|
||||
`<b style="background:url(${' '.repeat(1_900_000)}x)">y</b>`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ html: '<b>y</b>', hasBlockedImages: false });
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
// CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²)
|
||||
expect(cpuMs()).toBeLessThan(2_000);
|
||||
// 따옴표 앞뒤의 공백도 그대로 외부 주소로 알아본다
|
||||
expect(
|
||||
sanitizeMailHtml(
|
||||
@@ -224,13 +226,14 @@ describe('sanitizeMailHtml', () => {
|
||||
|
||||
it('주소가 남지 않은 이미지가 아주 많아도 입력 길이에 비례해 끝난다 (예전: 27만 개에 15초)', () => {
|
||||
const count = 333_333; // 2MB
|
||||
const started = performance.now();
|
||||
const cpuMs = startCpuTimer();
|
||||
|
||||
const result = sanitizeMailHtml('x<img>'.repeat(count));
|
||||
|
||||
expect(result.html).toBe('x'.repeat(count));
|
||||
// 이 PC 에서 0.5초 안쪽 — 넉넉하게
|
||||
expect(performance.now() - started).toBeLessThan(5_000);
|
||||
// CPU 시간 (test/cpu-time.ts) — 지금 1.1~1.6초(부하 중에도). 예전 경로(결과를 이미지마다 다시 자름)는 27만 개(1.6MB)에 15초, 이 크기면 20초가 넘는다.
|
||||
// 벽시계로 재던 때는 바쁜 PC 에서 5.9초·9.1초로 재져 실패했다 (2026-10-27)
|
||||
expect(cpuMs()).toBeLessThan(5_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user