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.
162 lines
7.3 KiB
TypeScript
162 lines
7.3 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { client } from "@/jmap/client";
|
|
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode";
|
|
import type { FileNode, JmapSession } from "@/jmap/types";
|
|
|
|
/**
|
|
* `nodeType` arrived in Stalwart 0.16. Sending it to an older server fails the
|
|
* whole create with `invalidProperties (nodeType)` — which is what uploading a
|
|
* file or making a folder hit on the live 0.15.5 box. Those servers tell a file
|
|
* from a directory by whether it carries file properties at all.
|
|
*/
|
|
|
|
function session(caps: string[]): JmapSession {
|
|
return { capabilities: Object.fromEntries(caps.map((c) => [c, {}])), accounts: {}, primaryAccounts: {}, state: "s" } as unknown as 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;
|
|
});
|
|
|
|
describe("on Stalwart 0.16 and newer", () => {
|
|
it("uses nodeType everywhere", () => {
|
|
client.session = session(NEW_SERVER);
|
|
expect(supportsNodeType()).toBe(true);
|
|
expect(fileNodeProps()).toContain("nodeType");
|
|
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail", nodeType: "directory" });
|
|
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png", nodeType: "file" });
|
|
});
|
|
|
|
it("leaves what the server reported alone", () => {
|
|
client.session = session(NEW_SERVER);
|
|
const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial<FileNode>[];
|
|
expect(normalizeFileNodes(nodes)).toEqual(nodes);
|
|
});
|
|
});
|
|
|
|
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);
|
|
expect(supportsNodeType()).toBe(false);
|
|
expect(fileNodeProps()).not.toContain("nodeType");
|
|
expect(directoryCreate(null, "ihasmail")).toEqual({ parentId: null, name: "ihasmail" });
|
|
expect(JSON.stringify(fileCreate("d1", "logo.png", "b1", "image/png"))).not.toContain("nodeType");
|
|
});
|
|
|
|
it("keeps a directory free of file properties, which is what makes it one", () => {
|
|
client.session = session(OLD_SERVER);
|
|
const dir = directoryCreate(null, "ihasmail");
|
|
// Setting blobId, size or type — even to null — would make this a file.
|
|
expect(dir).not.toHaveProperty("blobId");
|
|
expect(dir).not.toHaveProperty("size");
|
|
expect(dir).not.toHaveProperty("type");
|
|
});
|
|
|
|
it("still sends what a file needs", () => {
|
|
client.session = session(OLD_SERVER);
|
|
expect(fileCreate("d1", "logo.png", "b1", "image/png")).toEqual({ parentId: "d1", name: "logo.png", blobId: "b1", type: "image/png" });
|
|
});
|
|
|
|
it("works out nodeType from the file properties, so folders stay folders", () => {
|
|
client.session = session(OLD_SERVER);
|
|
const out = normalizeFileNodes([
|
|
{ id: "1", name: "Documents", blobId: null, size: null, type: null },
|
|
{ id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" },
|
|
{ id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null },
|
|
] as Partial<FileNode>[]);
|
|
expect(out.map((n) => n.nodeType)).toEqual(["directory", "file", "file"]);
|
|
});
|
|
|
|
it("does not overwrite a nodeType that did come back", () => {
|
|
client.session = session(OLD_SERVER);
|
|
const out = normalizeFileNodes([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial<FileNode>[]);
|
|
expect(out[0]!.nodeType).toBe("symlink");
|
|
});
|
|
});
|
|
|
|
it("assumes the older shape when there is no session yet", () => {
|
|
client.session = null;
|
|
expect(supportsNodeType()).toBe(false);
|
|
});
|
|
|
|
/**
|
|
* Rights were split up in 0.16. Before that a node carried mayRead / mayWrite /
|
|
* mayShare, with mayWrite covering everything the newer release names
|
|
* separately — so Rename and Delete sat permanently greyed out, doing nothing
|
|
* and saying nothing.
|
|
*/
|
|
describe("rights on a pre-0.16 server", () => {
|
|
const oldRights = (mayWrite: boolean) => ({ mayRead: true, mayWrite, mayShare: false });
|
|
|
|
it("widens mayWrite into the rights the UI gates on", () => {
|
|
client.session = session(OLD_SERVER);
|
|
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(true) }] as unknown as Partial<FileNode>[]);
|
|
expect(node!.myRights).toMatchObject({ mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false });
|
|
});
|
|
|
|
it("does not hand out rights the server withheld", () => {
|
|
client.session = session(OLD_SERVER);
|
|
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(false) }] as unknown as Partial<FileNode>[]);
|
|
expect(node!.myRights).toMatchObject({ mayRename: false, mayDelete: false, mayModifyContent: false });
|
|
});
|
|
|
|
it("leaves rights that already use the newer names untouched", () => {
|
|
client.session = session(OLD_SERVER);
|
|
const newer = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: false, mayModifyContent: true, mayShare: true };
|
|
const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: newer }] as unknown as Partial<FileNode>[]);
|
|
expect(node!.myRights).toEqual(newer);
|
|
});
|
|
|
|
it("copes with a node that reported no rights at all", () => {
|
|
client.session = session(OLD_SERVER);
|
|
const [node] = normalizeFileNodes([{ id: "1", name: "x" }] as Partial<FileNode>[]);
|
|
expect(node!.myRights).toBeUndefined();
|
|
expect(node!.nodeType).toBe("directory");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Before 0.16, FileNode/query masks its results with `document_ids(false)` —
|
|
* only resources that are *not* containers. It therefore returns files and
|
|
* never folders, with no error to explain the omission: a folder created there
|
|
* exists but never comes back in a listing. FileNode/get carries no such mask.
|
|
*/
|
|
describe("directory-blind query", () => {
|
|
it("is worked around on older servers", () => {
|
|
client.session = session(OLD_SERVER);
|
|
expect(queryOmitsDirectories()).toBe(true);
|
|
});
|
|
|
|
it("is not worked around where query can see folders", () => {
|
|
client.session = session(NEW_SERVER);
|
|
expect(queryOmitsDirectories()).toBe(false);
|
|
});
|
|
});
|