IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

totp-service.test.ts

Buka raw
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/lib/server/modules/auth/repository", () => ({
  findUserWithTotpById: vi.fn(),
  setPendingTotpSecret: vi.fn(),
  completeTotpEnrollment: vi.fn(),
  clearTotpEnrollment: vi.fn(),
  updateTotpBackupCodes: vi.fn(),
  createSession: vi.fn(),
  deleteSessionsForUser: vi.fn(),
}));

vi.mock("@/lib/server/modules/auth/partial-auth-token", () => ({
  verifyPartialAuthToken: vi.fn(),
}));

vi.mock("@/lib/server/modules/auth/session-token", () => ({
  createSessionToken: vi.fn(
    (sessionId: string, exp: number) => `sess.${sessionId}.${exp}.sig`,
  ),
}));

vi.mock("@/lib/server/env", () => ({
  serverEnv: { AUTH_SESSION_HOURS: 168 },
}));

vi.mock("@/lib/server/modules/auth/totp-crypto", () => ({
  encryptSecret: vi.fn((plaintext: string) => `enc:${plaintext}`),
  decryptSecret: vi.fn((packed: string) => packed.replace(/^enc:/, "")),
}));

vi.mock("@/lib/server/modules/auth/rate-limit", () => ({
  isTotpCodeReplayed: vi.fn(() => false),
  markTotpCodeUsed: vi.fn(),
  isPartialAuthTokenBlocked: vi.fn(() => false),
  isPartialAuthTokenConsumed: vi.fn(() => false),
  markPartialAuthTokenConsumed: vi.fn(),
  recordPartialAuthFailure: vi.fn(),
}));

vi.mock("@/lib/server/modules/auth/totp", () => ({
  generateTotpSecret: vi.fn((username: string) => ({
    secret: "JBSWY3DPEHPK3PXP",
    otpAuthUrl: `otpauth://totp/Homeio%3A${encodeURIComponent(username)}?secret=JBSWY3DPEHPK3PXP&issuer=Homeio&algorithm=SHA1&digits=6&period=30`,
  })),
  verifyTotp: vi.fn(),
  verifyBackupCode: vi.fn(async () => ({ matchedHash: null })),
  generateBackupCodes: vi.fn(async () => ({
    plaintext: ["AAAAAAAAAA", "BBBBBBBBBB"],
    hashes: ["salt-a:hash-a", "salt-b:hash-b"],
  })),
}));

vi.mock("qrcode", () => ({
  toString: vi.fn(async () => '<svg data-mock="qr">…</svg>'),
}));

import {
  TotpServiceError,
  beginTotpEnrollment,
  completeTotpEnrollment,
  completeTotpLogin,
  disableTotp,
} from "@/lib/server/modules/auth/totp-service";
import {
  clearTotpEnrollment as persistClearedTotp,
  completeTotpEnrollment as persistEnrolledTotp,
  createSession,
  deleteSessionsForUser,
  findUserWithTotpById,
  setPendingTotpSecret,
  updateTotpBackupCodes,
} from "@/lib/server/modules/auth/repository";
import { verifyPartialAuthToken } from "@/lib/server/modules/auth/partial-auth-token";
import { createSessionToken } from "@/lib/server/modules/auth/session-token";
import {
  isPartialAuthTokenBlocked,
  isPartialAuthTokenConsumed,
  isTotpCodeReplayed,
  markPartialAuthTokenConsumed,
  markTotpCodeUsed,
  recordPartialAuthFailure,
} from "@/lib/server/modules/auth/rate-limit";
import {
  decryptSecret,
  encryptSecret,
} from "@/lib/server/modules/auth/totp-crypto";
import {
  verifyBackupCode,
  verifyTotp,
} from "@/lib/server/modules/auth/totp";

const PENDING_USER = {
  id: "user-1",
  username: "admin",
  passwordHash: "hash",
  totpSecret: "enc:JBSWY3DPEHPK3PXP",
  totpEnabled: false,
  totpBackupCodes: null,
  totpEnrolledAt: null,
};

const FRESH_USER = {
  ...PENDING_USER,
  totpSecret: null,
};

const ENROLLED_USER = {
  ...PENDING_USER,
  totpEnabled: true,
  totpBackupCodes: "enc:" + JSON.stringify(["salt-a:hash-a", "salt-b:hash-b"]),
  totpEnrolledAt: new Date("2026-05-20T00:00:00.000Z"),
};

beforeEach(() => {
  vi.mocked(findUserWithTotpById).mockReset();
  // CAS-style helpers return true by default so the happy path keeps working;
  // tests covering CAS-loss override with mockResolvedValueOnce(false).
  vi.mocked(setPendingTotpSecret).mockReset().mockResolvedValue(true);
  vi.mocked(persistEnrolledTotp).mockReset().mockResolvedValue(true);
  vi.mocked(persistClearedTotp).mockReset();
  vi.mocked(updateTotpBackupCodes).mockReset().mockResolvedValue(true);
  vi.mocked(createSession).mockReset();
  vi.mocked(deleteSessionsForUser).mockReset();
  vi.mocked(encryptSecret).mockClear();
  vi.mocked(decryptSecret).mockClear();
  vi.mocked(verifyTotp).mockReset();
  vi.mocked(verifyBackupCode)
    .mockReset()
    .mockResolvedValue({ matchedHash: null });
  vi.mocked(isTotpCodeReplayed).mockReset().mockReturnValue(false);
  vi.mocked(markTotpCodeUsed).mockReset();
  vi.mocked(verifyPartialAuthToken).mockReset();
  vi.mocked(createSessionToken).mockClear();
  vi.mocked(isPartialAuthTokenBlocked).mockReset().mockReturnValue(false);
  vi.mocked(isPartialAuthTokenConsumed).mockReset().mockReturnValue(false);
  vi.mocked(markPartialAuthTokenConsumed).mockReset();
  vi.mocked(recordPartialAuthFailure).mockReset();
});

describe("beginTotpEnrollment", () => {
  it("generates a secret, stores its ciphertext, and returns SVG + URL", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...FRESH_USER });

    const result = await beginTotpEnrollment("user-1");

    expect(result.secret).toMatch(/^[A-Z2-7]+$/);
    expect(result.otpAuthUrl).toMatch(/^otpauth:\/\/totp\/Homeio%3Aadmin\?/);
    expect(result.qrCodeSvg).toContain("<svg");

    expect(vi.mocked(setPendingTotpSecret)).toHaveBeenCalledTimes(1);
    const [storedUserId, storedCiphertext] = vi
      .mocked(setPendingTotpSecret)
      .mock.calls[0];
    expect(storedUserId).toBe("user-1");
    expect(storedCiphertext).toBe(`enc:${result.secret}`);
    expect(storedCiphertext).not.toBe(result.secret);
  });

  it("rejects with 'already_enabled' (409) when totp_enabled is true", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({
      ...FRESH_USER,
      totpEnabled: true,
      totpEnrolledAt: new Date(),
    });

    await expect(beginTotpEnrollment("user-1")).rejects.toMatchObject({
      name: "TotpServiceError",
      code: "already_enabled",
      statusCode: 409,
    });
    expect(vi.mocked(setPendingTotpSecret)).not.toHaveBeenCalled();
  });

  it("rejects with 401 when the user row no longer exists", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce(null);

    const error = await beginTotpEnrollment("ghost-user").catch((e) => e);
    expect(error).toBeInstanceOf(TotpServiceError);
    expect(error.statusCode).toBe(401);
    expect(vi.mocked(setPendingTotpSecret)).not.toHaveBeenCalled();
  });

  it("maps a CAS loss on setPendingTotpSecret to already_enabled (409)", async () => {
    // Concurrent /verify completed enrolment between our read and our write,
    // so the conditional UPDATE matched zero rows. We must not return a QR
    // for a secret the DB will never accept.
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...FRESH_USER });
    vi.mocked(setPendingTotpSecret).mockResolvedValueOnce(false);

    await expect(beginTotpEnrollment("user-1")).rejects.toMatchObject({
      code: "already_enabled",
      statusCode: 409,
    });
  });
});

describe("completeTotpEnrollment", () => {
  it("returns the plaintext backup codes and persists the enrolled state on success", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...PENDING_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(true);

    const result = await completeTotpEnrollment({
      userId: "user-1",
      code: "123456",
    });

    expect(result.enabled).toBe(true);
    expect(result.backupCodes).toEqual(["AAAAAAAAAA", "BBBBBBBBBB"]);
    expect(result.enrolledAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);

    // The decrypted secret must reach verifyTotp, never the ciphertext.
    expect(vi.mocked(verifyTotp)).toHaveBeenCalledWith(
      "JBSWY3DPEHPK3PXP",
      "123456",
    );

    // The backup-code JSON written to the DB must be encrypted, and must
    // NOT contain the plaintext codes.
    expect(vi.mocked(persistEnrolledTotp)).toHaveBeenCalledTimes(1);
    const stored = vi.mocked(persistEnrolledTotp).mock.calls[0][0];
    expect(stored.userId).toBe("user-1");
    expect(stored.encryptedBackupCodes).toMatch(/^enc:/);
    expect(stored.encryptedBackupCodes).not.toContain("AAAAAAAAAA");
    expect(stored.enrolledAt).toBeInstanceOf(Date);

    // Successful code is marked used so its replay is rejected.
    expect(vi.mocked(markTotpCodeUsed)).toHaveBeenCalledWith(
      "user-1",
      "123456",
    );
  });

  it("rejects with invalid_totp (400) when the code does not verify", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...PENDING_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(false);

    await expect(
      completeTotpEnrollment({ userId: "user-1", code: "000000" }),
    ).rejects.toMatchObject({
      name: "TotpServiceError",
      code: "invalid_totp",
      statusCode: 400,
    });

    expect(vi.mocked(persistEnrolledTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(markTotpCodeUsed)).not.toHaveBeenCalled();
  });

  it("rejects replays of the most recently accepted code with invalid_totp", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...PENDING_USER });
    vi.mocked(isTotpCodeReplayed).mockReturnValueOnce(true);

    await expect(
      completeTotpEnrollment({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 400,
    });

    // Verification must not even run when the replay guard trips, so we
    // don't waste HMAC cycles and the code looks identical to a wrong code.
    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(persistEnrolledTotp)).not.toHaveBeenCalled();
  });

  it("rejects with no_pending_enrollment (409) when totp_secret is null", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...FRESH_USER });

    await expect(
      completeTotpEnrollment({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "no_pending_enrollment",
      statusCode: 409,
    });
    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
  });

  it("rejects with already_enabled (409) when totp is already turned on", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({
      ...PENDING_USER,
      totpEnabled: true,
      totpEnrolledAt: new Date(),
    });

    await expect(
      completeTotpEnrollment({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "already_enabled",
      statusCode: 409,
    });
    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
  });

  it("rejects with invalid_totp when the stored secret cannot be decrypted", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...PENDING_USER });
    vi.mocked(decryptSecret).mockImplementationOnce(() => {
      throw new Error("auth tag mismatch");
    });

    await expect(
      completeTotpEnrollment({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 400,
    });
    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(persistEnrolledTotp)).not.toHaveBeenCalled();
  });

  it("returns 401 when the user no longer exists", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce(null);

    await expect(
      completeTotpEnrollment({ userId: "ghost", code: "123456" }),
    ).rejects.toMatchObject({
      code: "not_enrolled",
      statusCode: 401,
    });
  });

  it("maps a CAS loss on completeTotpEnrollment to already_enabled (409)", async () => {
    // Two near-simultaneous /verify calls (e.g., a UI double-click): the
    // first flipped totp_enabled on with its backup codes; ours matched zero
    // rows. Must NOT return our locally-generated backup codes because they
    // do not exist in the DB — the user would be locked out of recovery.
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...PENDING_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(true);
    vi.mocked(persistEnrolledTotp).mockResolvedValueOnce(false);

    await expect(
      completeTotpEnrollment({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "already_enabled",
      statusCode: 409,
    });
    // Replay guard must not register a successful use either, since the
    // outcome to the caller is "already enabled, retry path".
    expect(vi.mocked(markTotpCodeUsed)).not.toHaveBeenCalled();
  });
});

describe("disableTotp", () => {
  it("clears the enrolment when the TOTP code is valid", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(true);

    const result = await disableTotp({ userId: "user-1", code: "123456" });

    expect(result).toEqual({ enabled: false });
    expect(vi.mocked(verifyTotp)).toHaveBeenCalledWith(
      "JBSWY3DPEHPK3PXP",
      "123456",
    );
    // Backup-code path must not run when TOTP already matched.
    expect(vi.mocked(verifyBackupCode)).not.toHaveBeenCalled();
    expect(vi.mocked(persistClearedTotp)).toHaveBeenCalledWith("user-1");
    // Every session for the user is revoked — disabling 2FA is a credential-
    // grade change and we force a fresh login on every device.
    expect(vi.mocked(deleteSessionsForUser)).toHaveBeenCalledWith("user-1");
    expect(vi.mocked(markTotpCodeUsed)).toHaveBeenCalledWith(
      "user-1",
      "123456",
    );
  });

  it("clears the enrolment when a backup code is valid", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyBackupCode).mockResolvedValueOnce({
      matchedHash: "salt-a:hash-a",
    });

    const result = await disableTotp({
      userId: "user-1",
      code: "AAAA-AAAA-AA",
    });

    expect(result).toEqual({ enabled: false });
    // TOTP path is shape-gated so a non-digit code must skip verifyTotp.
    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(verifyBackupCode)).toHaveBeenCalledWith(
      ["salt-a:hash-a", "salt-b:hash-b"],
      "AAAA-AAAA-AA",
    );
    expect(vi.mocked(persistClearedTotp)).toHaveBeenCalledWith("user-1");
  });

  it("falls back to the backup-code path when the TOTP code is wrong", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(false);
    vi.mocked(verifyBackupCode).mockResolvedValueOnce({
      matchedHash: "salt-b:hash-b",
    });

    const result = await disableTotp({ userId: "user-1", code: "000000" });

    expect(result).toEqual({ enabled: false });
    expect(vi.mocked(verifyTotp)).toHaveBeenCalled();
    expect(vi.mocked(verifyBackupCode)).toHaveBeenCalled();
    expect(vi.mocked(persistClearedTotp)).toHaveBeenCalledWith("user-1");
  });

  it("rejects with invalid_totp (400) when neither path matches", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(false);
    vi.mocked(verifyBackupCode).mockResolvedValueOnce({ matchedHash: null });

    await expect(
      disableTotp({ userId: "user-1", code: "999999" }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 400,
    });

    expect(vi.mocked(persistClearedTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(markTotpCodeUsed)).not.toHaveBeenCalled();
  });

  it("rejects with not_enabled (409) when TOTP is not currently on", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...PENDING_USER });

    await expect(
      disableTotp({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "not_enabled",
      statusCode: 409,
    });

    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(verifyBackupCode)).not.toHaveBeenCalled();
    expect(vi.mocked(persistClearedTotp)).not.toHaveBeenCalled();
  });

  it("rejects replays with invalid_totp before doing any HMAC work", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(isTotpCodeReplayed).mockReturnValueOnce(true);

    await expect(
      disableTotp({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 400,
    });

    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(verifyBackupCode)).not.toHaveBeenCalled();
    expect(vi.mocked(persistClearedTotp)).not.toHaveBeenCalled();
  });

  it("returns 401 when the user row is missing", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce(null);

    await expect(
      disableTotp({ userId: "ghost", code: "123456" }),
    ).rejects.toMatchObject({
      code: "not_enrolled",
      statusCode: 401,
    });
    expect(vi.mocked(persistClearedTotp)).not.toHaveBeenCalled();
  });

  it("maps a corrupt TOTP ciphertext to invalid_totp", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(decryptSecret).mockImplementationOnce(() => {
      throw new Error("auth tag mismatch");
    });

    await expect(
      disableTotp({ userId: "user-1", code: "123456" }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 400,
    });
    expect(vi.mocked(persistClearedTotp)).not.toHaveBeenCalled();
  });

  it("maps a corrupt backup-code ciphertext to invalid_totp", async () => {
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    // First decryptSecret call is for totpSecret (succeeds); second is for
    // backup-codes blob (throws). Only triggered if TOTP path fails and we
    // try backup — use a non-digit code to skip TOTP entirely.
    vi.mocked(decryptSecret).mockImplementationOnce(() => {
      throw new Error("auth tag mismatch");
    });

    await expect(
      disableTotp({ userId: "user-1", code: "AAAAAAAAAA" }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 400,
    });
    expect(vi.mocked(verifyBackupCode)).not.toHaveBeenCalled();
    expect(vi.mocked(persistClearedTotp)).not.toHaveBeenCalled();
  });
});

describe("completeTotpLogin", () => {
  it("issues a session on a valid TOTP code", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(true);

    const result = await completeTotpLogin({
      partialAuthToken: "partial.user-1.99999999.sig",
      code: "123456",
    });

    expect(result.sessionToken).toMatch(/^sess\./);
    expect(result.user).toEqual({ id: "user-1", username: "admin" });
    expect(vi.mocked(createSession)).toHaveBeenCalledTimes(1);
    expect(vi.mocked(createSessionToken)).toHaveBeenCalledTimes(1);
    // No backup code consumed on the TOTP path.
    expect(vi.mocked(updateTotpBackupCodes)).not.toHaveBeenCalled();
    expect(vi.mocked(markTotpCodeUsed)).toHaveBeenCalledWith(
      "user-1",
      "123456",
    );
    // Successful exchange burns the partial token so it cannot be reused.
    expect(vi.mocked(markPartialAuthTokenConsumed)).toHaveBeenCalledTimes(1);
    expect(vi.mocked(recordPartialAuthFailure)).not.toHaveBeenCalled();
  });

  it("rejects with too_many_attempts (429) when the partial token is blocked", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(isPartialAuthTokenBlocked).mockReturnValueOnce(true);

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.user-1.99999999.sig",
        code: "123456",
      }),
    ).rejects.toMatchObject({
      code: "too_many_attempts",
      statusCode: 429,
    });

    expect(vi.mocked(findUserWithTotpById)).not.toHaveBeenCalled();
    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
  });

  it("rejects with partial_auth_consumed (401) once the token has been used", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(isPartialAuthTokenConsumed).mockReturnValueOnce(true);

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.user-1.99999999.sig",
        code: "123456",
      }),
    ).rejects.toMatchObject({
      code: "partial_auth_consumed",
      statusCode: 401,
    });

    expect(vi.mocked(findUserWithTotpById)).not.toHaveBeenCalled();
    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
  });

  it("records a partial-auth failure on an invalid_totp result", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(false);
    vi.mocked(verifyBackupCode).mockResolvedValueOnce({ matchedHash: null });

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.user-1.99999999.sig",
        code: "999999",
      }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 401,
    });

    // Failure is counted against the brute-force budget for this partial
    // token; the token itself is not invalidated (still valid until expiry).
    expect(vi.mocked(recordPartialAuthFailure)).toHaveBeenCalledTimes(1);
    expect(vi.mocked(markPartialAuthTokenConsumed)).not.toHaveBeenCalled();
  });

  it("refuses a backup code whose hash was consumed concurrently (CAS loss)", async () => {
    // Two concurrent /login/totp calls with different backup codes both read
    // the same blob; the first one already wrote the pruned version, so our
    // CAS update returns false and we can't decode the same hash a second
    // time. Refuse the login — single-use means single-use.
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById)
      .mockResolvedValueOnce({ ...ENROLLED_USER })
      // CAS retry: refetch returns the already-pruned blob without our hash.
      .mockResolvedValueOnce({
        ...ENROLLED_USER,
        totpBackupCodes: "enc:" + JSON.stringify(["salt-b:hash-b"]),
      });
    vi.mocked(verifyBackupCode).mockResolvedValueOnce({
      matchedHash: "salt-a:hash-a",
    });
    vi.mocked(updateTotpBackupCodes).mockResolvedValueOnce(false);

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.user-1.99999999.sig",
        code: "AAAA-AAAA-AA",
      }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 401,
    });

    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
  });

  it("issues a session AND prunes the matched hash when a backup code is used", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyBackupCode).mockResolvedValueOnce({
      matchedHash: "salt-a:hash-a",
    });

    const result = await completeTotpLogin({
      partialAuthToken: "partial.user-1.99999999.sig",
      code: "AAAA-AAAA-AA",
    });

    expect(result.sessionToken).toMatch(/^sess\./);

    expect(vi.mocked(updateTotpBackupCodes)).toHaveBeenCalledTimes(1);
    const params = vi.mocked(updateTotpBackupCodes).mock.calls[0][0];
    expect(params.userId).toBe("user-1");
    // CAS: the previous blob must equal what we read; the next blob is the
    // JSON array minus the consumed hash, re-encrypted via our `enc:` mock.
    expect(params.previousBackupCodes).toBe(ENROLLED_USER.totpBackupCodes);
    expect(params.nextBackupCodes).toBe(
      `enc:${JSON.stringify(["salt-b:hash-b"])}`,
    );
  });

  it("rejects with partial_auth_expired when the token is missing or invalid", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce(null);

    await expect(
      completeTotpLogin({
        partialAuthToken: "garbage",
        code: "123456",
      }),
    ).rejects.toMatchObject({
      code: "partial_auth_expired",
      statusCode: 401,
    });

    expect(vi.mocked(findUserWithTotpById)).not.toHaveBeenCalled();
    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
  });

  it("rejects with invalid_totp (401) when the user vanished between A7 and A8", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "ghost",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce(null);

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.ghost.99999999.sig",
        code: "123456",
      }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 401,
    });
    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
  });

  it("rejects with invalid_totp when TOTP was disabled between A7 and A8", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({
      ...ENROLLED_USER,
      totpEnabled: false,
    });

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.user-1.99999999.sig",
        code: "123456",
      }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 401,
    });
    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
  });

  it("rejects with invalid_totp on a wrong code without consuming anything", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(verifyTotp).mockReturnValueOnce(false);
    vi.mocked(verifyBackupCode).mockResolvedValueOnce({ matchedHash: null });

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.user-1.99999999.sig",
        code: "999999",
      }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 401,
    });
    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
    expect(vi.mocked(updateTotpBackupCodes)).not.toHaveBeenCalled();
    expect(vi.mocked(markTotpCodeUsed)).not.toHaveBeenCalled();
  });

  it("rejects with invalid_totp on a replay", async () => {
    vi.mocked(verifyPartialAuthToken).mockReturnValueOnce({
      userId: "user-1",
      expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60,
    });
    vi.mocked(findUserWithTotpById).mockResolvedValueOnce({ ...ENROLLED_USER });
    vi.mocked(isTotpCodeReplayed).mockReturnValueOnce(true);

    await expect(
      completeTotpLogin({
        partialAuthToken: "partial.user-1.99999999.sig",
        code: "123456",
      }),
    ).rejects.toMatchObject({
      code: "invalid_totp",
      statusCode: 401,
    });
    expect(vi.mocked(verifyTotp)).not.toHaveBeenCalled();
    expect(vi.mocked(createSession)).not.toHaveBeenCalled();
  });
});
byte
27.396
baris
758
Jenis
ts
Commit terakhir
16 Oktober 2026