homeio
PublikFork dari doctor-io/homeio
route.test.ts
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/server/modules/auth/totp-service", () => ({
TotpServiceError: class TotpServiceError extends Error {
code: string;
statusCode: number;
publicMessage: string;
constructor(
message: string,
options: {
code: string;
statusCode: number;
publicMessage?: string;
},
) {
super(message);
this.name = "TotpServiceError";
this.code = options.code;
this.statusCode = options.statusCode;
this.publicMessage = options.publicMessage ?? message;
}
},
disableTotp: vi.fn(),
}));
import { POST } from "@/app/api/v1/auth/2fa/disable/route";
import { requireApiSession } from "@/lib/server/modules/auth/api";
import {
TotpServiceError,
disableTotp,
} from "@/lib/server/modules/auth/totp-service";
const buildRequest = (body: unknown) =>
new Request("http://localhost/api/v1/auth/2fa/disable", {
method: "POST",
headers: { "content-type": "application/json" },
body: typeof body === "string" ? body : JSON.stringify(body),
});
const VALID_BODY = { code: "123456" };
beforeEach(() => {
vi.mocked(disableTotp).mockReset();
vi.mocked(requireApiSession).mockImplementation(async () => ({
session: {
sessionId: "test-session",
userId: "test-user",
username: "admin",
passwordHash: "test-password-hash",
expiresAt: new Date(Date.now() + 3600_000),
},
response: null,
}));
});
describe("POST /api/v1/auth/2fa/disable", () => {
it("returns 401 when the session cannot be authenticated", async () => {
vi.mocked(requireApiSession).mockResolvedValueOnce({
session: null,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
});
const response = await POST(buildRequest(VALID_BODY));
expect(response.status).toBe(401);
expect(vi.mocked(disableTotp)).not.toHaveBeenCalled();
});
it("returns 400 for a malformed JSON body", async () => {
const response = await POST(buildRequest("{not json"));
expect(response.status).toBe(400);
expect(vi.mocked(disableTotp)).not.toHaveBeenCalled();
});
it("returns 400 when the body is missing 'code'", async () => {
const response = await POST(buildRequest({}));
expect(response.status).toBe(400);
expect(vi.mocked(disableTotp)).not.toHaveBeenCalled();
});
it("returns 200 with { enabled: false } on a successful TOTP disable", async () => {
vi.mocked(disableTotp).mockResolvedValueOnce({ enabled: false });
const response = await POST(buildRequest(VALID_BODY));
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { enabled: boolean } };
expect(json.data).toEqual({ enabled: false });
expect(vi.mocked(disableTotp)).toHaveBeenCalledWith({
userId: "test-user",
code: "123456",
});
});
it("clears the session cookie on success so the caller cannot ride the disable", async () => {
vi.mocked(disableTotp).mockResolvedValueOnce({ enabled: false });
const response = await POST(buildRequest(VALID_BODY));
expect(response.status).toBe(200);
// The service revoked every session row for the user; the route must
// also strip the caller's cookie so the browser stops presenting a token
// the server no longer recognises.
const setCookie = response.headers.get("set-cookie") ?? "";
expect(setCookie).toMatch(/Expires=Thu, 01 Jan 1970/);
expect(setCookie).toMatch(/^[^=]+=;/);
});
it("passes a backup-code style payload straight through to the service", async () => {
vi.mocked(disableTotp).mockResolvedValueOnce({ enabled: false });
const response = await POST(
buildRequest({ code: "AAAA-AAAA-AA" }),
);
expect(response.status).toBe(200);
expect(vi.mocked(disableTotp)).toHaveBeenCalledWith({
userId: "test-user",
code: "AAAA-AAAA-AA",
});
});
it("maps invalid_totp to 400 with the code in the body", async () => {
vi.mocked(disableTotp).mockRejectedValueOnce(
new TotpServiceError("Invalid verification code", {
code: "invalid_totp",
statusCode: 400,
}),
);
const response = await POST(buildRequest(VALID_BODY));
expect(response.status).toBe(400);
const json = (await response.json()) as { error: string; code: string };
expect(json.code).toBe("invalid_totp");
});
it("maps not_enabled to 409", async () => {
vi.mocked(disableTotp).mockRejectedValueOnce(
new TotpServiceError("Not enabled", {
code: "not_enabled",
statusCode: 409,
}),
);
const response = await POST(buildRequest(VALID_BODY));
expect(response.status).toBe(409);
const json = (await response.json()) as { code: string };
expect(json.code).toBe("not_enabled");
});
it("maps not_enrolled (ghost user) to 401", async () => {
vi.mocked(disableTotp).mockRejectedValueOnce(
new TotpServiceError("User not found", {
code: "not_enrolled",
statusCode: 401,
}),
);
const response = await POST(buildRequest(VALID_BODY));
expect(response.status).toBe(401);
const json = (await response.json()) as { code: string };
expect(json.code).toBe("not_enrolled");
});
it("returns 500 with a generic message for unexpected errors", async () => {
vi.mocked(disableTotp).mockRejectedValueOnce(
new Error("database is down"),
);
const response = await POST(buildRequest(VALID_BODY));
expect(response.status).toBe(500);
const json = (await response.json()) as { error: string; code?: string };
expect(json.error).not.toContain("database is down");
expect(json.code).toBeUndefined();
});
});