#34: fix the mail counters, the reader pane, and the state between surfaces
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>
This commit is contained in:
1 parent
e2ea58a4c9
commit
8b69521c76
25 files changed
+2276
-380
No files matched your search
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* The wiring behind the counters, the reader pane and the two surfaces.
|
||||
*
|
||||
* mailCounts.test.ts and emailBody.test.ts cover the rules themselves; these
|
||||
* check that the screens actually go through them, because every defect in this
|
||||
* file was a component computing the number or the markup inline where nothing
|
||||
* could reach it.
|
||||
*/
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
/** Strips comments, so a fix may name the code it removed without tripping. */
|
||||
const code = (path: string) =>
|
||||
read(path)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
|
||||
const USER_LAYOUT = "src/app/(user)/user/layout.tsx";
|
||||
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
|
||||
const ADMIN_LOGIN = "src/app/(auth)/admin/login/page.tsx";
|
||||
const USER_LOGIN = "src/app/(auth)/user/login/page.tsx";
|
||||
|
||||
describe("the counters the sidebar shows", () => {
|
||||
// The badge was computed inline from folders.find(...).unread_count, which
|
||||
// missed folders with no special-use role and printed nothing for Drafts.
|
||||
it("goes through the tested counting rules", () => {
|
||||
expect(code(USER_LAYOUT)).toContain("badgeCount");
|
||||
expect(code(USER_LAYOUT)).not.toMatch(/unread_count\s*\?\?/);
|
||||
});
|
||||
|
||||
// The folder list was fetched once on mount, so every badge froze at the
|
||||
// value it had when the tab was opened: new mail did not raise it and reading
|
||||
// a message did not lower it.
|
||||
it("refreshes the folder list instead of fetching it once on mount", () => {
|
||||
const layout = code(USER_LAYOUT);
|
||||
expect(layout).toContain("useFolders");
|
||||
expect(layout).not.toMatch(/useEffect\([^)]*\)\s*=>\s*\{\s*void fetch\("\/api\/v1\/mail\/folders"\)/);
|
||||
});
|
||||
|
||||
it("re-reads the counters when the server pushes a mailbox event", () => {
|
||||
expect(code("src/lib/useFolders.ts")).toContain("useMailEvents");
|
||||
});
|
||||
|
||||
// Reading a message changes a counter that lives in another component, so the
|
||||
// reader has to say so; nothing else can know.
|
||||
it("gives the reader a way to announce that a counter moved", () => {
|
||||
expect(code("src/lib/useFolders.ts")).toContain("MAIL_STATE_CHANGED");
|
||||
expect(code(MAIL_PAGE)).toContain("notifyMailStateChanged");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the counters the message list shows", () => {
|
||||
// The header printed messages.length - the size of one page, capped at 50 -
|
||||
// as though it were the size of the folder.
|
||||
it("does not present the length of the loaded page as the folder total", () => {
|
||||
const page = code(MAIL_PAGE);
|
||||
expect(page).toContain("listHeaderCount");
|
||||
expect(page).not.toContain("({messages.length})");
|
||||
});
|
||||
|
||||
it("filters through the tested rule rather than a chain of inline predicates", () => {
|
||||
expect(code(MAIL_PAGE)).toContain("filterMessages");
|
||||
});
|
||||
|
||||
// A chip that filters by unread or by attachment is worth a number, and both
|
||||
// were available on the page already.
|
||||
it("shows how many messages each filter chip would keep", () => {
|
||||
expect(code(MAIL_PAGE)).toContain("messageCounts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read and unread state", () => {
|
||||
// Opening a message marked it read on the server, so the flag flipping back
|
||||
// on the next reload was the list, the badge and the server disagreeing.
|
||||
it("persists a read-state change through the API, not only in local state", () => {
|
||||
expect(code(MAIL_PAGE)).toContain("/api/v1/mail/seen");
|
||||
});
|
||||
|
||||
it("keeps the row in step through the tested helper", () => {
|
||||
expect(code(MAIL_PAGE)).toContain("applySeen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reader pane", () => {
|
||||
it("resolves the message's own inline images", () => {
|
||||
const page = code(MAIL_PAGE);
|
||||
expect(page).toContain("resolveInlineImages");
|
||||
expect(page).toContain("inline_images");
|
||||
});
|
||||
|
||||
it("renders the body inside the tested document rather than a bare fragment", () => {
|
||||
expect(code(MAIL_PAGE)).toContain("buildReaderDocument");
|
||||
});
|
||||
|
||||
// A fixed 500px frame clipped every long message and left a slab of white
|
||||
// under every short one.
|
||||
it("sizes the frame from the message instead of pinning it to 500px", () => {
|
||||
const page = code(MAIL_PAGE);
|
||||
expect(page).not.toContain("h-[500px]");
|
||||
expect(page).toContain("lm:reader-height");
|
||||
// The height report needs script inside the frame, which sandbox="" forbids.
|
||||
expect(page).toContain("allow-scripts");
|
||||
});
|
||||
|
||||
// Every one of these was a Portuguese string typed into a component that had
|
||||
// a translated bundle beside it, so the interface was half English for a
|
||||
// Spanish reader and half Portuguese for an English one.
|
||||
it("has no hardcoded strings left in the reader", () => {
|
||||
const page = read(MAIL_PAGE);
|
||||
for (const literal of ["Sem assunto", "Para:", "Imagens Habilitadas", "(sanitised)"]) {
|
||||
expect(page).not.toContain(literal);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("moving between the two surfaces", () => {
|
||||
// The mailbox is the account's own mail behind the account's own password.
|
||||
it("does not ask the webmail sign-in for a second factor", () => {
|
||||
const login = code(USER_LOGIN);
|
||||
expect(login).not.toContain("mfa_required");
|
||||
expect(login).not.toContain("challenge_token");
|
||||
});
|
||||
|
||||
// Crossing into the console asked for the password again, though the session
|
||||
// in hand had just proven it.
|
||||
it("steps up into the console instead of sending the operator to a password form", () => {
|
||||
expect(code(USER_LAYOUT)).toContain("/admin/step-up");
|
||||
expect(code(USER_LAYOUT)).not.toContain('href="/admin/login"');
|
||||
});
|
||||
|
||||
it("collects only the second factor on the step-up screen", () => {
|
||||
const stepUp = code("src/app/(auth)/admin/step-up/page.tsx");
|
||||
expect(stepUp).toContain("/api/v1/auth/admin/step-up");
|
||||
expect(stepUp).not.toContain('type="password"');
|
||||
});
|
||||
|
||||
// Leaving the console needs nothing: the admin sign-in now issues the webmail
|
||||
// session too, so the link back cannot land on a login screen.
|
||||
it("keeps a plain link out of the console", () => {
|
||||
expect(code("src/app/(admin)/admin/layout.tsx")).toContain("/user/mail/inbox");
|
||||
});
|
||||
|
||||
it("still requires the second factor at the admin sign-in itself", () => {
|
||||
expect(code(ADMIN_LOGIN)).toContain("challenge_token");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user