Account switcher: more than one account signed in at once
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Someone who looks after several mailboxes of their own can now keep them all signed in in one browser and move between them from the account menu, without signing out (multi-account spec, MA-B; forum topic 75). How it holds them. The session cookie is unchanged: it is the account in front, and every request is answered with it, so nothing else in the server changes. The others ride in a second cookie, <name>_more, as a list of their own session cookies. Each session stays its own -- sealed credential, expiry and "this is my device" -- and nothing about one is read through another. At most 5 in all, all on this mail server. - Add account (account menu): with sign-in on the mail server's page it goes there with prompt=login, so the server asks again rather than reuse the first sign-in; with the password form, a small dialog asks. Refused, and the front stays, when either account's organization has addAccounts off (inbuxa:SharingPolicy), the account is on another server, or 5 are open. The same account again just comes to the front. - Switching (POST /api/auth/accounts/<id>/front) swaps it into front; the web app clears what it cached for the previous account and reloads. A message being written blocks the switch. - Sign out ends only the account in front, and the next one comes forward; Sign out of all accounts ends every one. - GET /api/auth/accounts lists them, the front first, and says whether one more may be added. Not in this change: unread counts and notifications for the accounts not in front (MA-8), which the spec puts last. The mock can sign in a second user (MOCK_SECOND_USER/PASS) and answer addAccounts false (MOCK_NO_ADD_ACCOUNTS), for the new server tests: two accounts joining, switching, the same account twice, a switch to a session it doesn't hold, signing out of one and of all, and an organization that forbids it; and the OAuth start asking prompt=login. The client tests cover listing, switching (cache cleared, reload) and both sign-outs. Checked in Chrome against the mock: add, switch, sign out of one. New strings (9, English only in the other ten catalogs): "Add account", "Sign out of all accounts", "Add an account", "Both accounts stay signed in here; switch between them from this menu.", "Working…", "That account couldn't be added.", "You can't add more accounts here.", and the other-server and organization refusals. typecheck, tests (web 1538, server 276) and build pass.
This commit is contained in:
1 parent
9f53759462
commit
34baca4365
10 files changed
+716
-9
No files matched your search
@@ -0,0 +1,141 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user