#23: show real data, unify the surfaces and make enrolment scannable
The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.
Fabricated data, removed:
- The outbound queue was two invented jobs held in component state. Retry and
Freeze only edited that local array, so the screen showed the same two rows
on every deployment and reported success for actions that never happened.
"Freeze" named an operation the backend has never implemented. It now reads
/api/v1/admin/queue, which was already serving the real thing, and offers
retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
plausible traffic for a server that might have delivered nothing. Two of
those fields were never returned by the endpoint at all, so the fake disk
figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
swallowed and a made-up TXT record displayed as though a key had been
issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
DANE from static markup, on a deployment currently serving a self-signed
one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
"SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
the first edit wrote that invented filter into the account's real script.
The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.
Surfaces:
The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.
Enrolment:
Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.
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 insisted you save. It is now terminal, with copy and
continue.
Language:
translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.
The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.
Healthchecks:
web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.
Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7e93275d5
commit
ee240ae227
29 files changed
+2201
-1221
No files matched your search
@@ -0,0 +1,174 @@
|
||||
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/);
|
||||
}
|
||||
});
|
||||
|
||||
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/login");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user