test(mfa): full MFA test coverage — unit + E2E
Unit tests (packages/api — 13 tests): - generateTotpSecret: DB write, returns secret + uri - verifyAndEnableTotp: valid token enables; invalid/already-enabled/no-secret guards - verifyTotp (login): valid → ok; invalid → UNAUTHORIZED; not-enabled → BAD_REQUEST - getCurrentMfaStatus: reads totpEnabled flag E2E tests (apps/web/e2e/dev-system/mfa.spec.ts — 7 scenarios): - Setup flow: generate secret, enable with valid code, reject invalid code, UI QR check - Login flow: MFA prompt appears, valid code logs in, wrong code shows error + stays on prompt - Login without MFA: no TOTP prompt for users without MFA enabled Also: start.sh health-check timeout 30s → 90s (container startup can exceed 30s) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* E2E tests for MFA (TOTP) flows.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. MFA setup: generate secret, enter valid TOTP, confirm enabled
|
||||
* 2. MFA login: sign in with password → TOTP prompt → enter code → dashboard
|
||||
* 3. MFA login: wrong TOTP code → error message, stays on prompt
|
||||
* 4. Login without MFA: no TOTP prompt for users without MFA enabled
|
||||
*
|
||||
* Design notes:
|
||||
* - Uses the admin user from STORAGE_STATE to set up/tear down MFA via tRPC.
|
||||
* - TOTP codes are generated in Node context using the `otpauth` package.
|
||||
* - Each test that enables MFA cleans up via disableMfa() in afterEach so
|
||||
* other test suites are not affected.
|
||||
* - Tests run against the live dev server (playwright.dev.config.ts).
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { TOTP, Secret } from "otpauth";
|
||||
import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
||||
|
||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
||||
|
||||
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ "0": { json: input } }),
|
||||
});
|
||||
const body = (await res.json()) as TrpcResult[];
|
||||
return body[0] ?? {};
|
||||
},
|
||||
{ procedure, input },
|
||||
);
|
||||
}
|
||||
|
||||
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||
const res = await fetch(`/api/trpc/${procedure}?batch=1&input=${encodedInput}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const body = (await res.json()) as TrpcResult[];
|
||||
return body[0] ?? {};
|
||||
},
|
||||
{ procedure, input },
|
||||
);
|
||||
}
|
||||
|
||||
// Enable MFA for the session user and return the TOTP instance for code generation.
|
||||
async function enableMfaForSession(page: Page): Promise<TOTP> {
|
||||
const genRes = await trpcMutation(page, "user.generateTotpSecret");
|
||||
const data = (genRes.result?.data as { json?: { secret?: string; uri?: string } })?.json;
|
||||
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
|
||||
|
||||
const totp = new TOTP({
|
||||
issuer: "CapaKraken",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: Secret.fromBase32(data.secret),
|
||||
});
|
||||
|
||||
const token = totp.generate();
|
||||
const enableRes = await trpcMutation(page, "user.verifyAndEnableTotp", { token });
|
||||
if (enableRes.error) throw new Error(`verifyAndEnableTotp failed: ${JSON.stringify(enableRes)}`);
|
||||
|
||||
return totp;
|
||||
}
|
||||
|
||||
// Disable MFA for the session user. Fetches current user profile first to get the userId.
|
||||
async function disableMfaForSession(page: Page): Promise<void> {
|
||||
const profileRes = await trpcQuery(page, "user.getCurrentUserProfile");
|
||||
const profile = (profileRes.result?.data as { json?: { id?: string } })?.json;
|
||||
if (!profile?.id) throw new Error("Could not resolve current user id for MFA disable");
|
||||
await trpcMutation(page, "user.disableTotp", { userId: profile.id });
|
||||
}
|
||||
|
||||
// ─── test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe("MFA — setup flow (account/security page)", () => {
|
||||
test.use({ storageState: STORAGE_STATE.admin });
|
||||
|
||||
let totp: TOTP | null = null;
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Clean up: disable MFA if a test enabled it
|
||||
if (totp) {
|
||||
await disableMfaForSession(page).catch(() => {/* already disabled or admin override */});
|
||||
totp = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("generates a TOTP secret and returns a valid otpauth URI", async ({ page }) => {
|
||||
await page.goto("/account/security");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const genRes = await trpcMutation(page, "user.generateTotpSecret");
|
||||
const data = (genRes.result?.data as { json?: { secret?: string; uri?: string } })?.json;
|
||||
|
||||
expect(data?.secret).toBeTruthy();
|
||||
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(data?.uri).toContain("CapaKraken");
|
||||
});
|
||||
|
||||
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
|
||||
await page.goto("/account/security");
|
||||
|
||||
totp = await enableMfaForSession(page);
|
||||
|
||||
// Verify status via tRPC
|
||||
const statusRes = await trpcQuery(page, "user.getMfaStatus");
|
||||
const status = (statusRes.result?.data as { json?: { totpEnabled?: boolean } })?.json;
|
||||
expect(status?.totpEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("verifyAndEnableTotp rejects an invalid 6-digit code", async ({ page }) => {
|
||||
await page.goto("/account/security");
|
||||
|
||||
// First generate a secret
|
||||
await trpcMutation(page, "user.generateTotpSecret");
|
||||
|
||||
// Submit obviously wrong code
|
||||
const res = await trpcMutation(page, "user.verifyAndEnableTotp", { token: "000000" });
|
||||
expect(res.error?.data?.code).toBe("BAD_REQUEST");
|
||||
expect(res.error?.message).toMatch(/Invalid TOTP token/i);
|
||||
});
|
||||
|
||||
test("setup UI shows QR code and secret fields after generating", async ({ page }) => {
|
||||
await page.goto("/account/security");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click the enable/setup button if MFA is not yet enabled
|
||||
const setupBtn = page.getByRole("button", { name: /set up/i }).or(
|
||||
page.getByRole("button", { name: /enable.*mfa/i }),
|
||||
);
|
||||
|
||||
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await setupBtn.click();
|
||||
// QR code image or canvas should appear
|
||||
await expect(
|
||||
page.locator('img[alt*="QR"], canvas, [data-testid="qr-code"]').first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MFA login flow ───────────────────────────────────────────────────────────
|
||||
|
||||
test.describe("MFA — login flow", () => {
|
||||
// These tests need a fresh unauthenticated browser context
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
let totp: TOTP | null = null;
|
||||
|
||||
// Enable MFA on admin before each login test using a separate authenticated context
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ storageState: STORAGE_STATE.admin });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto("/dashboard");
|
||||
totp = await enableMfaForSession(page);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ browser }) => {
|
||||
if (!totp) return;
|
||||
// Re-authenticate as admin (without MFA — use admin bypass via fresh session)
|
||||
// Since we just enabled MFA on the admin account, we need to sign in with TOTP to disable
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/auth\/signin/, { timeout: 5000 }).catch(() => {});
|
||||
|
||||
// Enter TOTP if prompted
|
||||
const totpInput = page.locator("#totp");
|
||||
if (await totpInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await totpInput.fill(totp!.generate());
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/\/(dashboard|resources)/, { timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// Disable MFA
|
||||
await disableMfaForSession(page).catch(() => {});
|
||||
await ctx.close();
|
||||
totp = null;
|
||||
});
|
||||
|
||||
test("login with MFA-enabled account: password step shows TOTP prompt", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should NOT redirect to dashboard — should show TOTP input
|
||||
await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 });
|
||||
await expect(page.getByText(/two-factor/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("login with MFA: valid TOTP code completes sign-in", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 });
|
||||
|
||||
const code = totp!.generate();
|
||||
await page.fill("#totp", code);
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 12000 });
|
||||
});
|
||||
|
||||
test("login with MFA: wrong TOTP code shows error and stays on MFA prompt", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page.locator("#totp")).toBeVisible({ timeout: 8000 });
|
||||
|
||||
await page.fill("#totp", "000000");
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Should show error and remain on TOTP step
|
||||
await expect(
|
||||
page.getByText(/invalid.*code|incorrect.*token|try again/i).or(
|
||||
page.locator("[data-error]"),
|
||||
).first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should NOT have navigated away
|
||||
expect(page.url()).not.toMatch(/\/(dashboard|resources)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Login without MFA ────────────────────────────────────────────────────────
|
||||
|
||||
test.describe("MFA — users without MFA enabled", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "manager123");
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// No TOTP step — straight redirect
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 12000 });
|
||||
await expect(page.locator("#totp")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Unit tests for MFA procedure functions in user-self-service-procedure-support.ts.
|
||||
*
|
||||
* Tests cover:
|
||||
* - generateTotpSecret: secret creation and DB write
|
||||
* - verifyAndEnableTotp: valid/invalid token, guard conditions
|
||||
* - verifyTotp (login): valid/invalid token, not-enabled guard
|
||||
* - getCurrentMfaStatus: status read
|
||||
*
|
||||
* otpauth is mocked so tests are deterministic and do not depend on
|
||||
* time-based code generation.
|
||||
*/
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ─── otpauth mock ────────────────────────────────────────────────────────────
|
||||
// Must be hoisted before imports that pull in the module under test.
|
||||
const totpValidateMock = vi.hoisted(() => vi.fn<() => number | null>());
|
||||
|
||||
vi.mock("otpauth", () => {
|
||||
class Secret {
|
||||
base32: string;
|
||||
constructor() { this.base32 = "TESTBASE32SECRET"; }
|
||||
static fromBase32(v: string) { return v; }
|
||||
}
|
||||
class TOTP {
|
||||
validate(_args: { token: string; window: number }) { return totpValidateMock(); }
|
||||
toString() { return "otpauth://totp/CapaKraken:test@example.com?secret=TESTBASE32SECRET"; }
|
||||
}
|
||||
return { Secret, TOTP };
|
||||
});
|
||||
|
||||
// ─── import after mock setup ─────────────────────────────────────────────────
|
||||
import {
|
||||
generateTotpSecret,
|
||||
verifyAndEnableTotp,
|
||||
verifyTotp,
|
||||
getCurrentMfaStatus,
|
||||
} from "../router/user-self-service-procedure-support.js";
|
||||
|
||||
// ─── context helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
...((dbOverrides.user as object | undefined) ?? {}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
|
||||
...((dbOverrides.auditLog as object | undefined) ?? {}),
|
||||
},
|
||||
},
|
||||
dbUser: { id: "user_1", systemRole: "ADMIN" as const, permissionOverrides: null },
|
||||
session: {
|
||||
user: { email: "test@example.com", name: "Test User", image: null },
|
||||
expires: "2027-01-01T00:00:00.000Z",
|
||||
},
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN" as const,
|
||||
permissions: new Set<string>(),
|
||||
roleDefaults: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makePublicCtx(dbOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
...((dbOverrides.user as object | undefined) ?? {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── generateTotpSecret ───────────────────────────────────────────────────────
|
||||
|
||||
describe("generateTotpSecret", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it("writes the base32 secret to the user record", async () => {
|
||||
const ctx = makeSelfServiceCtx();
|
||||
await generateTotpSecret(ctx as Parameters<typeof generateTotpSecret>[0]);
|
||||
expect(ctx.db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user_1" },
|
||||
data: { totpSecret: "TESTBASE32SECRET" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the base32 secret and an otpauth URI", async () => {
|
||||
const ctx = makeSelfServiceCtx();
|
||||
const result = await generateTotpSecret(ctx as Parameters<typeof generateTotpSecret>[0]);
|
||||
expect(result.secret).toBe("TESTBASE32SECRET");
|
||||
expect(result.uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verifyAndEnableTotp ──────────────────────────────────────────────────────
|
||||
|
||||
describe("verifyAndEnableTotp", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
totpValidateMock.mockReset();
|
||||
});
|
||||
|
||||
const baseUser = {
|
||||
id: "user_1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
totpSecret: "TESTBASE32SECRET",
|
||||
totpEnabled: false,
|
||||
};
|
||||
|
||||
it("enables TOTP and returns { enabled: true } when token is valid", async () => {
|
||||
totpValidateMock.mockReturnValue(0); // delta 0 = current window
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||
});
|
||||
const result = await verifyAndEnableTotp(
|
||||
ctx as Parameters<typeof verifyAndEnableTotp>[0],
|
||||
{ token: "123456" },
|
||||
);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(ctx.db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user_1" },
|
||||
data: { totpEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when token is invalid", async () => {
|
||||
totpValidateMock.mockReturnValue(null);
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||
});
|
||||
await expect(
|
||||
verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], { token: "000000" }),
|
||||
).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." }));
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when no secret has been generated yet", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ ...baseUser, totpSecret: null }) },
|
||||
});
|
||||
await expect(
|
||||
verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], { token: "123456" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when TOTP is already enabled", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ ...baseUser, totpEnabled: true }) },
|
||||
});
|
||||
await expect(
|
||||
verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], { token: "123456" }),
|
||||
).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." }));
|
||||
});
|
||||
|
||||
it("writes an audit entry on successful enable", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
|
||||
});
|
||||
await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], { token: "123456" });
|
||||
// Audit entry is fire-and-forget; wait one tick
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(ctx.db.auditLog.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verifyTotp (login step) ──────────────────────────────────────────────────
|
||||
|
||||
describe("verifyTotp", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
totpValidateMock.mockReset();
|
||||
});
|
||||
|
||||
const mfaUser = { id: "user_1", totpSecret: "TESTBASE32SECRET", totpEnabled: true };
|
||||
|
||||
it("returns { valid: true } when token is correct", async () => {
|
||||
totpValidateMock.mockReturnValue(0);
|
||||
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
||||
const result = await verifyTotp(ctx as Parameters<typeof verifyTotp>[0], {
|
||||
userId: "user_1",
|
||||
token: "123456",
|
||||
});
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when token is invalid", async () => {
|
||||
totpValidateMock.mockReturnValue(null);
|
||||
const ctx = makePublicCtx({ user: { findUnique: vi.fn().mockResolvedValue(mfaUser) } });
|
||||
await expect(
|
||||
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "000000" }),
|
||||
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }));
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when user does not have MFA enabled", async () => {
|
||||
const ctx = makePublicCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpEnabled: false }) },
|
||||
});
|
||||
await expect(
|
||||
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
||||
).rejects.toThrow(new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." }));
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST when user has no TOTP secret (inconsistent state)", async () => {
|
||||
const ctx = makePublicCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ ...mfaUser, totpSecret: null }) },
|
||||
});
|
||||
await expect(
|
||||
verifyTotp(ctx as Parameters<typeof verifyTotp>[0], { userId: "user_1", token: "123456" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getCurrentMfaStatus ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getCurrentMfaStatus", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it("returns totpEnabled: true when MFA is active", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: true }) },
|
||||
});
|
||||
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
||||
expect(result).toEqual({ totpEnabled: true });
|
||||
});
|
||||
|
||||
it("returns totpEnabled: false when MFA is inactive", async () => {
|
||||
const ctx = makeSelfServiceCtx({
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: false }) },
|
||||
});
|
||||
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
|
||||
expect(result).toEqual({ totpEnabled: false });
|
||||
});
|
||||
});
|
||||
+8
-3
@@ -23,8 +23,9 @@ echo " Starting app container on port 3100..."
|
||||
docker compose --profile full up -d app
|
||||
|
||||
# 4. Wait for server to be ready
|
||||
echo " Waiting for server..."
|
||||
for i in {1..30}; do
|
||||
# Allow up to 90s: prisma generate + migrate deploy + next dev compilation
|
||||
echo " Waiting for server (up to 90s)..."
|
||||
for i in {1..90}; do
|
||||
if curl -sf http://localhost:3100/api/health > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "CapaKraken is running!"
|
||||
@@ -34,9 +35,13 @@ for i in {1..30}; do
|
||||
echo " Logs: docker logs -f capakraken-app-1"
|
||||
exit 0
|
||||
fi
|
||||
# Print progress every 10s
|
||||
if (( i % 10 == 0 )); then
|
||||
echo " Still waiting... (${i}s)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "ERROR: Server failed to start within 30 seconds"
|
||||
echo "ERROR: Server failed to start within 90 seconds"
|
||||
echo "Check logs: docker logs --tail 100 capakraken-app-1"
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user