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:
+17
-1
@@ -123,6 +123,20 @@ export class JmapClient {
|
||||
return Boolean(acc && cap in acc.accountCapabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server carries a capability at all, wherever it chose to
|
||||
* advertise it.
|
||||
*
|
||||
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it
|
||||
* in the session-level `capabilities`, so `hasCapability` alone reports every
|
||||
* real 0.16 server as though it were older. Look in all three places.
|
||||
*/
|
||||
hasCapabilityAnywhere(cap: string): boolean {
|
||||
if (this.hasCapability(cap)) return true;
|
||||
if (this.session?.primaryAccounts && cap in this.session.primaryAccounts) return true;
|
||||
return Object.values(this.session?.accounts ?? {}).some((a) => cap in (a.accountCapabilities ?? {}));
|
||||
}
|
||||
|
||||
primaryAccount(cap: string): Id | null {
|
||||
return this.session?.primaryAccounts[cap] ?? null;
|
||||
}
|
||||
@@ -210,7 +224,9 @@ export class JmapClient {
|
||||
*/
|
||||
private supportedUsing(using: string[]): string[] {
|
||||
if (!this.session?.capabilities) return using;
|
||||
return using.filter((u) => u === CAP.core || this.hasCapability(u));
|
||||
// Anywhere counts: a capability advertised per-account is one the server
|
||||
// has, and Stalwart advertises its own that way and no other.
|
||||
return using.filter((u) => u === CAP.core || this.hasCapabilityAnywhere(u));
|
||||
}
|
||||
|
||||
/** Low-level request: send invocations verbatim, return raw response. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
*
|
||||
* 0.16 is also the first release to advertise `urn:stalwart:jmap`, and no
|
||||
* earlier one knows that capability, so its presence is a reliable stand-in for
|
||||
* "this server has the newer FileNode shape".
|
||||
* "this server has the newer FileNode shape" — as long as it is looked for in
|
||||
* `primaryAccounts` and `accountCapabilities`, which is where Stalwart puts it,
|
||||
* and not only in the session-level `capabilities`, where it never appears.
|
||||
*/
|
||||
import { client } from "@/jmap/client";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
@@ -19,7 +21,10 @@ import type { FileNode, Id } from "@/jmap/types";
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
|
||||
export function supportsNodeType(): boolean {
|
||||
return client.hasCapability(STALWART_CAP);
|
||||
// Not `hasCapability`: Stalwart advertises this per-account, never in the
|
||||
// session-level capabilities, so looking only there treats every real 0.16
|
||||
// server as pre-0.16 and drops Files onto the older code path.
|
||||
return client.hasCapabilityAnywhere(STALWART_CAP);
|
||||
}
|
||||
|
||||
const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
|
||||
|
||||
Reference in New Issue
Block a user