quak login --server: log in to a self-hosted Ente server (closes #243)
check / check (push) Successful in 2m56s

`quak login` takes the server's API address from `--server` or
`QUAK_SERVER`. `isServerAddress` refuses anything but an http or https
URL with no query or fragment, there and in `Client.fromJSON`. The
client's snapshot keeps a non-default `apiOrigin`, so the session file
names the server and every later command uses it.

Two servers can give two accounts, and two files, the same ID. A
self-hosted account's default cache directory therefore names the
server, and `metadata.json` records it: `Library.open` deletes a cache
from another server whole. `originalOf` and `thumbnailOf` record the
account in a cache that names none before storing a file, and `open`
treats a cache with no collection as empty. This fixes
#254 (closes #254).

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-13 20:34:05 +00:00
1 parent 9bd7e8f099
commit 94283522e9
10 files changed
+700 -68

No files matched your search

+35 -3
View File
@@ -2,12 +2,14 @@
* Tests for the retry options `bin/quak.ts` loads the saved session with
* (`quak backup` gets the unattended ones, every other command the default),
* each with the callbacks that log its requests and retries, for `quak files`
* taking its collection ID as an optional argument, and for `quak get` and
* `quak get-thumb` refusing `--collection`.
* taking its collection ID as an optional argument, for `quak get` and
* `quak get-thumb` refusing `--collection`, and for `quak login` taking
* `--server` and sending its first request to the server it names.
*
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session,
* so the command stops before it makes any request.
* so the command stops before it makes any request. `quak login` reads no
* session, so its test stubs `fetch` instead and records each request.
*
* The first load of the sources `bin/quak.ts` imports compiles them, and the
* first `init()` starts libsodium. On a loaded host that takes longer than a
@@ -58,6 +60,8 @@ afterEach(() => {
process.argv = argv;
loaded.length = 0;
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
afterAll(() => {
@@ -117,3 +121,31 @@ describe("bin/quak.ts get and get-thumb", () => {
},
);
});
describe("bin/quak.ts login", () => {
// The command refuses an address that is not a URL before it asks for
// anything or sends any request.
it("hands --server to the command", async () => {
expect(await quak("login", "--server", "ente.example.com")).toMatch(
/^Invalid server address "ente\.example\.com"/,
);
});
// `fetch` answers 404, so the login fails at its first request. A login
// that succeeded would save its session in this user's real data
// directory, since env-paths is not mocked here.
it("sends its first request to the server --server names", async () => {
const requests: string[] = [];
vi.stubGlobal("fetch", async (input: string) => {
const { origin, pathname } = new URL(input);
requests.push(origin + pathname);
return new Response("not found", { status: 404 });
});
vi.stubEnv("QUAK_EMAIL", "login@example.com");
vi.stubEnv("QUAK_PASSWORD", "password");
await quak("login", "--server", "https://ente.example.com");
expect(requests).toEqual([
"https://ente.example.com/users/srp/attributes",
]);
});
});
+250 -17
View File
@@ -328,6 +328,7 @@ describe("login", () => {
prompts = [];
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
vi.stubEnv("QUAK_PASSWORD", "hunter2");
vi.stubEnv("QUAK_SERVER", undefined);
});
afterEach(() => {
@@ -340,12 +341,14 @@ describe("login", () => {
calls.push(opts);
return loggedIn;
});
expect(await loginCommand(ctx)).toBe(0);
expect(await loginCommand(ctx, {})).toBe(0);
expect(prompts).toEqual([]);
expect(calls).toHaveLength(1);
expect(calls[0]!.email).toBe("cli@example.com");
expect(calls[0]!.password).toBe("hunter2");
// No server named: the client uses Ente's hosted service.
expect(calls[0]!.apiOptions).toBeUndefined();
const path = join(ctx.sessionDir, "session.json");
expect(stderr.text).toBe(
"Authenticating...\n" +
@@ -356,7 +359,7 @@ describe("login", () => {
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
const ctx = loginContext(async () => loggedIn);
expect(await loginCommand(ctx)).toBe(0);
expect(await loginCommand(ctx, {})).toBe(0);
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
const path = join(ctx.sessionDir, "session.json");
@@ -370,7 +373,7 @@ describe("login", () => {
code = await opts.totp!();
return loggedIn;
});
expect(await loginCommand(ctx)).toBe(0);
expect(await loginCommand(ctx, {})).toBe(0);
expect(prompts).toEqual(["TOTP code: "]);
expect(code).toBe("123456");
@@ -380,13 +383,68 @@ describe("login", () => {
const ctx = loginContext(async () => {
throw new Error("HTTP 401 from server");
});
expect(await loginCommand(ctx)).toBe(1);
expect(await loginCommand(ctx, {})).toBe(1);
expect(stderr.text).toBe(
"Authenticating...\nLogin failed: HTTP 401 from server\n",
);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
});
it("logs in to the server --server names, ahead of QUAK_SERVER", async () => {
vi.stubEnv("QUAK_SERVER", "https://other.example.com");
const calls: LoginOptions[] = [];
const ctx = loginContext(async (opts) => {
calls.push(opts);
return loggedIn;
});
expect(
await loginCommand(ctx, { server: "https://ente.example.com" }),
).toBe(0);
expect(calls[0]!.apiOptions).toEqual({
apiOrigin: "https://ente.example.com",
});
});
it("logs in to the server QUAK_SERVER names without --server", async () => {
vi.stubEnv("QUAK_SERVER", "http://192.0.2.1:8080/ente");
const calls: LoginOptions[] = [];
const ctx = loginContext(async (opts) => {
calls.push(opts);
return loggedIn;
});
expect(await loginCommand(ctx, {})).toBe(0);
expect(calls[0]!.apiOptions).toEqual({
apiOrigin: "http://192.0.2.1:8080/ente",
});
});
it.each([
"",
"ente.example.com",
"ftp://ente.example.com",
"https://ente.example.com/?region=eu",
"https://ente.example.com/#top",
])(
"refuses the server address %j before asking for anything",
async (server) => {
const ctx = loginContext(async () => {
throw new Error("login must not be called");
});
expect(await loginCommand(ctx, { server })).toBe(1);
expect(prompts).toEqual([]);
expect(stderr.text).toBe(
`Invalid server address "${server}": it must be an http or ` +
`https URL with no query or fragment\n`,
);
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(
false,
);
},
);
});
// These use a real client read from the session file, over a fake API that
@@ -461,6 +519,16 @@ describe("logout", () => {
);
});
it("names a self-hosted account's default cache directory, under its server's address", async () => {
const ctx = { ...logoutContext(200), cacheDir: undefined };
const apiOrigin = "https://ente.example.com";
saveSession(ctx.sessionDir, { ...snapshot, apiOrigin });
expect(await logoutCommand(ctx)).toBe(0);
expect(stderr.text).toContain(
`Cache directory ${defaultCacheDirectory(USER_ID, apiOrigin)} still holds decrypted data`,
);
});
it("without a session says so, calls nothing and exits 0", async () => {
expect(await logoutCommand(logoutContext(200))).toBe(0);
expect(requests).toHaveLength(0);
@@ -1618,18 +1686,19 @@ describe("an account with many albums", () => {
// encrypted as the server stores it. The original's bytes are the title.
// These tests fetch no thumbnail. The server records a thumbnail size, so
// `helper fix-missing-thumbnails` downloads the original before it skips
// the photo as not a JPEG.
const photoRow = (photo: {
id: number;
collectionID: number;
title: string;
updationTime: number;
}): { row: Record<string, unknown>; original: Uint8Array } => {
// the photo as not a JPEG. `albumKey` is that album's key, by default the
// one in `albumKeys`.
const photoRow = (
photo: {
id: number;
collectionID: number;
title: string;
updationTime: number;
},
albumKey = albumKeys.get(photo.collectionID)!,
): { row: Record<string, unknown>; original: Uint8Array } => {
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(
photoKey,
albumKeys.get(photo.collectionID)!,
);
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
@@ -1742,6 +1811,77 @@ describe("an account with many albums", () => {
};
};
// The albums in the cache's `metadata.json`. A search on an empty cache
// stores none there, only the account whose original or thumbnail it
// stored.
const cachedAlbums = (): unknown[] =>
JSON.parse(readFileSync(join(root, "cache", "metadata.json"), "utf-8"))
.collections;
// A context whose login and saved session send each request to the server
// its host names: `ente.example.com` is the fake server above, and
// `photos.example.org` a second one. Every account the fake server logs in
// has user ID 42, so the second server's account has the first one's user
// ID. Its one album holds photo 200, `other-200.jpg`; the first server
// gives ID 200 to `photo-2.jpg`.
const twoServerContext = async (): Promise<CliContext> => {
const account = await buildAccount(PASSWORD);
const albumKey = sodium.crypto_secretbox_keygen();
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
const name = encryptSecretbox(
new TextEncoder().encode("Other album"),
albumKey,
);
const { row, original } = photoRow(
{
id: 200,
collectionID: 1,
title: "other-200.jpg",
updationTime: 1,
},
albumKey,
);
const other = buildMetaFetch({
verifier: account.verifier,
srpAttributes: account.srpAttributes,
keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken,
collections: [
{
id: 1,
owner: { id: USER_ID },
encryptedKey: toBase64(albumKeyBox.ciphertext),
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
encryptedName: toBase64(name.ciphertext),
nameDecryptionNonce: toBase64(name.nonce),
type: "album",
updationTime: 1,
},
],
filesByCollection: { 1: [row] },
encryptedMLData: {},
fileCiphertexts: { 200: original },
});
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const { host } = new URL(
input instanceof Request ? input.url : input,
);
return (host === "photos.example.org" ? other : server)(
input,
init,
);
}) as typeof globalThis.fetch;
return {
...context(),
login: (opts) =>
Client.login({
...opts,
apiOptions: { ...opts.apiOptions, fetch },
}),
loadSession: (path) => loadSession(path, { fetch }),
};
};
it("collections lists every album from one request, fetching no album's files", async () => {
const ctx = accountContext();
expect(await collectionsCommand(ctx, {})).toBe(0);
@@ -1848,7 +1988,100 @@ describe("an account with many albums", () => {
"/collections/v2/diff?collectionID=2&sinceTime=0",
"/?fileID=200",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
expect(cachedAlbums()).toEqual([]);
});
// A self-hosted server serves downloads under its own address, at
// `/files/download/<id>`, so no request goes to Ente's hosted service.
it("login --server saves the server, and a later get sends every request to it", async () => {
vi.stubEnv("QUAK_EMAIL", "many@example.com");
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
vi.stubEnv("QUAK_SERVER", undefined);
const hosts = new Set<string>();
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
hosts.add(
new URL(input instanceof Request ? input.url : input).host,
);
return server(input, init);
}) as typeof globalThis.fetch;
const ctx: CliContext = {
...context(),
login: (opts) =>
Client.login({
...opts,
apiOptions: { ...opts.apiOptions, fetch },
}),
loadSession: (path) => loadSession(path, { fetch }),
};
const out = join(root, "photo.jpg");
try {
const opts = { server: "https://ente.example.com" };
expect(await loginCommand(ctx, opts)).toBe(0);
requests.length = 0;
expect(await getCommand(ctx, "200", { out })).toBe(0);
} finally {
vi.unstubAllEnvs();
}
const path = join(ctx.sessionDir, "session.json");
expect(JSON.parse(readFileSync(path, "utf-8")).apiOrigin).toBe(
"https://ente.example.com",
);
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
expect(requests.at(-1)).toBe("/files/download/200");
expect([...hosts]).toEqual(["ente.example.com"]);
});
// The two servers' accounts share the cache directory `--cache-dir` names.
it("after a login to another server whose account has the same user ID, files and get see none of the first server's albums and originals", async () => {
const ctx = await twoServerContext();
const first = join(root, "first.jpg");
const second = join(root, "second.jpg");
vi.stubEnv("QUAK_EMAIL", "many@example.com");
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
vi.stubEnv("QUAK_SERVER", undefined);
try {
const opts = { server: "https://ente.example.com" };
expect(await loginCommand(ctx, opts)).toBe(0);
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(await getCommand(ctx, "200", { out: first })).toBe(0);
const otherOpts = { server: "https://photos.example.org" };
expect(await loginCommand(ctx, otherOpts)).toBe(0);
stdout.text = "";
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(await getCommand(ctx, "200", { out: second })).toBe(0);
} finally {
vi.unstubAllEnvs();
}
expect(stdout.text).toBe("200\timage\tother-200.jpg\n");
expect(readFileSync(first, "utf-8")).toBe("photo-2.jpg");
expect(readFileSync(second, "utf-8")).toBe("other-200.jpg");
});
// `get` on an empty cache stores the original but no album.
it("get on an empty cache, after the same on another server whose account has the same user ID, saves this server's photo", async () => {
const ctx = await twoServerContext();
const first = join(root, "first.jpg");
const second = join(root, "second.jpg");
vi.stubEnv("QUAK_EMAIL", "many@example.com");
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
vi.stubEnv("QUAK_SERVER", undefined);
try {
const opts = { server: "https://ente.example.com" };
expect(await loginCommand(ctx, opts)).toBe(0);
expect(await getCommand(ctx, "200", { out: first })).toBe(0);
const otherOpts = { server: "https://photos.example.org" };
expect(await loginCommand(ctx, otherOpts)).toBe(0);
expect(await getCommand(ctx, "200", { out: second })).toBe(0);
} finally {
vi.unstubAllEnvs();
}
expect(readFileSync(first, "utf-8")).toBe("photo-2.jpg");
expect(readFileSync(second, "utf-8")).toBe("other-200.jpg");
});
// `files` first fills the cache, so `get` waits for a refresh. No album has
@@ -1906,7 +2139,7 @@ describe("an account with many albums", () => {
"/collections/v2/diff?collectionID=39&sinceTime=0",
"/?fileID=3900",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
expect(cachedAlbums()).toEqual([]);
});
// `files` first fills the cache, so the helper waits for a refresh. No
+42
View File
@@ -47,6 +47,20 @@ describe("Client.toJSON", () => {
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
});
it("round-trips a snapshot that names a server", () => {
const snapshot = {
...validSnapshot(),
apiOrigin: "https://ente.example.com",
};
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
});
it("names the server apiOptions gave fromJSON", () => {
const apiOrigin = "https://ente.example.com";
const client = Client.fromJSON(validSnapshot(), { apiOrigin });
expect(client.toJSON().apiOrigin).toBe(apiOrigin);
});
it("throws instead of emitting a snapshot without a token", () => {
const client = Client.fromJSON(validSnapshot());
client.getApiClient().clearAuthToken();
@@ -77,6 +91,10 @@ describe("Client.fromJSON", () => {
["publicKey", undefined],
["publicKey", "not base64!"],
["publicKey", shortKey],
["apiOrigin", 7],
["apiOrigin", ""],
["apiOrigin", "ente.example.com"],
["apiOrigin", "ftp://ente.example.com"],
])("rejects %s = %j, naming the field", (field, value) => {
const snapshot: Record<string, unknown> = { ...validSnapshot() };
snapshot[field] = value;
@@ -85,6 +103,30 @@ describe("Client.fromJSON", () => {
);
});
// The token belongs to the snapshot's server, so that server wins over
// the one in `apiOptions`.
it("sends requests to the snapshot's server", async () => {
const urls: string[] = [];
const client = Client.fromJSON(
{ ...validSnapshot(), apiOrigin: "https://ente.example.com/" },
{
apiOrigin: "https://other.example.com",
fetch: async (input) => {
urls.push(String(input));
return new Response(JSON.stringify({ collections: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
},
);
await client.listCollections();
expect(urls).toEqual([
"https://ente.example.com/collections/v2?sinceTime=0",
]);
});
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
expect(() => Client.fromJSON(value)).toThrow(
/^Invalid session data: not a JSON object/,
+180 -6
View File
@@ -19,16 +19,21 @@
* refresh fails (offline start from cache).
* 5. `close()` stops the timer and is idempotent, and its promise resolves
* only once an in-flight refresh has written the cache file.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
* 7. `open()` branches on the cache: an empty cache awaits the first refresh
* (it has nothing to serve yet); an existing cache serves its copy at once
* and refreshes in the background, so a slow or dead server never stalls
* opening.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id,
* with a self-hosted server's address before the user id.
* 7. `open()` branches on the cache: a cache that holds no collection awaits
* the first refresh (it has nothing to serve yet); an existing cache serves
* its copy at once and refreshes in the background, so a slow or dead
* server never stalls opening.
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
* set and keeps retrying the write; a later empty refresh does not clear it.
* 9. With `refreshOnOpen: false`, `open()` starts no refresh at all, and a
* refresh reports through `onProgress` each collection whose files it
* fetched.
* 10. A cache another account filled starts empty. One filled from another
* server also loses its originals and thumbnails, since two servers can
* give two files the same ID. `thumbnailOf` on an empty cache saves the
* account's user ID and server.
*
* The client is a mock: no crypto, no network. It serves scripted pages and
* records the `sinceTime` each call carried so cursor threading is provable.
@@ -49,7 +54,13 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import envPaths from "env-paths";
@@ -107,6 +118,7 @@ const file = (
*/
class MockClient {
userID = USER_ID;
apiOrigin?: string;
failCollections = false;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
@@ -584,6 +596,44 @@ describe("Library.open and background refresh", () => {
}
});
// `originalOf` on an empty cache leaves such a cache behind: it names the
// account before it stores the original.
it("awaits the first refresh on a cache that names the account but holds no collection", async () => {
const seed = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
seed.userID = USER_ID;
await seed.save();
let releaseFirstFetch: (page: CollectionsPage) => void = () => {};
const gate = new Promise<CollectionsPage>((resolve) => {
releaseFirstFetch = resolve;
});
const client = new MockClient();
client.collectionsSince = async () => gate;
let opened = false;
const openPromise = Library.open({ client, cacheDirectory }).then(
(l) => {
opened = true;
return l;
},
);
await new Promise((r) => setTimeout(r, 20));
expect(opened).toBe(false);
releaseFirstFetch({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
const lib = await openPromise;
try {
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
} finally {
await lib.close();
}
});
// A caller that asks for each refresh it needs, as the CLI's read commands
// do, opens without one: on an empty cache the refresh `open()` would
// await fetches the files of every collection, which `quak collections`
@@ -842,4 +892,128 @@ describe("Library.open and background refresh", () => {
else process.env.XDG_CACHE_HOME = prev;
}
});
it("puts a self-hosted server's address, URL-encoded, before the user id in the default cacheDirectory", async () => {
const xdg = join(dir, "xdg-cache");
const prev = process.env.XDG_CACHE_HOME;
process.env.XDG_CACHE_HOME = xdg;
try {
const client = new MockClient();
client.apiOrigin = "https://ente.example.com:8443/api";
const lib = await Library.open({ client });
try {
expect(lib.cacheDirectory).toBe(
join(
envPaths("quak", { suffix: "" }).cache,
"https%3A%2F%2Fente.example.com%3A8443%2Fapi",
String(USER_ID),
),
);
expect(lib.cacheDirectory.startsWith(xdg)).toBe(true);
} finally {
await lib.close();
}
} finally {
if (prev === undefined) delete process.env.XDG_CACHE_HOME;
else process.env.XDG_CACHE_HOME = prev;
}
});
// A cache that account USER_ID on `apiOrigin` filled: its records, and an
// original and a thumbnail of file 1001.
const fillCache = async (apiOrigin?: string): Promise<void> => {
const seed = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
seed.userID = USER_ID;
seed.apiOrigin = apiOrigin;
seed.collectionsSinceTime = 500;
seed.putCollection(collection(1, 400));
seed.putFile(file(1001, 1, 400));
await seed.save();
for (const name of ["originals", "thumbnails"]) {
mkdirSync(join(cacheDirectory, name));
writeFileSync(join(cacheDirectory, name, "1001.jpg"), "1001");
}
};
it("deletes the whole cache an account on another server filled", async () => {
await fillCache("https://ente.example.com");
const client = new MockClient();
client.apiOrigin = "https://photos.example.org";
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: 3600,
});
try {
expect(client.collectionsSinceTimes).toEqual([0]);
expect(lib.listCollections()).toEqual([]);
for (const name of ["originals", "thumbnails"]) {
expect(existsSync(join(cacheDirectory, name, "1001.jpg"))).toBe(
false,
);
}
} finally {
await lib.close();
}
});
// A server gives each file its own ID, so another account's cached file
// is never served for this account's.
it("keeps the originals and thumbnails another account on the same server cached", async () => {
await fillCache();
const client = new MockClient();
client.userID = USER_ID + 1;
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: 3600,
});
try {
expect(client.collectionsSinceTimes).toEqual([0]);
expect(lib.listCollections()).toEqual([]);
for (const name of ["originals", "thumbnails"]) {
expect(existsSync(join(cacheDirectory, name, "1001.jpg"))).toBe(
true,
);
}
} finally {
await lib.close();
}
});
// `quak get-thumb` on an empty cache stores a thumbnail before any refresh
// runs. `open()` for an account on another server deletes that thumbnail
// only if the cache names the account that stored it.
it("thumbnailOf on an empty cache saves the account's user ID and server in metadata.json", async () => {
const client = new MockClient();
client.apiOrigin = "https://ente.example.com";
const lib = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: Infinity,
contentSource: {
original: async () => {
throw new Error("original not expected");
},
thumbnail: async ({ destination }) => {
writeFileSync(destination, "thumb");
return { bytesWritten: 5 };
},
},
});
try {
await lib.thumbnailOf(file(1001, 1, 400));
} finally {
await lib.close();
}
const saved = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
expect(saved.userID).toBe(USER_ID);
expect(saved.apiOrigin).toBe("https://ente.example.com");
});
});