The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.
Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.
Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.
Two defects found while testing the above:
- The admin sign-in checked the password and the second factor and never the
role, so any account that had enrolled a factor from its own settings could
obtain an admin-audience session.
- Session tokens carried nothing that varied below one second, so signing in
twice within the same second minted the identical token and collided with
the unique index on web_sessions.refresh_token_hash. A correct password
answered 500, and a double-clicked sign-in was enough to do it. Tokens now
carry a jti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
8.3 KiB
TypeScript
201 lines
8.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { MESSAGES, translate } from "./i18n/config.js";
|
|
|
|
/**
|
|
* Guards the class of defect this interface kept shipping: text that cannot be
|
|
* translated, and screens filled with invented data.
|
|
*
|
|
* Each of these reproduces something that reached production. They are file
|
|
* assertions rather than rendering tests because that is where the defects
|
|
* lived - a constant at the top of a module, a placeholder never substituted.
|
|
*/
|
|
|
|
function sourceFiles(): string[] {
|
|
const out: string[] = [];
|
|
const walk = (dir: string) => {
|
|
for (const entry of readdirSync(dir)) {
|
|
const path = join(dir, entry);
|
|
if (statSync(path).isDirectory()) walk(path);
|
|
else if (path.endsWith(".tsx") || (path.endsWith(".ts") && !path.includes(".test."))) out.push(path);
|
|
}
|
|
};
|
|
walk(resolve(process.cwd(), "src"));
|
|
return out;
|
|
}
|
|
|
|
describe("message interpolation", () => {
|
|
// The compose screen rendered "Undo Send ({seconds}s remaining) (9s
|
|
// remaining)": the bundle's placeholder was never filled, and a second
|
|
// hardcoded countdown was appended after it.
|
|
it("substitutes placeholders instead of printing them", () => {
|
|
const out = translate(MESSAGES.en, "mail.undoSend", { seconds: 9 });
|
|
expect(out).toContain("9");
|
|
expect(out).not.toContain("{seconds}");
|
|
});
|
|
|
|
it("substitutes in every locale, not just the fallback", () => {
|
|
for (const locale of ["en", "pt-BR", "es"] as const) {
|
|
expect(translate(MESSAGES[locale], "mail.undoSend", { seconds: 3 })).not.toContain("{");
|
|
}
|
|
});
|
|
|
|
// Leaving the token visible is a bug report; blanking it hides the bug.
|
|
it("leaves an unknown placeholder alone rather than blanking it", () => {
|
|
expect(translate(MESSAGES.en, "mail.undoSend", {})).toContain("{seconds}");
|
|
});
|
|
|
|
it("still resolves keys that take no parameters", () => {
|
|
expect(translate(MESSAGES["pt-BR"], "auth.signInButton")).toBe("Entrar");
|
|
});
|
|
});
|
|
|
|
describe("the Portuguese bundle is written in Portuguese", () => {
|
|
// A pass that stripped diacritics to satisfy the ASCII-source rule was
|
|
// applied to the JSON bundles as well, so the console displayed Portuguese
|
|
// words with their accents missing. That rule covers source code; a
|
|
// translation is data, and stripping it there just makes the text wrong.
|
|
//
|
|
// The expected values are written as \u escapes for the same reason: this
|
|
// file is source, so it may name the accented words without containing them.
|
|
it("keeps diacritics on words that require them", () => {
|
|
const pt = MESSAGES["pt-BR"];
|
|
expect(pt.ui.recipient).toBe("Destinat\u00e1rio");
|
|
expect(pt.ui.lastError).toBe("\u00daltimo erro");
|
|
expect(pt.ui.actions).toBe("A\u00e7\u00f5es");
|
|
});
|
|
|
|
it("has accented text across the bundle, not just in a few entries", () => {
|
|
const values = Object.values(MESSAGES["pt-BR"]).flatMap((ns) => Object.values(ns));
|
|
const accented = values.filter((v) => /[^\u0000-\u007F]/.test(v));
|
|
expect(accented.length).toBeGreaterThan(50);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Strips comments so these checks read the code, not the prose about it.
|
|
* Several of the fixes below are documented by naming the value they removed,
|
|
* and a scan that could not tell the two apart would forbid explaining itself.
|
|
*/
|
|
function code(file: string): string {
|
|
return readFileSync(file, "utf8")
|
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
.replace(/^\s*\/\/.*$/gm, "");
|
|
}
|
|
|
|
describe("no fabricated data is baked into the interface", () => {
|
|
const files = sourceFiles();
|
|
const read = (f: string) => readFileSync(f, "utf8");
|
|
|
|
// The queue screen shipped two invented jobs whose Retry and Freeze buttons
|
|
// only edited local state; the sidebars showed a fixed address; the domains
|
|
// screen showed thirteen example.com records marked VERIFIED.
|
|
const forbidden = [
|
|
"job-101",
|
|
"job-102",
|
|
"partner@remotehost.com",
|
|
"alerts@monitoring.org",
|
|
"admin@lambdamail.local",
|
|
"user@lambdamail.local",
|
|
"default-domain",
|
|
];
|
|
|
|
for (const needle of forbidden) {
|
|
it(`does not contain the placeholder "${needle}"`, () => {
|
|
const offenders = files.filter((f) => code(f).includes(needle));
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
}
|
|
|
|
it("reads the outbound queue from the API rather than component state", () => {
|
|
const queue = read(resolve(process.cwd(), "src/app/(admin)/admin/queue/page.tsx"));
|
|
expect(queue).toContain("/api/v1/admin/queue");
|
|
});
|
|
|
|
// Every KPI fell back to an invented constant, so an unreachable API showed
|
|
// a dashboard of plausible traffic for a server that had delivered nothing.
|
|
it("does not fall back to invented dashboard figures", () => {
|
|
const dash = code(resolve(process.cwd(), "src/app/(admin)/admin/dashboard/page.tsx"));
|
|
expect(dash).not.toMatch(/\?\?\s*\d{2,}/);
|
|
});
|
|
});
|
|
|
|
describe("surface routing", () => {
|
|
// The root listed the two surfaces side by side and advertised cookie names
|
|
// and audiences to anonymous visitors.
|
|
it("has no surface-selector landing page", () => {
|
|
const page = code(resolve(process.cwd(), "src/app/page.tsx"));
|
|
expect(page).toContain("redirect");
|
|
expect(page).toContain("/user/mail/inbox");
|
|
expect(page).not.toMatch(/Access Admin Console|Surface Selector/);
|
|
});
|
|
|
|
it("leaves no links pointing at the removed selector", () => {
|
|
for (const file of sourceFiles()) {
|
|
expect(code(file)).not.toMatch(/Back to Surface Selector|Back to surface selection/);
|
|
}
|
|
});
|
|
|
|
// The destination is the step-up rather than the admin sign-in: the console
|
|
// costs a second factor, but not the password the session in hand just
|
|
// proved. mail-state-integrity.test.ts covers the rest of that crossing.
|
|
it("offers the console from inside the app, gated on the account's role", () => {
|
|
const layout = readFileSync(resolve(process.cwd(), "src/app/(user)/user/layout.tsx"), "utf8");
|
|
expect(layout).toContain("isAdminRole");
|
|
expect(layout).toContain("/admin/step-up");
|
|
});
|
|
|
|
it("offers a plain way back to webmail from the console", () => {
|
|
const layout = readFileSync(resolve(process.cwd(), "src/app/(admin)/admin/layout.tsx"), "utf8");
|
|
expect(layout).toContain("/user/mail/inbox");
|
|
});
|
|
});
|
|
|
|
describe("two-factor enrolment", () => {
|
|
it("ships a QR code component both enrolment screens use", () => {
|
|
const path = resolve(process.cwd(), "src/components/TotpEnrolment.tsx");
|
|
expect(existsSync(path)).toBe(true);
|
|
expect(readFileSync(path, "utf8")).toContain("qrcode-generator");
|
|
|
|
for (const screen of [
|
|
"src/app/(user)/user/settings/page.tsx",
|
|
"src/app/(auth)/admin/login/page.tsx",
|
|
]) {
|
|
expect(readFileSync(resolve(process.cwd(), screen), "utf8")).toContain("TotpEnrolment");
|
|
}
|
|
});
|
|
|
|
// The recovery-code step rendered its own "sign in" button while the form's
|
|
// submit button stayed visible below it: two identical buttons, and no way
|
|
// to copy the codes it told you to save.
|
|
it("gives the recovery-code step a copy action and a single continue", () => {
|
|
const source = readFileSync(resolve(process.cwd(), "src/components/TotpEnrolment.tsx"), "utf8");
|
|
expect(source).toContain("common.copy");
|
|
expect(source).toContain("onContinue");
|
|
});
|
|
});
|
|
|
|
describe("API proxy routing", () => {
|
|
// A route added to the protocols service but not to the proxy's list is sent
|
|
// to the auth service instead, which answers 401 from its admin guard. That
|
|
// is indistinguishable from a session problem, so the failure looks like
|
|
// "you are not logged in" rather than "this was routed to the wrong
|
|
// service" - which is exactly how it went unnoticed once already.
|
|
it("sends every protocols-owned admin area to the protocols service", () => {
|
|
const proxy = readFileSync(resolve(process.cwd(), "src/app/api/v1/[...path]/route.ts"), "utf8");
|
|
|
|
// Areas the protocols service actually serves, taken from its router.
|
|
for (const area of ["dkim", "tls", "dns"]) {
|
|
expect(proxy).toContain(`"${area}"`);
|
|
}
|
|
expect(proxy).toMatch(/PROTOCOLS_ADMIN_AREAS/);
|
|
});
|
|
|
|
it("still sends mail to protocols and everything else to auth", () => {
|
|
const proxy = readFileSync(resolve(process.cwd(), "src/app/api/v1/[...path]/route.ts"), "utf8");
|
|
expect(proxy).toMatch(/path\[0\] === "mail".*PROTOCOLS_SERVICE_URL/s);
|
|
expect(proxy).toContain("return AUTH_SERVICE_URL");
|
|
});
|
|
});
|