Files
inbuxa-webmail/server/src/accounts.test.ts
T
jcoffey-dev 9fcf4812f3
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Notifications with the app closed, for every signed-in account
Second of three for background push across accounts (multi-account
spec, MA-8 part 2).

Turning on "Notify me even when inbuxa is closed" now registers this
browser in every signed-in account, each through its own session (the
route from the previous change), and renewal keeps them all current.
GET /api/auth/accounts says which mail account each session is, so the
subscription can name it. The remembered endpoint is kept per account,
and survives the clean-up that follows switching accounts.

The service worker is told who the other accounts are. A push for one
of them is titled with that account's address, shown even while a tab
is focused (the tab only shows the front account's mail), and its
Mark read and Archive act through that account's session. Clicking it
brings that account to the front and opens the message. While push is
on, the tab's own polling of other accounts stops notifying, so nothing
arrives twice.

Signing out of one account removes this device's subscription there
only; signing out of all, or turning push off, removes every one.

Also fixes where every background notification opened: the worker
linked to /mail/inbox/<thread>, and the route takes a mailbox id there,
so a click landed on the inbox list with "That folder no longer
exists". The worker now gets each account's inbox id and links to the
message.

Checked end to end in Chrome against a local server with two accounts:
both registered and verified, a message to the account not in front
showed a notification under its address, and clicking it switched
accounts and opened the message. No new strings. typecheck, tests
(web 1547, server 279) and build pass.
2026-10-05 20:38:44 -07:00

201 lines
9.4 KiB
TypeScript

import { test, after } from "node:test";
import assert from "node:assert/strict";
/**
* inbuxa MA-B: more than one account signed in in one browser. The session
* cookie is the account in front; the others ride in `<name>_more`. Adding
* signs a second account in beside the first, switching swaps them, signing
* out ends only the one in front, and an organization that doesn't allow it
* keeps adding off.
*/
const PORT = 18801;
process.env.MOCK_PORT = String(PORT);
process.env.MOCK_USER = "[email protected]";
process.env.MOCK_PASS = "first-password";
process.env.MOCK_SECOND_USER = "[email protected]";
process.env.MOCK_SECOND_PASS = "second-password";
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
process.env.APP_SECRET = "test-secret-for-accounts";
// Every test here signs in several times from one address
process.env.LOGIN_RATE_LIMIT = "100";
const mock = await import("./mock/index.js");
const { createApp } = await import("./app.js");
const { config } = await import("./config.js");
const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js");
const app = createApp();
const FRONT = config.cookieName;
const MORE = `${config.cookieName}_more`;
after(() => {
(mock as { server?: { close(): void } }).server?.close();
});
/** A browser's cookie jar, as far as these two cookies go. */
class Browser {
jar = new Map<string, string>();
private take(res: Response) {
for (const line of res.headers.getSetCookie()) {
const [pair, ...attrs] = line.split(";");
const at = pair!.indexOf("=");
const name = pair!.slice(0, at).trim();
const value = pair!.slice(at + 1).trim();
const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a));
if (expired || !value) this.jar.delete(name);
else this.jar.set(name, value);
}
}
async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> {
const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ");
const res = await app.request(path, {
method: init.method ?? "GET",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) },
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
});
this.take(res);
const text = await res.text();
return { status: res.status, body: text ? JSON.parse(text) : null };
}
signIn(username: string, password: string, add = false) {
return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } });
}
async accounts(): Promise<{ username: string; front: boolean; id: string }[]> {
const res = await this.call("/api/auth/accounts");
assert.equal(res.status, 200, JSON.stringify(res.body));
return res.body.accounts;
}
}
test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => {
const good = "abcdefghij.ABCDEFGHIJKLMN";
assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]);
const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`);
assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1);
assert.equal(serializeOthers(["bad", good]), good);
});
test("a second account joins the first, and switching swaps them", async () => {
const b = new Browser();
assert.equal((await b.signIn("[email protected]", "first-password")).status, 200);
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
const first = b.jar.get(FRONT);
const added = await b.signIn("[email protected]", "second-password", true);
assert.equal(added.status, 200, JSON.stringify(added.body));
assert.equal(added.body.added, true);
assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one");
let accounts = await b.accounts();
assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["[email protected]", true], ["[email protected]", false]]);
// The same account again is not a second copy
await b.signIn("[email protected]", "first-password", true);
accounts = await b.accounts();
assert.equal(accounts.length, 2);
assert.equal(accounts[0]!.username, "[email protected]", "it came to the front instead");
// Switch back
const second = accounts.find((a) => !a.front)!;
assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200);
assert.equal((await b.accounts())[0]!.username, "[email protected]");
// Signing out ends only the one in front; the other comes forward
const out = await b.call("/api/auth/logout", { method: "POST" });
assert.equal(out.body.next, true);
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
// Sign out of all
await b.signIn("[email protected]", "second-password", true);
assert.equal((await b.accounts()).length, 2);
await b.call("/api/auth/logout-all", { method: "POST" });
assert.equal(b.jar.has(FRONT), false);
assert.equal(b.jar.has(MORE), false);
assert.equal((await b.call("/api/auth/accounts")).status, 401);
});
test("an account in front can't switch to one it doesn't hold", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404);
});
test("an organization that doesn't allow it keeps adding off", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
process.env.MOCK_NO_ADD_ACCOUNTS = "1";
try {
// The cached upstream session is a minute old at most; ask afresh
await b.call("/api/auth/session?refresh=1");
const res = await b.call("/api/auth/accounts");
assert.equal(res.body.canAdd, false);
const added = await b.signIn("[email protected]", "second-password", true);
assert.equal(added.status, 403);
assert.equal(added.body.error, "add_not_allowed");
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"], "the front stayed");
} finally {
delete process.env.MOCK_NO_ADD_ACCOUNTS;
}
});
test("inbuxa MA-8: the accounts not in front report their Inbox unread count", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
// Alone, there is nothing to report
assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []);
await b.signIn("[email protected]", "second-password", true);
const res = await b.call("/api/auth/accounts/unread");
assert.equal(res.status, 200);
assert.equal(res.body.accounts.length, 1, "only the account not in front");
const [other] = res.body.accounts;
const listed = (await b.accounts()).find((a) => !a.front)!;
assert.equal(other.id, listed.id);
assert.equal(typeof other.unread, "number", JSON.stringify(res.body));
});
test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in front", async () => {
const { otherAccountCallAllowed } = await import("./app.js");
assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true);
// Anything else is refused
assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false);
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false);
assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false);
const b = new Browser();
await b.signIn("[email protected]", "first-password");
const added = await b.signIn("[email protected]", "second-password", true);
assert.equal(added.status, 200, JSON.stringify(added.body));
const other = (await b.accounts()).find((a) => !a.front)!;
const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
method: "POST",
body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get");
const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
method: "POST",
body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] },
});
assert.equal(refused.status, 403);
// The account in front isn't reached this way, nor a session not held here
const front = (await b.accounts()).find((a) => a.front)!;
assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404);
});
test("inbuxa MA-8: each listed account says which mail account it is", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
await b.signIn("[email protected]", "second-password", true);
const res = await b.call("/api/auth/accounts");
for (const a of res.body.accounts) assert.equal(typeof a.mailAccountId, "string", JSON.stringify(a));
});