Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.
- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
'warning' entry regression) and render behavior.
Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.
jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.
Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { initPasswordValidation, loadPasswordRules } from "./password_validation";
|
|
|
|
// The checklist is display-only (the server enforces), but it gates the
|
|
// submit button and mirrors the ACTIVE server rules — these tests pin the
|
|
// rules matrix, the fetch fallback behavior, and the submit gating.
|
|
|
|
function buildDom(): void {
|
|
document.body.innerHTML = `
|
|
<input id="password" />
|
|
<input id="confirm-password" />
|
|
<input id="username" />
|
|
<input id="email" />
|
|
<div id="password-requirements"></div>
|
|
<button id="register-btn">Register</button>
|
|
`;
|
|
}
|
|
|
|
function setPassword(value: string): void {
|
|
(document.getElementById("password") as HTMLInputElement).value = value;
|
|
(document.getElementById("password") as HTMLInputElement).dispatchEvent(new Event("input"));
|
|
}
|
|
|
|
const submitButton = (): HTMLButtonElement =>
|
|
document.getElementById("register-btn") as HTMLButtonElement;
|
|
|
|
describe("loadPasswordRules", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("adopts fetched rules", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ min_length: 12, require_upper: false }), { status: 200 }),
|
|
),
|
|
);
|
|
const rules = await loadPasswordRules();
|
|
expect(rules).toEqual({
|
|
min_length: 12,
|
|
require_upper: false,
|
|
require_lower: true,
|
|
require_number: true,
|
|
require_special: true,
|
|
});
|
|
});
|
|
|
|
it("falls back to defaults when the endpoint fails", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("nope", { status: 500 })),
|
|
);
|
|
const rules = await loadPasswordRules();
|
|
expect(rules).toEqual({
|
|
min_length: 8,
|
|
require_upper: true,
|
|
require_lower: true,
|
|
require_number: true,
|
|
require_special: true,
|
|
});
|
|
});
|
|
|
|
it("falls back to defaults on invalid payloads", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response(JSON.stringify({ min_length: -5 }), { status: 200 })),
|
|
);
|
|
const rules = await loadPasswordRules();
|
|
expect(rules.min_length).toBe(8);
|
|
});
|
|
});
|
|
|
|
describe("initPasswordValidation", () => {
|
|
beforeEach(() => {
|
|
// Deterministic rules: fetch failure lands on the historical defaults.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("nope", { status: 500 })),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// initPasswordValidation debounces through window.setTimeout, which under
|
|
// jsdom is a distinct timer from the faked global — so these tests wait
|
|
// out the real 100ms debounce instead of advancing fake clocks.
|
|
const settleDebounce = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
it("generates one checklist row per active rule plus the match row", async () => {
|
|
buildDom();
|
|
await loadPasswordRules();
|
|
initPasswordValidation({});
|
|
|
|
const rows = document.querySelectorAll("#password-requirements li");
|
|
expect(rows.length).toBe(6); // length, upper, lower, number, special, match
|
|
});
|
|
|
|
it("starts with the submit button disabled for an empty password", async () => {
|
|
buildDom();
|
|
await loadPasswordRules();
|
|
initPasswordValidation({});
|
|
|
|
expect(submitButton().disabled).toBe(true);
|
|
});
|
|
|
|
it("enables submit when all requirements pass", async () => {
|
|
buildDom();
|
|
await loadPasswordRules();
|
|
initPasswordValidation({});
|
|
|
|
setPassword("Abcdef1!");
|
|
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
|
|
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
|
|
new Event("input"),
|
|
);
|
|
(document.getElementById("username") as HTMLInputElement).value = "user";
|
|
(document.getElementById("username") as HTMLInputElement).dispatchEvent(new Event("input"));
|
|
(document.getElementById("email") as HTMLInputElement).value = "user@example.com";
|
|
(document.getElementById("email") as HTMLInputElement).dispatchEvent(new Event("input"));
|
|
await settleDebounce();
|
|
|
|
expect(submitButton().disabled).toBe(false);
|
|
});
|
|
|
|
it("keeps submit disabled on a confirmation mismatch", async () => {
|
|
buildDom();
|
|
await loadPasswordRules();
|
|
initPasswordValidation({});
|
|
|
|
setPassword("Abcdef1!");
|
|
(document.getElementById("confirm-password") as HTMLInputElement).value = "Different1!";
|
|
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
|
|
new Event("input"),
|
|
);
|
|
await settleDebounce();
|
|
|
|
expect(submitButton().disabled).toBe(true);
|
|
});
|
|
|
|
it("does not require username/email when requireUsernameEmail is false", async () => {
|
|
buildDom();
|
|
await loadPasswordRules();
|
|
initPasswordValidation({ requireUsernameEmail: false });
|
|
|
|
setPassword("Abcdef1!");
|
|
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
|
|
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
|
|
new Event("input"),
|
|
);
|
|
await settleDebounce();
|
|
|
|
expect(submitButton().disabled).toBe(false);
|
|
});
|
|
});
|