mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
195 lines
5.8 KiB
TypeScript
195 lines
5.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import bcrypt from "bcryptjs";
|
|
import speakeasy from "speakeasy";
|
|
|
|
const userUpdate = vi.fn().mockResolvedValue(null);
|
|
const findById = vi.fn();
|
|
|
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
|
createCurrentUserRepository: () => ({ findById, update: userUpdate }),
|
|
createCurrentTrustedDeviceRepository: () => ({}),
|
|
createCurrentUserSessionRepository: () => ({}),
|
|
createCurrentUserAuthRepository: () => ({
|
|
removeSecondFactor: vi.fn().mockResolvedValue(true),
|
|
recordSecondFactor: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../../utils/logger.js", () => ({
|
|
authLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
|
DatabaseSaveTrigger: { forceSave: vi.fn().mockResolvedValue(undefined) },
|
|
}));
|
|
|
|
const { registerUserTotpRoutes } =
|
|
await import("../../../database/routes/user-totp-routes.js");
|
|
|
|
const secret = speakeasy.generateSecret({ name: "test" }).base32;
|
|
const PASSWORD = "correct-horse";
|
|
|
|
/**
|
|
* The disable dialog has one field, labelled "Enter TOTP code or password",
|
|
* and its caller passes that single value as `disableTOTP(input)` — which
|
|
* lands in the `password` argument, leaving `totp_code` undefined.
|
|
*
|
|
* 2.5.1 changed the route to require both, so from then on the first check
|
|
* rejected every attempt regardless of what was typed. Nobody could turn 2FA
|
|
* off, and the client reported it as the generic "Failed to disable 2FA".
|
|
*/
|
|
describe("POST /totp/disable", () => {
|
|
let handler: (req: unknown, res: unknown) => Promise<void>;
|
|
let setupHandler: (req: unknown, res: unknown) => Promise<void>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
const routes = new Map<string, (req: unknown, res: unknown) => unknown>();
|
|
const router = {
|
|
post: (path: string, ...rest: unknown[]) => {
|
|
routes.set(path, rest[rest.length - 1] as never);
|
|
},
|
|
get: () => {},
|
|
put: () => {},
|
|
delete: () => {},
|
|
};
|
|
|
|
registerUserTotpRoutes(
|
|
router as never,
|
|
{
|
|
authenticateJWT: (() => {}) as never,
|
|
authManager: { getUserDataKey: () => null } as never,
|
|
isNativeAppRequest: () => false,
|
|
} as never,
|
|
);
|
|
|
|
handler = routes.get("/totp/disable") as never;
|
|
setupHandler = routes.get("/totp/setup") as never;
|
|
findById.mockResolvedValue({
|
|
id: "user-1",
|
|
username: "test-user",
|
|
isOidc: false,
|
|
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
|
totpSecret: secret,
|
|
totpBackupCodes: JSON.stringify(["BACKUP01"]),
|
|
totpEnabled: true,
|
|
});
|
|
});
|
|
|
|
function call(body: Record<string, unknown>) {
|
|
const res = {
|
|
statusCode: 200,
|
|
body: undefined as unknown,
|
|
status(code: number) {
|
|
this.statusCode = code;
|
|
return this;
|
|
},
|
|
json(payload: unknown) {
|
|
this.body = payload;
|
|
return this;
|
|
},
|
|
};
|
|
return handler({ userId: "user-1", body }, res).then(() => res);
|
|
}
|
|
|
|
function callSetup(body: Record<string, unknown>) {
|
|
const res = {
|
|
statusCode: 200,
|
|
body: undefined as unknown,
|
|
status(code: number) {
|
|
this.statusCode = code;
|
|
return this;
|
|
},
|
|
json(payload: unknown) {
|
|
this.body = payload;
|
|
return this;
|
|
},
|
|
};
|
|
return setupHandler({ userId: "user-1", body }, res).then(() => res);
|
|
}
|
|
|
|
it("reveals the existing enrollment only after re-authentication", async () => {
|
|
const denied = await callSetup({ credential: "wrong" });
|
|
expect(denied.statusCode).toBe(401);
|
|
|
|
const allowed = await callSetup({ credential: PASSWORD });
|
|
expect(allowed.statusCode).toBe(200);
|
|
expect(allowed.body).toEqual(
|
|
expect.objectContaining({ secret, additional: true }),
|
|
);
|
|
expect(userUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires a credential before adding another authenticator", async () => {
|
|
const res = await callSetup({});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(userUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts the TOTP code on its own", async () => {
|
|
// What the dialog sends: one value, in whichever field the client used.
|
|
const res = await call({
|
|
totp_code: speakeasy.totp({ secret, encoding: "base32" }),
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(userUpdate).toHaveBeenCalledWith(
|
|
"user-1",
|
|
expect.objectContaining({ totpEnabled: false, totpSecret: null }),
|
|
);
|
|
});
|
|
|
|
it("accepts the account password on its own", async () => {
|
|
// The single field is labelled "TOTP code or password", and the client
|
|
// passes it as the password argument — this is the exact failing call.
|
|
const res = await call({ password: PASSWORD });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(userUpdate).toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts a backup code", async () => {
|
|
const res = await call({ totp_code: "BACKUP01" });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("still refuses a wrong value", async () => {
|
|
const res = await call({ password: "not-my-password" });
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
expect(userUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses an empty request rather than disabling anything", async () => {
|
|
const res = await call({});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(userUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let an OIDC user disable it with a password", async () => {
|
|
// No password to compare against; only a TOTP or backup code will do.
|
|
findById.mockResolvedValue({
|
|
id: "user-1",
|
|
isOidc: true,
|
|
passwordHash: null,
|
|
totpSecret: secret,
|
|
totpBackupCodes: JSON.stringify([]),
|
|
totpEnabled: true,
|
|
});
|
|
|
|
const res = await call({ password: "anything" });
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
expect(userUpdate).not.toHaveBeenCalled();
|
|
});
|
|
});
|