Look for Stalwart's capability where Stalwart advertises it

Self-service credentials, the About page and Files all keyed off
`urn:stalwart:jmap`, and all three looked for it in the session-level
`capabilities`. Stalwart has never put it there. `Session::new` builds that
list from a fixed set the capability is not part of, in any 0.16.x from
0.16.0 to 0.16.19; it is handed out per-account instead, so it arrives in
`primaryAccounts` and in each account's `accountCapabilities`.

So every real 0.16 server read as pre-0.16. Password changes, 2FA and app
passwords fell back to `POST /api/account/auth`, which 0.16 removed, and
reported that the server offers no self-service credential management. About
named the wrong generation. Files ran the pre-0.16 path, omitting `nodeType`
and listing the tree through get.

Look in all three places, on both sides. Two nearby soft spots go with it: a
transport error while probing the registry no longer downgrades a server to
the legacy path -- which would have posted the current password to an
endpoint that is not there -- and a locale request that is merely refused no
longer discards a generation the capability had already settled.

The mock advertised the capability in the session, which is why no test ever
caught this; it now advertises it where the real server does, and validates
`using` by the urn rather than by the session, as Stalwart does. Put the old
lookup back and nine tests fail.

Stalwart still publishes no version number to clients -- VERSION_PUBLIC is a
fixed "1.0.0" -- so About continues to report the generation and edition,
which are now the right ones.
This commit is contained in:
2026-08-24 22:21:26 -07:00
parent 03b5a6c388
commit 14125a0799
9 changed files with 203 additions and 28 deletions
+24
View File
@@ -17,6 +17,20 @@ function session(caps: string[]): JmapSession {
const NEW_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode", "urn:stalwart:jmap"];
const OLD_SERVER = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:filenode"];
/**
* The session a real Stalwart 0.16 sends: `urn:stalwart:jmap` is handed out
* per-account and never appears in the session-level capabilities, so a client
* that only checks there drops every 0.16 server onto the older code path.
*/
function realStalwartSession(): JmapSession {
return {
capabilities: Object.fromEntries(OLD_SERVER.map((c) => [c, {}])),
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} } } },
primaryAccounts: { "urn:stalwart:jmap": "a1" },
state: "s",
} as unknown as JmapSession;
}
afterEach(() => {
client.session = null;
});
@@ -37,6 +51,16 @@ describe("on Stalwart 0.16 and newer", () => {
});
});
describe("on a real 0.16 session, which advertises per-account only", () => {
it("is recognised as 0.16 even though the session capabilities do not say so", () => {
client.session = realStalwartSession();
expect(client.hasCapability("urn:stalwart:jmap")).toBe(false);
expect(supportsNodeType()).toBe(true);
expect(queryOmitsDirectories()).toBe(false);
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
});
});
describe("on Stalwart before 0.16", () => {
it("never mentions nodeType, in creates or in requested properties", () => {
client.session = session(OLD_SERVER);