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

`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 21:19:24 +00:00
1 parent 9bd7e8f099
commit 6f8f85fff5
10 files changed
+768 -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",
]);
});
});
+265 -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,83 @@ 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,
);
},
);
it("refuses an empty QUAK_SERVER before asking for anything", async () => {
vi.stubEnv("QUAK_SERVER", "");
const ctx = loginContext(async () => {
throw new Error("login must not be called");
});
expect(await loginCommand(ctx, {})).toBe(1);
expect(prompts).toEqual([]);
expect(stderr.text).toBe(
`Invalid server address "": 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 +534,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 +1701,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 +1826,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 +2003,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 +2154,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