All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m57s
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
|
|
vi.mock("../services/verification.js");
|
|
vi.mock("../services/email.js");
|
|
vi.mock("../services/db.js");
|
|
vi.mock("../services/logger.js", () => ({
|
|
default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
}));
|
|
|
|
let app: express.Express;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
|
|
const { createPendingVerification, verifyCode } = await import("../services/verification.js");
|
|
const { sendVerificationEmail } = await import("../services/email.js");
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
|
|
vi.mocked(createPendingVerification).mockResolvedValue({ email: "new@example.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 });
|
|
vi.mocked(verifyCode).mockResolvedValue({ status: "ok" });
|
|
vi.mocked(sendVerificationEmail).mockResolvedValue(true);
|
|
// Default: apiKey exists, email not taken
|
|
vi.mocked(queryWithRetry).mockImplementation((async (sql: string, params?: any[]) => {
|
|
if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("key =")) {
|
|
return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 };
|
|
}
|
|
if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("email =")) {
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
if (sql.includes("UPDATE")) {
|
|
return { rows: [{ email: "new@example.com" }], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}) as any);
|
|
|
|
const { emailChangeRouter } = await import("../routes/email-change.js");
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use("/v1/email-change", emailChangeRouter);
|
|
});
|
|
|
|
describe("POST /v1/email-change", () => {
|
|
it("returns 400 for missing apiKey", async () => {
|
|
const res = await request(app).post("/v1/email-change").send({ newEmail: "new@example.com" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 400 for missing newEmail", async () => {
|
|
const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 400 for invalid email format", async () => {
|
|
const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "bad" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 403 for invalid API key", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => {
|
|
if (sql.includes("SELECT") && sql.includes("key =")) {
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}) as any);
|
|
const res = await request(app).post("/v1/email-change").send({ apiKey: "fake", newEmail: "new@example.com" });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("returns 409 when email already taken", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => {
|
|
if (sql.includes("SELECT") && sql.includes("key =")) {
|
|
return { rows: [{ key: "df_pro_xxx", email: "old@example.com" }], rowCount: 1 };
|
|
}
|
|
if (sql.includes("SELECT") && sql.includes("email =")) {
|
|
return { rows: [{ key: "df_pro_other", email: "new@example.com" }], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}) as any);
|
|
const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it("returns 200 with verification_sent on success", async () => {
|
|
const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe("verification_sent");
|
|
});
|
|
|
|
it("does not crash when sendVerificationEmail fails (fire-and-forget)", async () => {
|
|
const { sendVerificationEmail } = await import("../services/email.js");
|
|
const logger = (await import("../services/logger.js")).default;
|
|
|
|
vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("SMTP connection failed"));
|
|
|
|
const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe("verification_sent");
|
|
|
|
// Give the catch handler a moment to execute
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
// Verify error was logged
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({ email: "new@example.com" }),
|
|
"Failed to send email change verification"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("POST /v1/email-change/verify", () => {
|
|
it("returns 400 for missing fields", async () => {
|
|
const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 403 for invalid API key", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => {
|
|
if (sql.includes("SELECT") && sql.includes("key =")) {
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}) as any);
|
|
|
|
const res = await request(app).post("/v1/email-change/verify").send({
|
|
apiKey: "fake",
|
|
newEmail: "new@example.com",
|
|
code: "123456"
|
|
});
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Invalid API key");
|
|
});
|
|
|
|
it("returns 400 for invalid code", async () => {
|
|
const { verifyCode } = await import("../services/verification.js");
|
|
vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" });
|
|
const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 410 for expired code", async () => {
|
|
const { verifyCode } = await import("../services/verification.js");
|
|
vi.mocked(verifyCode).mockResolvedValue({ status: "expired" });
|
|
const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" });
|
|
expect(res.status).toBe(410);
|
|
});
|
|
|
|
it("returns 429 for max attempts", async () => {
|
|
const { verifyCode } = await import("../services/verification.js");
|
|
vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" });
|
|
const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" });
|
|
expect(res.status).toBe(429);
|
|
});
|
|
|
|
it("returns 200 and updates email on success", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe("ok");
|
|
expect(res.body.newEmail).toBe("new@example.com");
|
|
// Verify UPDATE was called
|
|
expect(queryWithRetry).toHaveBeenCalledWith(
|
|
expect.stringContaining("UPDATE"),
|
|
expect.arrayContaining(["new@example.com", "df_pro_xxx"])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("POST /v1/email-change - Database failure handling", () => {
|
|
it("returns 500 when validateApiKey DB query fails", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
|
|
vi.mocked(queryWithRetry).mockRejectedValue(new Error("Connection pool exhausted"));
|
|
|
|
const res = await request(app).post("/v1/email-change").send({
|
|
apiKey: "df_pro_xxx",
|
|
newEmail: "new@example.com"
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toEqual({ error: "Internal server error" });
|
|
});
|
|
|
|
it("returns 500 when email existence check fails", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
|
|
let callCount = 0;
|
|
vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => {
|
|
callCount++;
|
|
// First call (validateApiKey) succeeds
|
|
if (callCount === 1 && sql.includes("SELECT") && sql.includes("key =")) {
|
|
return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 };
|
|
}
|
|
// Second call (email check) fails
|
|
throw new Error("DB connection lost");
|
|
}) as any);
|
|
|
|
const res = await request(app).post("/v1/email-change").send({
|
|
apiKey: "df_pro_xxx",
|
|
newEmail: "new@example.com"
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toEqual({ error: "Internal server error" });
|
|
});
|
|
|
|
it("returns 500 when createPendingVerification fails", async () => {
|
|
const { createPendingVerification } = await import("../services/verification.js");
|
|
|
|
vi.mocked(createPendingVerification).mockRejectedValue(new Error("DB insert failed"));
|
|
|
|
const res = await request(app).post("/v1/email-change").send({
|
|
apiKey: "df_pro_xxx",
|
|
newEmail: "new@example.com"
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toEqual({ error: "Internal server error" });
|
|
});
|
|
});
|
|
|
|
describe("POST /v1/email-change/verify - Database failure handling", () => {
|
|
it("returns 500 when validateApiKey DB query fails", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
|
|
vi.mocked(queryWithRetry).mockRejectedValue(new Error("Connection timeout"));
|
|
|
|
const res = await request(app).post("/v1/email-change/verify").send({
|
|
apiKey: "df_pro_xxx",
|
|
newEmail: "new@example.com",
|
|
code: "123456"
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toEqual({ error: "Internal server error" });
|
|
});
|
|
|
|
it("returns 500 when UPDATE query fails", async () => {
|
|
const { queryWithRetry } = await import("../services/db.js");
|
|
|
|
let callCount = 0;
|
|
vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => {
|
|
callCount++;
|
|
// First call (validateApiKey) succeeds
|
|
if (callCount === 1 && sql.includes("SELECT") && sql.includes("key =")) {
|
|
return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 };
|
|
}
|
|
// Second call (UPDATE) fails
|
|
throw new Error("UPDATE failed - constraint violation");
|
|
}) as any);
|
|
|
|
const res = await request(app).post("/v1/email-change/verify").send({
|
|
apiKey: "df_pro_xxx",
|
|
newEmail: "new@example.com",
|
|
code: "123456"
|
|
});
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body).toEqual({ error: "Internal server error" });
|
|
});
|
|
});
|