Client class red: literate usage tests and stub

test/client/usage.test.ts is a tutorial-as-test-suite that walks
through the entire quack API in order: login, whoami, listCollections,
listFiles, downloadFile, downloadThumbnail, toJSON/fromJSON, logout.

Each it() block is a self-contained example with prose commentary
explaining what the code does and why, with code samples showing the
API as a consumer would use it. The mock server performs real SRP and
crypto so the test data is structurally identical to production.

8 tests, all failing against the stub.
This commit is contained in:
2026-05-13 17:59:18 -07:00
parent 3b04a8134f
commit ca6857d3fe
2 changed files with 692 additions and 0 deletions

72
src/client.ts Normal file
View File

@@ -0,0 +1,72 @@
// Stub: see the README "Development workflow" section for TDD policy.
import type { ApiClientOptions } from "./api/client.js";
import type { Collection, EnteFile } from "./model/types.js";
import type { DownloadResult } from "./download/index.js";
export interface LoginOptions {
email: string;
password: string;
totp?: () => Promise<string>;
emailOTP?: () => Promise<string>;
apiOptions?: ApiClientOptions;
}
export interface ClientSnapshot {
email: string;
userID: number;
token: string;
masterKey: string;
secretKey: string;
publicKey: string;
}
export class Client {
static async login(_opts: LoginOptions): Promise<Client> {
throw new Error("Client.login not implemented");
}
static fromJSON(
_snapshot: ClientSnapshot,
_apiOptions?: ApiClientOptions,
): Client {
throw new Error("Client.fromJSON not implemented");
}
whoami(): { email: string; userID: number } {
throw new Error("Client.whoami not implemented");
}
toJSON(): ClientSnapshot {
throw new Error("Client.toJSON not implemented");
}
logout(): void {
throw new Error("Client.logout not implemented");
}
async listCollections(): Promise<Collection[]> {
throw new Error("Client.listCollections not implemented");
}
async listFiles(
_collectionID: number,
_collectionKey: Uint8Array,
): Promise<EnteFile[]> {
throw new Error("Client.listFiles not implemented");
}
async downloadFile(
_file: EnteFile,
_outPath?: string,
): Promise<DownloadResult> {
throw new Error("Client.downloadFile not implemented");
}
async downloadThumbnail(
_file: EnteFile,
_outPath?: string,
): Promise<DownloadResult> {
throw new Error("Client.downloadThumbnail not implemented");
}
}

620
test/client/usage.test.ts Normal file
View File

@@ -0,0 +1,620 @@
/**
* # Using the quack Client
*
* This test file is a tutorial. It walks through every operation the
* library supports, in the order you would use them in a real program.
* Each `it()` block is a self-contained example with commentary
* explaining what is happening and why. If you are reading the quack
* source for the first time, start here.
*
* The tests run against a mock Ente server built from the same SRP and
* crypto primitives the real server uses, so the data flowing through
* the Client is structurally identical to production data. Nothing hits
* the network.
*
*
* ## Table of contents
*
* 1. Logging in
* 2. Inspecting account info
* 3. Listing collections (albums)
* 4. Listing files in a collection
* 5. Downloading a file
* 6. Downloading a thumbnail
* 7. Serializing and restoring a session
* 8. Logging out
*
*
* ## Prerequisites
*
* All you need is the `Client` class:
*
* ```ts
* import { Client } from "quack";
* ```
*
* The Client wraps every lower-level module (crypto, auth, api, model,
* download) into a single object. You create one by logging in, and
* then call methods on it for the lifetime of your program. The session
* (auth token, master key, secret key) lives in memory on the Client
* instance. If you want to persist it across process restarts, call
* `client.toJSON()` to get a serializable snapshot, store it however
* you like, and later restore it with `Client.fromJSON(snapshot)`.
*/
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
import { SRP, SrpServer } from "fast-srp-hap";
import { beforeAll, afterAll, describe, expect, it } from "vitest";
import {
init,
toBase64,
deriveKEK,
deriveLoginSubkey,
} from "../../src/crypto/index.js";
import { Client } from "../../src/client.js";
import type { KeyAttributes } from "../../src/auth/types.js";
// ---------------------------------------------------------------------------
// Mock server
// ---------------------------------------------------------------------------
//
// Everything below this line builds a fake Ente backend that the Client
// talks to via an injected `fetch`. You would not write any of this in a
// real program; it exists only so the tests are self-contained.
const TEST_EMAIL = "user@example.com";
const TEST_PASSWORD = "hunter2";
const TEST_OPS = 2;
const TEST_MEM = 64 * 1024 * 1024;
interface ServerState {
srpAttributes: {
srpUserID: string;
srpSalt: string;
memLimit: number;
opsLimit: number;
kekSalt: string;
isEmailMFAEnabled: boolean;
};
verifier: Buffer;
keyAttributes: KeyAttributes;
encryptedToken: string;
masterKey: Uint8Array;
photoPlaintext: Uint8Array;
photoKey: Uint8Array;
photoHeader: Uint8Array;
photoCiphertext: Uint8Array;
thumbPlaintext: Uint8Array;
thumbHeader: Uint8Array;
thumbCiphertext: Uint8Array;
collectionKey: Uint8Array;
}
let server: ServerState;
let testDir: string;
const buildServer = async (): Promise<ServerState> => {
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM);
const loginSubKeyBytes = deriveLoginSubkey(kek);
const srpUserID = "mock-srp-user";
const srpSalt = sodium.randombytes_buf(16);
const verifier = SRP.computeVerifier(
SRP.params["4096"],
Buffer.from(srpSalt),
Buffer.from(srpUserID),
Buffer.from(loginSubKeyBytes),
);
// Master key, keypair, token
const masterKey = sodium.randombytes_buf(32);
const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek);
const kp = sodium.crypto_box_keypair();
const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encryptedSecretKey = sodium.crypto_secretbox_easy(
kp.privateKey,
skNonce,
masterKey,
);
const tokenBytes = sodium.randombytes_buf(32);
const encryptedToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
const keyAttributes: KeyAttributes = {
kekSalt: toBase64(kekSalt),
encryptedKey: toBase64(encryptedKey),
keyDecryptionNonce: toBase64(keyNonce),
publicKey: toBase64(kp.publicKey),
encryptedSecretKey: toBase64(encryptedSecretKey),
secretKeyDecryptionNonce: toBase64(skNonce),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
};
// A collection with one photo
const collectionKey = sodium.crypto_secretbox_keygen();
const ckNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encCollKey = sodium.crypto_secretbox_easy(
collectionKey,
ckNonce,
masterKey,
);
const collName = new TextEncoder().encode("Vacation");
const cnNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encCollName = sodium.crypto_secretbox_easy(
collName,
cnNonce,
collectionKey,
);
// A file inside that collection
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const fkNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const encFileKey = sodium.crypto_secretbox_easy(
photoKey,
fkNonce,
collectionKey,
);
const metadata = JSON.stringify({
title: "beach.jpg",
fileType: 0,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
latitude: 35.6762,
longitude: 139.6503,
});
const metaPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
metaPush.state,
new TextEncoder().encode(metadata),
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Encrypted file body (a small fake JPEG)
const photoPlaintext = sodium.randombytes_buf(5000);
const filePush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
const photoCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
filePush.state,
photoPlaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Encrypted thumbnail
const thumbPlaintext = sodium.randombytes_buf(800);
const thumbPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(photoKey);
const thumbCiphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
thumbPush.state,
thumbPlaintext,
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
);
// Wire up the mock fetch responses
const rawCollection = {
id: 1,
owner: { id: 42 },
encryptedKey: toBase64(encCollKey),
keyDecryptionNonce: toBase64(ckNonce),
encryptedName: toBase64(encCollName),
nameDecryptionNonce: toBase64(cnNonce),
type: "album",
updationTime: 1700000000000000,
};
const rawFile = {
id: 100,
collectionID: 1,
ownerID: 42,
encryptedKey: toBase64(encFileKey),
keyDecryptionNonce: toBase64(fkNonce),
metadata: {
encryptedData: toBase64(encMeta),
decryptionHeader: toBase64(metaPush.header),
},
file: { decryptionHeader: toBase64(filePush.header) },
thumbnail: { decryptionHeader: toBase64(thumbPush.header) },
updationTime: 1700000000000000,
};
// Store the raw JSON responses on the server state so the mock fetch
// can return them. This is ugly plumbing; in a real program you
// never see any of it.
(globalThis as Record<string, unknown>).__mockRawCollection = rawCollection;
(globalThis as Record<string, unknown>).__mockRawFile = rawFile;
return {
srpAttributes: {
srpUserID,
srpSalt: toBase64(srpSalt),
memLimit: TEST_MEM,
opsLimit: TEST_OPS,
kekSalt: toBase64(kekSalt),
isEmailMFAEnabled: false,
},
verifier,
keyAttributes,
encryptedToken: toBase64(encryptedToken),
masterKey,
photoPlaintext,
photoKey,
photoHeader: filePush.header,
photoCiphertext,
thumbPlaintext,
thumbHeader: thumbPush.header,
thumbCiphertext,
collectionKey,
};
};
const buildMockFetch = (s: ServerState) => {
let srpServer: SrpServer;
return (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const path = new URL(url).pathname;
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (path === "/users/srp/attributes") {
return json({ attributes: s.srpAttributes });
}
if (path === "/users/srp/create-session") {
const body = JSON.parse(init?.body as string);
const serverKey = await SRP.genKey();
srpServer = new SrpServer(
SRP.params["4096"],
s.verifier,
serverKey,
);
const B = srpServer.computeB();
srpServer.setA(Buffer.from(body.srpA, "base64"));
return json({ sessionID: "s1", srpB: B.toString("base64") });
}
if (path === "/users/srp/verify-session") {
const body = JSON.parse(init?.body as string);
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
const M2 = srpServer.computeM2();
return json({
srpM2: M2.toString("base64"),
id: 42,
keyAttributes: s.keyAttributes,
encryptedToken: s.encryptedToken,
});
}
if (path === "/collections/v2") {
const raw = (globalThis as Record<string, unknown>)
.__mockRawCollection;
return json({ collections: [raw] });
}
if (path === "/collections/v2/diff") {
const raw = (globalThis as Record<string, unknown>).__mockRawFile;
return json({ diff: [raw], hasMore: false });
}
if (url.includes("files.ente.io") || path === "/files/download/100") {
return new Response(s.photoCiphertext, { status: 200 });
}
if (
url.includes("thumbnails.ente.io") ||
path === "/files/preview/100"
) {
return new Response(s.thumbCiphertext, { status: 200 });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
};
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
await init();
await sodium.ready;
server = await buildServer();
testDir = mkdtempSync(join(tmpdir(), "quack-usage-test-"));
});
afterAll(() => {
if (testDir && existsSync(testDir))
rmSync(testDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// The tutorial
// ---------------------------------------------------------------------------
describe("quack Client usage guide", () => {
/**
* ## 1. Logging in
*
* Create a Client by calling `Client.login()`. This performs the
* entire authentication handshake (SRP key exchange, key derivation,
* key unwrapping) and returns a ready-to-use Client. The password
* never leaves the process.
*
* If the account requires a second factor, `login()` accepts
* optional callbacks for TOTP and email OTP. If neither is provided
* and the server demands 2FA, `login()` throws.
*
* ```ts
* const client = await Client.login({
* email: "you@example.com",
* password: "your-password",
* // Optional: supply these if the account has 2FA enabled.
* // totp: async () => prompt("Enter TOTP code: "),
* // emailOTP: async () => prompt("Enter email code: "),
* });
* ```
*/
it("1. log in with email and password", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
// The client is now authenticated. All subsequent calls use the
// auth token internally.
expect(client).toBeInstanceOf(Client);
});
/**
* ## 2. Inspecting account info
*
* After login, the client knows the user's email and numeric ID.
* The master key and secret key are held in memory but not directly
* exposed; they are used internally by collection/file decryption.
*
* ```ts
* const { email, userID } = client.whoami();
* console.log(`Logged in as ${email} (id ${userID})`);
* ```
*/
it("2. inspect account info with whoami()", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const info = client.whoami();
expect(info.email).toBe(TEST_EMAIL);
expect(info.userID).toBe(42);
});
/**
* ## 3. Listing collections
*
* Collections are Ente's name for albums. Each collection has a
* name, a type (album, folder, favorites, uncategorized), and an
* encryption key. `listCollections()` fetches them from the server,
* decrypts the keys and names, and returns typed objects.
*
* ```ts
* const collections = await client.listCollections();
* for (const c of collections) {
* console.log(`${c.name} [${c.type}] — ${c.id}`);
* }
* ```
*/
it("3. list and decrypt collections", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
expect(collections.length).toBe(1);
expect(collections[0]!.name).toBe("Vacation");
expect(collections[0]!.type).toBe("album");
expect(collections[0]!.id).toBe(1);
// The decrypted collection key is available for advanced use.
expect(collections[0]!.key.length).toBe(32);
});
/**
* ## 4. Listing files in a collection
*
* Each collection contains files (photos, videos, live photos).
* `listFiles()` fetches the file list, decrypts each file's key
* and metadata (title, type, creation time, GPS coordinates), and
* returns typed objects.
*
* ```ts
* const files = await client.listFiles(collection.id, collection.key);
* for (const f of files) {
* console.log(`${f.metadata.title} [${f.metadata.fileType}]`);
* if (f.metadata.latitude) {
* console.log(` at ${f.metadata.latitude}, ${f.metadata.longitude}`);
* }
* }
* ```
*/
it("4. list and decrypt files", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
const col = collections[0]!;
const files = await client.listFiles(col.id, col.key);
expect(files.length).toBe(1);
expect(files[0]!.metadata.title).toBe("beach.jpg");
expect(files[0]!.metadata.fileType).toBe("image");
expect(files[0]!.metadata.latitude).toBeCloseTo(35.6762);
expect(files[0]!.metadata.longitude).toBeCloseTo(139.6503);
});
/**
* ## 5. Downloading a file
*
* `downloadFile()` fetches the encrypted file body from the CDN,
* decrypts it chunk by chunk using the file's secretstream key and
* header, and writes the plaintext to disk.
*
* If you omit `outPath`, the file is written to the current
* directory using the title from the decrypted metadata.
*
* ```ts
* const result = await client.downloadFile(file, "/tmp/beach.jpg");
* console.log(`Wrote ${result.bytesWritten} bytes to ${result.path}`);
* ```
*/
it("5. download and decrypt a file to disk", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
const files = await client.listFiles(
collections[0]!.id,
collections[0]!.key,
);
const file = files[0]!;
const outPath = join(testDir, "beach.jpg");
const result = await client.downloadFile(file, outPath);
expect(result.path).toBe(outPath);
expect(result.bytesWritten).toBe(server.photoPlaintext.length);
expect(readFileSync(outPath)).toEqual(
Buffer.from(server.photoPlaintext),
);
});
/**
* ## 6. Downloading a thumbnail
*
* Thumbnails are encrypted the same way as full files, just served
* from a different CDN endpoint. The API is identical.
*
* ```ts
* const result = await client.downloadThumbnail(file, "/tmp/thumb.jpg");
* ```
*/
it("6. download and decrypt a thumbnail", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
const collections = await client.listCollections();
const files = await client.listFiles(
collections[0]!.id,
collections[0]!.key,
);
const file = files[0]!;
const outPath = join(testDir, "thumb.jpg");
const result = await client.downloadThumbnail(file, outPath);
expect(result.bytesWritten).toBe(server.thumbPlaintext.length);
expect(readFileSync(outPath)).toEqual(
Buffer.from(server.thumbPlaintext),
);
});
/**
* ## 7. Serializing and restoring a session
*
* The Client holds the auth token and key material in memory. If
* you need to persist a session across process restarts (for a CLI
* that doesn't re-prompt for a password every time, or a long-lived
* daemon), call `toJSON()` to get a plain object you can
* JSON.stringify and store however you like. Later, pass it to
* `Client.fromJSON()` to get back a working Client without logging
* in again.
*
* The serialized form contains the master key and secret key. Treat
* it as you would treat the password itself. Encrypt it at rest if
* you store it on disk.
*
* ```ts
* // Save
* const snapshot = client.toJSON();
* fs.writeFileSync("session.json", JSON.stringify(snapshot));
*
* // Restore (in a later process)
* const data = JSON.parse(fs.readFileSync("session.json", "utf-8"));
* const restored = Client.fromJSON(data);
* const collections = await restored.listCollections(); // works
* ```
*/
it("7. serialize with toJSON() and restore with fromJSON()", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
// Serialize to a plain object. This is what you would persist.
const snapshot = client.toJSON();
expect(snapshot.email).toBe(TEST_EMAIL);
expect(snapshot.userID).toBe(42);
expect(typeof snapshot.token).toBe("string");
expect(typeof snapshot.masterKey).toBe("string"); // base64
// Restore from the snapshot. The new client must be able to do
// everything the original could, without logging in again.
const restored = Client.fromJSON(snapshot, {
fetch: buildMockFetch(server),
});
expect(restored.whoami().email).toBe(TEST_EMAIL);
const collections = await restored.listCollections();
expect(collections[0]!.name).toBe("Vacation");
});
/**
* ## 8. Logging out
*
* `logout()` clears the in-memory session. After this call, every
* method that requires authentication will throw. The Client object
* is no longer usable.
*
* ```ts
* client.logout();
* // client.listCollections() would now throw
* ```
*/
it("8. logout clears the session", async () => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: { fetch: buildMockFetch(server) },
});
client.logout();
expect(() => client.whoami()).toThrow();
await expect(client.listCollections()).rejects.toThrow();
});
});