폼 전송 TDD추가 및 암호화 적용 중
CI / test (push) Failing after 4m41s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-02 11:02:15 +09:00
parent d0766fca45
commit 3e223a45d4
40 changed files with 1290 additions and 31 deletions
+1
View File
@@ -71,6 +71,7 @@ describe("/api/auth", () => {
const setCookie = res.headers.get("set-cookie");
expect(setCookie).toBeTruthy();
expect(setCookie).toContain("pb_access=");
expect(setCookie?.toLowerCase()).toContain("max-age=604800");
});
it("비밀번호가 8자 미만이면 400 과 에러 메시지를 반환해야 한다", async () => {
@@ -99,10 +99,13 @@ describe("정적 Export 폼 컨트롤러 통합", () => {
form!.dispatchEvent(submitEvent);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0] as [any, RequestInit];
const [url, options] = fetchMock.mock.calls[0] as unknown as [any, RequestInit];
expect(url).toBe("/api/forms/submit");
expect(options.method).toBe("POST");
expect(options.body).toBeInstanceOf(FormData);
const body = options.body as FormData;
expect(body.get("__projectSlug")).toBe(projectConfig.slug);
});
});
+215 -1
View File
@@ -1,9 +1,52 @@
import "dotenv/config";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { FormBlockProps } from "@/features/editor/state/editorStore";
const BASE_URL = "http://localhost";
// /api/forms/submit 테스트에서는 실제 DB 대신 메모리 기반 프로젝트/제출 내역 저장소를 사용한다.
// 이렇게 하면 DATABASE_URL 없이도 Prisma 의 의존성을 만족시키면서 라우트 로직을 검증할 수 있다.
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
// 프로젝트 조회는 슬러그 기준으로 수행한다.
project = {
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
};
// 폼 제출 내역은 단순 배열에 push 하여 테스트에서 검증할 수 있도록 한다.
formSubmission = {
create: async ({ data }: any) => {
const now = new Date();
const record = {
id: String(inMemoryFormSubmissions.length + 1),
createdAt: now,
...data,
};
inMemoryFormSubmissions.push(record);
return record;
},
findMany: async () => {
return [...inMemoryFormSubmissions];
},
};
}
return { PrismaClient: PrismaClientMock };
});
beforeEach(() => {
// 각 테스트 전에 메모리 스토리지를 초기화하고, 암호화 키를 고정 값으로 설정한다.
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
});
// /api/forms/submit 라우트에 대한 기본 TDD:
// - internal 모드에서 임의 필드가 포함된 FormData 를 받아도 에러 없이 ok:true 를 반환해야 한다.
// - webhook 모드에서는 config.extraParams / headers 와 함께 x-www-form-urlencoded 로 전달해야 한다.
@@ -340,4 +383,175 @@ describe("/api/forms/submit", () => {
expect(json.error).toBe("webhook_exception");
expect(json.message).toBe(config.errorMessage);
});
describe("internal/both 모드의 DB 저장 및 민감정보 암호화", () => {
it("internal 모드에서 프로젝트 슬러그와 민감 필드를 포함해 제출하면 FormSubmission 이 암호화/분리된 상태로 저장되어야 한다", async () => {
inMemoryProjects.push({
id: "proj-1",
slug: "project-1",
userId: "owner-1",
});
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "성공적으로 전송되었습니다.",
errorMessage: "전송 중 오류가 발생했습니다.",
fieldIds: [],
submitButtonId: null,
fields: [
{ id: "f1", name: "name", type: "text", label: "이름", required: true },
{ id: "f2", name: "email", type: "email", label: "이메일", required: true },
{ id: "f3", name: "phone", type: "text", label: "전화번호", required: false },
{ id: "f4", name: "birth", type: "text", label: "생년월일", required: false },
{ id: "f5", name: "message", type: "textarea", label: "메시지", required: true },
],
};
const formData = new FormData();
formData.append("name", "홍길동");
formData.append("email", "test@example.com");
formData.append("phone", "010-1234-5678");
formData.append("birth", "1990-01-02");
formData.append("message", "문의 내용");
formData.append("__projectSlug", "project-1");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(inMemoryFormSubmissions.length).toBe(1);
const saved = inMemoryFormSubmissions[0] as any;
expect(saved.projectSlug).toBe("project-1");
expect(saved.projectId).toBe("proj-1");
expect(saved.userId).toBe("owner-1");
expect(saved.payloadJson).toBeTruthy();
expect(saved.payloadJson.name).toBe("홍길동");
expect(saved.payloadJson.message).toBe("문의 내용");
expect(saved.payloadJson.email).toBeUndefined();
expect(saved.payloadJson.phone).toBeUndefined();
expect(saved.payloadJson.birth).toBeUndefined();
expect(typeof saved.sensitiveEnc).toBe("string");
const { decryptJson } = await import("@/features/auth/authCrypto");
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
expect(sensitive.email).toBe("test@example.com");
expect(sensitive.phone).toBe("010-1234-5678");
expect(sensitive.birth).toBe("1990-01-02");
});
it("프로젝트를 찾지 못해도 projectSlug 만으로 FormSubmission 이 저장되어야 한다", async () => {
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "err",
fieldIds: [],
submitButtonId: null,
};
const formData = new FormData();
formData.append("email", "no-project@example.com");
formData.append("__projectSlug", "unknown-slug");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(inMemoryFormSubmissions.length).toBe(1);
const saved = inMemoryFormSubmissions[0] as any;
expect(saved.projectSlug).toBe("unknown-slug");
expect(saved.projectId ?? null).toBeNull();
expect(saved.userId ?? null).toBeNull();
});
it("config.fields 가 비어 있어도 email/phone/birthdate 키는 민감정보로 암호화되어야 한다", async () => {
inMemoryProjects.push({
id: "proj-2",
slug: "project-2",
userId: "owner-2",
});
const config: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
successMessage: "ok",
errorMessage: "err",
fieldIds: [],
submitButtonId: null,
// fields 를 비워 두고, 실제 FormData 키 이름만으로 민감 필드를 판별하도록 한다.
} as any;
const formData = new FormData();
formData.append("name", "홍길동");
formData.append("email", "hgd@example.com");
formData.append("phone", "010-1111-2222");
formData.append("birthdate", "1990-01-01");
formData.append("message", "테스트 문의입니다");
formData.append("__projectSlug", "project-2");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(inMemoryFormSubmissions.length).toBe(1);
const saved = inMemoryFormSubmissions[0] as any;
// project 연관 정보
expect(saved.projectSlug).toBe("project-2");
expect(saved.projectId).toBe("proj-2");
expect(saved.userId).toBe("owner-2");
// payloadJson 에는 비민감 필드만 남아야 한다.
expect(saved.payloadJson).toBeTruthy();
expect(saved.payloadJson.name).toBe("홍길동");
expect(saved.payloadJson.message).toBe("테스트 문의입니다");
expect(saved.payloadJson.email).toBeUndefined();
expect(saved.payloadJson.phone).toBeUndefined();
expect(saved.payloadJson.birthdate).toBeUndefined();
// 민감 필드는 암호화된 sensitiveEnc 에만 존재해야 한다.
expect(typeof saved.sensitiveEnc).toBe("string");
const { decryptJson } = await import("@/features/auth/authCrypto");
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
expect(sensitive.email).toBe("hgd@example.com");
expect(sensitive.phone).toBe("010-1111-2222");
expect(sensitive.birthdate).toBe("1990-01-01");
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import "dotenv/config";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { signAccessToken } from "@/features/auth/authCrypto";
const BASE_URL = "http://localhost";
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
project = {
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
};
formSubmission = {
findMany: async ({ where: { projectId }, orderBy, take }: any) => {
let items = inMemoryFormSubmissions.filter((s) => s.projectId === projectId);
if (orderBy?.createdAt === "desc") {
items = items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
if (typeof take === "number") {
items = items.slice(0, take);
}
return items;
},
};
}
return { PrismaClient: PrismaClientMock };
});
const TEST_USER = { id: "user-1", email: "submissions@example.com", tokenVersion: 1 };
const OTHER_USER = { id: "user-2", email: "submissions2@example.com", tokenVersion: 1 };
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
const token = await signAccessToken(user);
return { cookie: `pb_access=${token}` };
}
async function buildAuthHeaders() {
return buildAuthHeadersFor(TEST_USER);
}
beforeEach(() => {
process.env.AUTH_JWT_SECRET = "test-jwt-secret-submissions";
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
});
describe("/api/projects/[slug]/submissions", () => {
it("로그인한 소유자가 자신의 프로젝트 제출 내역을 조회할 수 있어야 한다", async () => {
const project = {
id: "proj-1",
slug: "project-with-submissions",
userId: TEST_USER.id,
};
inMemoryProjects.push(project);
const createdAt = new Date();
inMemoryFormSubmissions.push({
id: "sub-1",
projectId: project.id,
projectSlug: project.slug,
userId: TEST_USER.id,
createdAt,
payloadJson: { name: "홍길동", message: "문의" },
sensitiveEnc: undefined,
metaJson: null,
});
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const headers = await buildAuthHeaders();
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
headers,
}),
{ params: { slug: project.slug } } as any,
);
expect(res.status).toBe(200);
const json = (await res.json()) as any[];
expect(Array.isArray(json)).toBe(true);
expect(json.length).toBe(1);
expect(json[0].id).toBe("sub-1");
expect(json[0].projectSlug).toBe(project.slug);
expect(json[0].payload.name).toBe("홍길동");
expect(json[0].payload.message).toBe("문의");
});
it("민감정보는 암호화되어 저장되더라도 조회 시 복호화된 값으로 응답 payload 에 포함되어야 한다", async () => {
const project = {
id: "proj-2",
slug: "project-sensitive",
userId: TEST_USER.id,
};
inMemoryProjects.push(project);
const { encryptJson } = await import("@/features/auth/authCrypto");
const sensitive = { email: "test@example.com", phone: "010-0000-0000" };
const sensitiveEnc = await encryptJson(sensitive);
const createdAt = new Date();
inMemoryFormSubmissions.push({
id: "sub-2",
projectId: project.id,
projectSlug: project.slug,
userId: TEST_USER.id,
createdAt,
payloadJson: { name: "김철수" },
sensitiveEnc,
metaJson: null,
});
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const headers = await buildAuthHeaders();
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
headers,
}),
{ params: { slug: project.slug } } as any,
);
expect(res.status).toBe(200);
const json = (await res.json()) as any[];
expect(json.length).toBe(1);
expect(json[0].payload.name).toBe("김철수");
expect(json[0].payload.email).toBe("test@example.com");
expect(json[0].payload.phone).toBe("010-0000-0000");
});
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/any/submissions`),
{ params: { slug: "any" } } as any,
);
expect(res.status).toBe(401);
});
it("다른 사용자가 소유한 프로젝트 제출 내역에 접근하면 404 를 반환해야 한다", async () => {
const project = {
id: "proj-3",
slug: "owner-only-project",
userId: OTHER_USER.id,
};
inMemoryProjects.push(project);
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
const headers = await buildAuthHeaders();
const res = await getSubmissions(
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
headers,
}),
{ params: { slug: project.slug } } as any,
);
expect(res.status).toBe(404);
});
});