198 lines
9.8 KiB
TypeScript
198 lines
9.8 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { readFileSync } from "node:fs";
|
|
import { Window, type HTMLInputElement, type HTMLButtonElement, type HTMLElement } from "happy-dom";
|
|
|
|
test("MFA resend waits for cooldown, clears old code and replaces the active challenge", async () => {
|
|
const window = new Window({ url: "https://sso.example.test/mfa" });
|
|
try {
|
|
let now = Date.now();
|
|
window.Date.now = () => now;
|
|
window.sessionStorage.setItem(
|
|
"wrnexus.auth.mfa",
|
|
JSON.stringify({
|
|
mfaToken: "synthetic-test-challenge",
|
|
mfaExpiresAt: now + 300_000,
|
|
requires: { mfa: ["email-otp"] },
|
|
}),
|
|
);
|
|
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
|
|
<input name="mfaToken"><input name="challengeId"><input name="code"><input type="radio" name="method" value="email-otp">
|
|
<div data-auth-mfa-pin><input name="otpCode"></div><p data-auth-mfa-status></p><p data-error="_form">Verification failed</p>
|
|
<div data-auth-mfa-resend-group hidden><button type="button" data-auth-mfa-resend>Resend code</button></div><button type="submit">Verify</button>
|
|
</form></section>`;
|
|
const requests: any[] = [];
|
|
window.fetch = (async (_: unknown, init: RequestInit) => {
|
|
requests.push(JSON.parse(String(init.body)));
|
|
return Response.json({
|
|
id: "challenge-" + requests.length,
|
|
retryAfter: 60,
|
|
expiresAt: now + 300_000,
|
|
});
|
|
}) as any;
|
|
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
|
(window as any).WRNexusAuth.mount(window.document);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const button = window.document.querySelector<HTMLButtonElement>("[data-auth-mfa-resend]")!;
|
|
expect(button.disabled).toBe(true);
|
|
expect(button.textContent).toContain("60s");
|
|
button.dispatchEvent(new window.Event("click"));
|
|
expect(requests.length).toBe(1);
|
|
const code = window.document.querySelector<HTMLInputElement>("[name=otpCode]")!;
|
|
code.value = "123456";
|
|
now += 60_001;
|
|
button.dispatchEvent(new window.Event("click"));
|
|
button.dispatchEvent(new window.Event("click"));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(requests.length).toBe(2);
|
|
expect(requests[1].resend).toBe(true);
|
|
expect(window.document.querySelector<HTMLInputElement>("[name=challengeId]")!.value).toBe(
|
|
"challenge-2",
|
|
);
|
|
expect(code.value).toBe("");
|
|
expect(window.document.querySelector("[data-error=_form]")!.textContent).toBe("");
|
|
expect(button.disabled).toBe(true);
|
|
} finally {
|
|
await window.happyDOM.close();
|
|
}
|
|
});
|
|
|
|
test("expired and legacy MFA requests are rejected on mount before entering a code", async () => {
|
|
for (const deadline of [undefined, Date.now() - 1]) {
|
|
const window = new Window({ url: "https://sso.example.test/mfa" });
|
|
try {
|
|
window.sessionStorage.setItem(
|
|
"wrnexus.auth.mfa",
|
|
JSON.stringify({ mfaToken: "old-challenge", mfaExpiresAt: deadline }),
|
|
);
|
|
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
|
|
<input name="mfaToken"><input type="radio" name="method" value="totp"><input name="code"><p data-auth-mfa-status hidden></p><button type="submit">Verify</button>
|
|
</form><p data-auth-mfa-expired role="alert" hidden>Sign in again</p><a data-auth-mfa-restart hidden href="/login">Back to sign in</a></section>`;
|
|
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
|
(window as any).WRNexusAuth.mount(window.document);
|
|
expect(window.document.querySelector("button")!.disabled).toBe(true);
|
|
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-restart]")!.hidden).toBe(
|
|
false,
|
|
);
|
|
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-expired]")!.hidden).toBe(
|
|
false,
|
|
);
|
|
expect(window.document.querySelector("form")!.hidden).toBe(true);
|
|
expect(window.document.querySelector("[data-auth-mfa-status]")!.textContent).toBe("");
|
|
expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull();
|
|
} finally {
|
|
await window.happyDOM.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
test("selecting delivery radios sends once, ignores stale responses, and never sends for local methods", async () => {
|
|
const window = new Window({ url: "https://sso.example.test/mfa" });
|
|
try {
|
|
window.sessionStorage.setItem(
|
|
"wrnexus.auth.mfa",
|
|
JSON.stringify({ mfaToken: "synthetic-test-challenge", mfaExpiresAt: Date.now() + 300_000 }),
|
|
);
|
|
window.document.body.innerHTML = `<section data-auth-mfa-challenge data-mfa-initial-method="totp"><form data-auth-mfa-form>
|
|
<input name="mfaToken"><input name="challengeId"><input name="code">
|
|
${["totp", "recovery-code", "email-otp", "sms-otp"].map((value) => `<label><input type="radio" name="method" value="${value}">${value}</label>`).join("")}
|
|
<div data-auth-mfa-pin><input name="otpCode"></div><div data-auth-mfa-recovery hidden><input name="recoveryCode"></div>
|
|
<p data-auth-mfa-status></p><button type="submit">Verify</button>
|
|
</form></section>`;
|
|
const requests: Array<{ method: string; resolve: (response: Response) => void }> = [];
|
|
window.fetch = ((_: unknown, init: RequestInit) =>
|
|
new Promise<Response>((resolve) => {
|
|
requests.push({ method: JSON.parse(String(init.body)).method, resolve });
|
|
})) as any;
|
|
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
|
(window as any).WRNexusAuth.mount(window.document);
|
|
const form = window.document.querySelector("form")!;
|
|
const field = (name: string) => form.querySelector<HTMLInputElement>(`[name=${name}]`)!;
|
|
const choose = (value: string) => {
|
|
const radio = form.querySelector<HTMLInputElement>(`[name=method][value="${value}"]`)!;
|
|
radio.checked = true;
|
|
radio.dispatchEvent(new window.Event("change", { bubbles: true }));
|
|
};
|
|
choose("recovery-code");
|
|
expect(requests).toHaveLength(0);
|
|
field("recoveryCode").value = "abc123-def456";
|
|
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
|
|
expect(field("code").value).toBe("abc123-def456");
|
|
choose("email-otp");
|
|
choose("email-otp");
|
|
expect(requests.map((x) => x.method)).toEqual(["email-otp"]);
|
|
choose("sms-otp");
|
|
expect(requests.map((x) => x.method)).toEqual(["email-otp", "sms-otp"]);
|
|
requests[0]!.resolve(Response.json({ id: "email-challenge" }));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(field("challengeId").value).toBe("");
|
|
requests[1]!.resolve(Response.json({ id: "sms-challenge" }));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(field("challengeId").value).toBe("sms-challenge");
|
|
choose("totp");
|
|
expect(field("challengeId").value).toBe("");
|
|
expect(requests).toHaveLength(2);
|
|
field("otpCode").value = "123456";
|
|
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
|
|
expect(field("code").value).toBe("123456");
|
|
} finally {
|
|
await window.happyDOM.close();
|
|
}
|
|
});
|
|
|
|
test("MFA radios preserve allowed methods and restore continuation without reviving expired challenges", async () => {
|
|
const window = new Window({ url: "https://sso.example.test/mfa" });
|
|
try {
|
|
window.sessionStorage.setItem(
|
|
"wrnexus.auth.mfa",
|
|
JSON.stringify({
|
|
mfaToken: "synthetic-test-challenge",
|
|
mfaExpiresAt: Date.now() + 300_000,
|
|
requires: { mfa: ["totp"] },
|
|
}),
|
|
);
|
|
window.document.body.innerHTML = `<section data-auth-mfa-challenge data-mfa-initial-method="totp">
|
|
<form data-auth-mfa-form>
|
|
<input type="hidden" name="mfaToken">
|
|
<input type="hidden" name="challengeId">
|
|
<div data-auth-mfa-method><label><input type="radio" name="method" value="totp">Authenticator app</label><label><input type="radio" name="method" value="email-otp">Email</label></div>
|
|
<input name="code"><p data-auth-mfa-status></p><button type="submit">Verify</button>
|
|
</form><a data-auth-mfa-restart hidden href="/login">Back to sign in</a>
|
|
</section>`;
|
|
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
|
(window as any).WRNexusAuth.mount(window.document);
|
|
const form = window.document.querySelector("form")!;
|
|
const token = form.querySelector<HTMLInputElement>('[name="mfaToken"]')!;
|
|
expect(token.value).toBe("synthetic-test-challenge");
|
|
expect(form.querySelector<HTMLInputElement>('input[value="email-otp"]')!.disabled).toBe(true);
|
|
token.value = ""; // Hydration reapplies the component's initial empty prop.
|
|
form.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
|
|
expect(token.value).toBe("synthetic-test-challenge");
|
|
form.dispatchEvent(
|
|
new window.CustomEvent("wrn:error", { bubbles: true, detail: { code: "mfa-invalid" } }),
|
|
);
|
|
expect(token.value).toBe("synthetic-test-challenge");
|
|
form.dispatchEvent(
|
|
new window.CustomEvent("wrn:error", {
|
|
bubbles: true,
|
|
detail: { code: "mfa-transaction-expired" },
|
|
}),
|
|
);
|
|
// Validation finalizers may restore button.disabled after dispatching error.
|
|
form.querySelector("button")!.disabled = false;
|
|
expect(form.hidden).toBe(true);
|
|
expect(
|
|
window.document.querySelector<HTMLElement>("[data-auth-mfa-challenge]")!.dataset.mfaExpired,
|
|
).toBe("true");
|
|
expect(window.sessionStorage.getItem("wrnexus.auth.mfa")).toBeNull();
|
|
expect(window.document.querySelector<HTMLElement>("[data-auth-mfa-restart]")!.hidden).toBe(
|
|
false,
|
|
);
|
|
const retry = new window.Event("submit", { bubbles: true, cancelable: true });
|
|
form.dispatchEvent(retry);
|
|
expect(retry.defaultPrevented).toBe(true);
|
|
expect(token.value).toBe("");
|
|
} finally {
|
|
await window.happyDOM.close();
|
|
}
|
|
});
|