Files
ihasmail-inbuxa/server/src/accountinfo.test.ts
T
jcoffey-dev 94bf42cfda Drop Stalwart 0.15 support
ihasmail spoke to two generations of Stalwart that are less alike than
their version numbers suggest: 0.16 replaced the REST management API with
JMAP registry objects, changed the shape of FileNode, split its rights
up, and moved configuration into the store. Carrying both meant 34 branch
points across nine files, a 92-line compatibility shim whose only job was
telling them apart, a parallel REST implementation of every credential
operation, and a mock that had to model both.

The branches were not the real cost. The cost was that a wrong answer
about which generation had answered always had somewhere to fall back to,
so it failed quietly rather than loudly: one capability looked for in the
wrong place downgraded every real 0.16 server onto the 0.15 path, which
posted the current password to an endpoint 0.16 had removed, reported the
wrong generation on About, and ran Files on the older code. It reached
production and was recorded as verified when it was not. The mock
mirrored the same wrong placement, which is why the tests agreed.

Removed: the filenode compatibility shim, the dual "registry" | "legacy"
backend in account.ts, the pre-0.16 generation in AccountInfo and
everything that read it, the mock's LEGACY mode and dev:mock:legacy, and
the three test files that existed only to pin 0.15 behaviour.

Sign-in now refuses an older server by name, once, rather than letting
Files, the account locale and credentials each fail in their own way with
nothing connecting them. It says the credentials were fine -- someone
hitting this has typed a correct password, and telling them otherwise
sends them round in circles -- and names the tag to build from. Four
tests cover it, including that no session cookie is minted and that bad
credentials on such a server are still a plain 401.

Two fallbacks went that were not strictly about 0.15, and both for the
same reason the removal is happening. Files no longer answers a refused
filter or sort by fetching every node in the account, which would hide a
real fault behind a performance cliff nobody would notice. And the app
folder lookups now filter on parentId/isTopLevel alone and match names
client-side, since `name` is not a filter Stalwart is known to implement
and one it does not know fails the whole query rather than being ignored.

The last release that runs on 0.15 is tagged stalwart-0.15-support.

Verified against the mock end to end: sign-in, the Files tree on the 0.16
path with the app folder hidden, and self-service credentials over the
registry. 226 web + 75 server tests pass; typecheck and build clean.
2026-08-26 09:51:03 -07:00

113 lines
5.1 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./upstream.js";
/**
* The account locale used to be read only from `x:Account/get`, which needs
* the `sysAccountGet` permission — one the built-in `user` role is not given.
* Ordinary users therefore silently fell back to the browser locale. Stalwart
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
* so both are asked for and whichever answers wins. Both are 0.16 methods:
* this is a permissions fallback, not a version one.
*/
type Responses = [string, Record<string, unknown>, string][];
const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"];
const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"];
const failed = (id: string, type: string): Responses[number] => ["error", { type }, id];
test("prefers the locale a regular user is allowed to read", () => {
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
assert.equal(info.locale, "de-DE");
});
test("falls back to x:Account when the settings object is forbidden", () => {
const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]);
assert.equal(info.locale, "sr-Latn-RS");
});
test("an account with no locale set yields none, rather than a guess", () => {
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
assert.equal(info.locale, null);
});
test("neither answering leaves the locale unknown", () => {
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
});
test("locales that carry no language are dropped, not passed through", () => {
assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null);
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
});
test("a server without the registry is not asked for anything", async () => {
// Sign-in refuses these, so getAccountInfo should never reach the wire for
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
// fails the whole request rather than the one call.
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
assert.deepEqual(info, { locale: null, edition: null });
});
test("no capabilities at all is treated the same way", async () => {
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
assert.equal(info.locale, null);
});
/**
* Where Stalwart actually advertises `urn:stalwart:jmap`.
*
* Not in the session-level `capabilities`: `Session::new` builds those from a
* fixed list that has never carried this capability, in any 0.16.x. It is
* handed out per-account instead, so it lands in `primaryAccounts` and in each
* account's `accountCapabilities`. Looking only at the session level called
* every real 0.16 server too old, which sent self-service credentials to a
* REST endpoint 0.16 had removed and made the About page report the wrong
* thing.
*
* This check now decides whether a sign-in is allowed at all, so getting it
* wrong would lock every user out of a perfectly good server.
*/
const STALWART = "urn:stalwart:jmap";
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
assert.equal(
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
true,
);
});
test("a 0.16 server is recognised from an account's capabilities", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:mail": {}, [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});
test("the session level still counts, for a server that ever advertises it there", () => {
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
});
test("a server that advertises it nowhere is one we do not support", () => {
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
assert.equal(hasStalwartRegistry(undefined), false);
});
test("a shared account carrying the capability is enough to recognise the server", () => {
assert.equal(
hasStalwartRegistry({
capabilities: baseCaps,
accounts: { a1: { accountCapabilities: baseCaps }, a2: { accountCapabilities: { [STALWART]: {} } } },
primaryAccounts: {},
}),
true,
);
});