IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

route.test.ts

Open raw
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();
  });
});
bytes
5,808
lines
182
Type
ts
Latest commit
16 October 2026