459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
import "dotenv/config";
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
|
|
|
|
const BASE_URL = "http://localhost";
|
|
|
|
// PrismaClient 를 실제 DB 대신 메모리 기반 user 저장소를 사용하는 목으로 대체한다.
|
|
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
|
|
const inMemoryUsers: any[] = [];
|
|
|
|
// 이메일 인증 토큰 발송 유틸을 목 처리해, 실제 SMTP 로 메일을 보내지 않고 호출 여부/인자를 검증한다.
|
|
const sendVerificationEmailMock = vi.fn();
|
|
|
|
vi.mock("@prisma/client", () => {
|
|
class PrismaClientMock {
|
|
user = {
|
|
// where.email 또는 where.id 기준으로 단일 유저를 찾는다.
|
|
findUnique: async ({ where }: any) => {
|
|
if (!where) return null;
|
|
if (where.email) {
|
|
return inMemoryUsers.find((u) => u.email === where.email) ?? null;
|
|
}
|
|
if (where.id) {
|
|
return inMemoryUsers.find((u) => u.id === where.id) ?? null;
|
|
}
|
|
if (where.emailVerifyToken) {
|
|
return inMemoryUsers.find((u) => u.emailVerifyToken === where.emailVerifyToken) ?? null;
|
|
}
|
|
return null;
|
|
},
|
|
create: async ({ data }: any) => {
|
|
const now = new Date();
|
|
const user = {
|
|
id: String(inMemoryUsers.length + 1),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...data,
|
|
};
|
|
inMemoryUsers.push(user);
|
|
return user;
|
|
},
|
|
// change-password / change-email 에서 사용할 update 목
|
|
update: async ({ where, data }: any) => {
|
|
const target = inMemoryUsers.find((u) => u.id === where.id);
|
|
if (!target) {
|
|
throw new Error("User not found in inMemoryUsers");
|
|
}
|
|
Object.assign(target, data, { updatedAt: new Date() });
|
|
return target;
|
|
},
|
|
};
|
|
}
|
|
|
|
return { PrismaClient: PrismaClientMock };
|
|
});
|
|
|
|
vi.mock("@/features/email/sendVerificationEmail", () => {
|
|
return {
|
|
sendVerificationEmail: (...args: any[]) => sendVerificationEmailMock(...args),
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
|
|
inMemoryUsers.length = 0;
|
|
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
|
|
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
|
|
|
sendVerificationEmailMock.mockReset();
|
|
});
|
|
|
|
describe("/api/auth", () => {
|
|
describe("POST /api/auth/signup", () => {
|
|
it("새 이메일과 8자 이상 비밀번호로 회원가입하면 User 가 생성되고 JWT 쿠키가 설정되어야 한다", async () => {
|
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
|
|
|
const payload = { email: "user@example.com", password: "securePass1" };
|
|
|
|
const res = await signup(
|
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
|
|
const json = (await res.json()) as any;
|
|
expect(json.email).toBe(payload.email);
|
|
expect(json.id).toBeDefined();
|
|
// 응답에는 passwordHash 가 포함되면 안 된다.
|
|
expect(json.passwordHash).toBeUndefined();
|
|
|
|
// 메모리 저장소에도 유저가 1명 생성되어야 한다.
|
|
expect(inMemoryUsers.length).toBe(1);
|
|
expect(inMemoryUsers[0].email).toBe(payload.email);
|
|
expect(typeof inMemoryUsers[0].passwordHash).toBe("string");
|
|
|
|
// JWT 세션 쿠키가 설정되어야 한다.
|
|
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 () => {
|
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
|
|
|
const res = await signup(
|
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: "short@example.com", password: "short" }),
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as any;
|
|
expect(json.message).toBeDefined();
|
|
});
|
|
|
|
it("이미 존재하는 이메일로 회원가입하면 409 를 반환해야 한다", async () => {
|
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
|
|
|
// 먼저 한 번 성공적으로 가입
|
|
await signup(
|
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: "dup@example.com", password: "securePass1" }),
|
|
}),
|
|
);
|
|
|
|
// 같은 이메일로 다시 요청
|
|
const res = await signup(
|
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: "dup@example.com", password: "anotherPass1" }),
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(409);
|
|
const json = (await res.json()) as any;
|
|
expect(json.message).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("POST /api/auth/login", () => {
|
|
it("올바른 이메일/비밀번호로 로그인하면 200 과 JWT 쿠키를 반환해야 한다", async () => {
|
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
|
const { POST: login } = await import("@/app/api/auth/login/route");
|
|
|
|
const email = "login@example.com";
|
|
const password = "securePass1";
|
|
|
|
// 사전 회원가입
|
|
await signup(
|
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
}),
|
|
);
|
|
|
|
const res = await login(
|
|
new Request(`${BASE_URL}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.email).toBe(email);
|
|
expect(json.id).toBeDefined();
|
|
expect(json.passwordHash).toBeUndefined();
|
|
|
|
const setCookie = res.headers.get("set-cookie");
|
|
expect(setCookie).toBeTruthy();
|
|
expect(setCookie).toContain("pb_access=");
|
|
});
|
|
|
|
it("잘못된 이메일 또는 비밀번호로 로그인하면 401 을 반환해야 한다", async () => {
|
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
|
const { POST: login } = await import("@/app/api/auth/login/route");
|
|
|
|
const email = "wrong@example.com";
|
|
const password = "securePass1";
|
|
|
|
// 사전 회원가입
|
|
await signup(
|
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
}),
|
|
);
|
|
|
|
// 존재하지 않는 이메일
|
|
const res1 = await login(
|
|
new Request(`${BASE_URL}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: "nope@example.com", password }),
|
|
}),
|
|
);
|
|
expect(res1.status).toBe(401);
|
|
|
|
// 비밀번호 불일치
|
|
const res2 = await login(
|
|
new Request(`${BASE_URL}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password: "wrongPass1" }),
|
|
}),
|
|
);
|
|
expect(res2.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/auth/logout", () => {
|
|
it("로그아웃 시 pb_access 쿠키를 제거하는 Set-Cookie 헤더를 반환해야 한다", async () => {
|
|
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
|
|
|
const res = await logout(
|
|
new Request(`${BASE_URL}/api/auth/logout`, {
|
|
method: "POST",
|
|
headers: {
|
|
cookie: "pb_access=dummy.token.value",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const setCookie = res.headers.get("set-cookie");
|
|
expect(setCookie).toBeTruthy();
|
|
expect(setCookie).toContain("pb_access=");
|
|
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
|
});
|
|
|
|
it("쿠키가 없어도 200 과 쿠키 제거 헤더를 반환해야 한다", async () => {
|
|
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
|
|
|
const res = await logout(
|
|
new Request(`${BASE_URL}/api/auth/logout`, {
|
|
method: "POST",
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const setCookie = res.headers.get("set-cookie");
|
|
expect(setCookie).toBeTruthy();
|
|
expect(setCookie).toContain("pb_access=");
|
|
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
|
});
|
|
});
|
|
|
|
describe("GET /api/auth/me", () => {
|
|
it("유효한 JWT 쿠키가 있을 때 현재 로그인 유저 정보를 반환해야 한다", async () => {
|
|
const { GET: me } = await import("@/app/api/auth/me/route");
|
|
|
|
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1, emailVerified: true };
|
|
|
|
// /api/auth/me 가 DB(User) 기준으로 동작하므로, Prisma 목(inMemoryUsers)에 동일한 유저를 추가한다.
|
|
inMemoryUsers.push({
|
|
id: user.id,
|
|
email: user.email,
|
|
passwordHash: "hashed",
|
|
tokenVersion: user.tokenVersion,
|
|
emailVerified: user.emailVerified,
|
|
emailVerifyToken: null,
|
|
emailVerifyTokenExpiresAt: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const token = await signAccessToken(user as any);
|
|
|
|
const res = await me(
|
|
new Request(`${BASE_URL}/api/auth/me`, {
|
|
headers: {
|
|
cookie: `pb_access=${token}`,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.id).toBe(user.id);
|
|
expect(json.email).toBe(user.email);
|
|
expect(json.emailVerified).toBe(true);
|
|
expect(json.passwordHash).toBeUndefined();
|
|
});
|
|
|
|
it("JWT 쿠키가 없거나 잘못된 경우 401 을 반환해야 한다", async () => {
|
|
const { GET: me } = await import("@/app/api/auth/me/route");
|
|
|
|
const res1 = await me(new Request(`${BASE_URL}/api/auth/me`));
|
|
expect(res1.status).toBe(401);
|
|
|
|
const res2 = await me(
|
|
new Request(`${BASE_URL}/api/auth/me`, {
|
|
headers: { cookie: "pb_access=invalid.token.here" },
|
|
}),
|
|
);
|
|
expect(res2.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/auth/request-email-verification", () => {
|
|
it("로그인된 사용자가 호출하면 emailVerifyToken 과 만료 시간이 설정되어야 한다", async () => {
|
|
const { POST: requestEmailVerification } = await import(
|
|
"@/app/api/auth/request-email-verification/route",
|
|
);
|
|
|
|
const email = "verify@example.com";
|
|
inMemoryUsers.push({
|
|
id: "user-20",
|
|
email,
|
|
passwordHash: "hashed",
|
|
tokenVersion: 1,
|
|
emailVerified: false,
|
|
emailVerifyToken: null,
|
|
emailVerifyTokenExpiresAt: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const token = await signAccessToken({ id: "user-20", email, tokenVersion: 1 });
|
|
|
|
const res = await requestEmailVerification(
|
|
new Request(`${BASE_URL}/api/auth/request-email-verification`, {
|
|
method: "POST",
|
|
headers: {
|
|
cookie: `pb_access=${token}`,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as any;
|
|
expect(json.message).toBeDefined();
|
|
expect(typeof json.verificationToken).toBe("string");
|
|
expect(json.verificationToken.length).toBeGreaterThan(0);
|
|
|
|
// in-memory 유저에 토큰과 만료 시간이 설정되었는지 확인한다.
|
|
const user = inMemoryUsers[0];
|
|
expect(typeof user.emailVerifyToken).toBe("string");
|
|
expect(user.emailVerifyToken.length).toBeGreaterThan(0);
|
|
expect(user.emailVerifyTokenExpiresAt instanceof Date).toBe(true);
|
|
|
|
// 이메일 발송 유틸이 올바른 인자로 한 번 호출되었는지 확인한다.
|
|
expect(sendVerificationEmailMock).toHaveBeenCalledTimes(1);
|
|
expect(sendVerificationEmailMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
to: email,
|
|
token: json.verificationToken,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("인증 정보가 없으면 401 을 반환해야 한다", async () => {
|
|
const { POST: requestEmailVerification } = await import(
|
|
"@/app/api/auth/request-email-verification/route",
|
|
);
|
|
|
|
const res = await requestEmailVerification(
|
|
new Request(`${BASE_URL}/api/auth/request-email-verification`, {
|
|
method: "POST",
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/auth/verify-email", () => {
|
|
it("유효한 토큰으로 호출하면 emailVerified 가 true 로 설정되고 토큰 필드가 제거되어야 한다", async () => {
|
|
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
|
|
|
const token = "verify-token-123";
|
|
const future = new Date(Date.now() + 60 * 60 * 1000);
|
|
inMemoryUsers.push({
|
|
id: "user-21",
|
|
email: "verify2@example.com",
|
|
passwordHash: "hashed",
|
|
tokenVersion: 1,
|
|
emailVerified: false,
|
|
emailVerifyToken: token,
|
|
emailVerifyTokenExpiresAt: future,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const res = await verifyEmail(
|
|
new Request(`${BASE_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`),
|
|
);
|
|
|
|
// 리다이렉트 응답이어야 한다.
|
|
expect(res.status).toBeGreaterThanOrEqual(300);
|
|
expect(res.status).toBeLessThan(400);
|
|
const location = res.headers.get("location");
|
|
expect(location).toContain("/mypage");
|
|
|
|
// in-memory 유저 상태가 업데이트되었는지 확인한다.
|
|
const user = inMemoryUsers[0];
|
|
expect(user.emailVerified).toBe(true);
|
|
expect(user.emailVerifyToken).toBeNull();
|
|
expect(user.emailVerifyTokenExpiresAt).toBeNull();
|
|
});
|
|
|
|
it("토큰이 없거나 잘못된 경우 400 을 반환해야 한다", async () => {
|
|
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
|
|
|
// 쿼리 파라미터 없이 호출
|
|
const res1 = await verifyEmail(
|
|
new Request(`${BASE_URL}/api/auth/verify-email`),
|
|
);
|
|
expect(res1.status).toBe(400);
|
|
|
|
// 잘못된 토큰으로 호출
|
|
const res2 = await verifyEmail(
|
|
new Request(`${BASE_URL}/api/auth/verify-email?token=unknown-token`),
|
|
);
|
|
expect(res2.status).toBe(400);
|
|
});
|
|
|
|
it("만료된 토큰으로 호출하면 400 을 반환해야 한다", async () => {
|
|
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
|
|
|
const token = "expired-token-123";
|
|
const past = new Date(Date.now() - 60 * 60 * 1000);
|
|
inMemoryUsers.push({
|
|
id: "user-22",
|
|
email: "expired@example.com",
|
|
passwordHash: "hashed",
|
|
tokenVersion: 1,
|
|
emailVerified: false,
|
|
emailVerifyToken: token,
|
|
emailVerifyTokenExpiresAt: past,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
|
|
const res = await verifyEmail(
|
|
new Request(`${BASE_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`),
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as any;
|
|
expect(json.message).toBeDefined();
|
|
});
|
|
});
|
|
});
|