/** * Tests for the CLI commands (`src/cli-commands.ts`, issue #12). * * Each command is called directly with a context whose output streams collect * text, whose session directory is a fresh temp directory, and whose session * loader hands back a fake client. The fake serves two albums and three files * from memory, writes stand-in bytes for originals and thumbnails, and makes no * network calls. The helpers the commands call (`cli-read`, `cli-output`, * backup, thumbnails) have their own tests; these check what each command * prints and the exit code it returns. */ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import * as jpegJs from "jpeg-js"; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, } from "vitest"; import { type CliContext, saveSession, loginCommand, whoamiCommand, logoutCommand, collectionsCommand, filesCommand, getCommand, getThumbCommand, backupCommand, backupMetadataCommand, listMissingThumbnailsCommand, fixMissingThumbnailsCommand, } from "../../src/cli-commands.js"; import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js"; import type { ContentSource } from "../../src/library/content.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { init, toBase64 } from "../../src/crypto/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js"; import { asLivePhoto, cdnSource, IMAGE, livePhotoHash, livePhotoZip, VIDEO, } from "../live-photo.js"; const USER_ID = 42; const collection = ( id: number, name: string, isShared = false, ): Collection => ({ id, ownerID: USER_ID, key: new Uint8Array([id]), name, type: "album", updationTime: 1, isShared, }); const file = (id: number, collectionID: number, title: string): EnteFile => ({ id, collectionID, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), metadata: { title, fileType: "image", creationTime: 1000, modificationTime: 1000, }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: 1, }); const COLLECTIONS = [collection(1, "Vacation"), collection(2, "Work", true)]; const FILES: Record = { 1: [file(100, 1, "beach.jpg"), file(101, 1, "sunset.jpg")], 2: [file(200, 2, "diagram.png")], }; // An original is 7 bytes and a thumbnail 3. `failID` makes that file's // original fail; `emptyThumbID` makes the server report that file's // thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing // the collection's updationTime as the server does, and `refreshError` makes // listing collections fail with that message. const fakeClient = ( opts: { failID?: number; emptyThumbID?: number; withNewFile?: boolean; refreshError?: string; } = {}, ) => { const collections = opts.withNewFile ? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]] : COLLECTIONS; const files = opts.withNewFile ? { ...FILES, 1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }], } : FILES; const source: ContentSource = { original: async ({ file: f, destination }) => { if (f.id === opts.failID) throw new Error("HTTP 500 from server"); writeFileSync(destination, Buffer.alloc(7, f.id & 0xff)); return { bytesWritten: 7 }; }, thumbnail: async ({ file: f, destination }) => { writeFileSync(destination, Buffer.alloc(3, f.id & 0xff)); return { bytesWritten: 3 }; }, }; const fake = { whoami: () => ({ email: "cli@example.com", userID: USER_ID }), collectionsSince: async () => { if (opts.refreshError) throw new Error(opts.refreshError); return { collections, deleted: [], cursor: 1 }; }, filesSince: async (args: { collectionID: number }) => ({ files: files[args.collectionID] ?? [], deleted: [], cursor: 1, }), contentSource: () => source, getApiClient: () => ({ // The ML data request of `backup-metadata`: no file has any. postJSON: async () => ({ data: [] }), getThumbnailStream: async (fileID: number) => new ReadableStream({ start(controller) { if (fileID !== opts.emptyThumbID) { controller.enqueue(new Uint8Array(3)); } controller.close(); }, }), }), }; // The commands only call the methods above. return fake as unknown as Client; }; // Collects everything written to it. class Output { text = ""; write(text: string): void { this.text += text; } } let root: string; let stdout: Output; let stderr: Output; const context = (client: Client | null = fakeClient()): CliContext => ({ stdout, stderr, sessionDir: join(root, "session"), cacheDir: join(root, "cache"), loadSession: () => client, login: async () => { throw new Error("login not expected"); }, prompt: async () => { throw new Error("prompt not expected"); }, promptSecret: async () => { throw new Error("prompt not expected"); }, }); beforeAll(async () => { await init(); }); beforeEach(() => { root = mkdtempSync(join(tmpdir(), "quak-cli-test-")); stdout = new Output(); stderr = new Output(); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); describe("session file", () => { const snapshot: ClientSnapshot = { email: "cli@example.com", userID: USER_ID, token: "token", masterKey: "a", secretKey: "b", publicKey: "c", }; it("is written with mode 0600 in a directory with mode 0700", () => { const dir = join(root, "new", "session"); saveSession(dir, snapshot); expect(statSync(dir).mode & 0o777).toBe(0o700); const path = join(dir, "session.json"); expect(statSync(path).mode & 0o777).toBe(0o600); expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot); }); it("a missing session exits 1 with 'Not logged in'", async () => { const ctx = { ...context(), loadSession }; expect(await whoamiCommand(ctx)).toBe(1); expect(stderr.text).toBe( `Not logged in. Run "quak login" first.\n` + `Session file: ${join(ctx.sessionDir, "session.json")}\n`, ); expect(stdout.text).toBe(""); }); it("a corrupt session exits 1 and says it is corrupt", async () => { const ctx = { ...context(), loadSession }; saveSession(ctx.sessionDir, snapshot); expect(await collectionsCommand(ctx, {})).toBe(1); expect(stderr.text).toContain("is corrupt"); expect(stderr.text).toContain( `Run "quak logout" and then "quak login" to replace it.\n`, ); expect(stdout.text).toBe(""); }); }); // The login function is a fake that hands back a client whose snapshot is // `snapshot`; each prompt is recorded and answered with "123456". describe("login", () => { const snapshot: ClientSnapshot = { email: "cli@example.com", userID: USER_ID, token: "token", masterKey: "a", secretKey: "b", publicKey: "c", }; const loggedIn = { whoami: () => ({ email: "cli@example.com", userID: USER_ID }), toJSON: () => snapshot, } as unknown as Client; let prompts: string[]; const loginContext = ( login: (opts: LoginOptions) => Promise, ): CliContext => ({ ...context(), login, prompt: async (message) => { prompts.push(message); return "123456"; }, promptSecret: async (message) => { prompts.push(message); return "123456"; }, }); beforeEach(() => { prompts = []; vi.stubEnv("QUAK_EMAIL", "cli@example.com"); vi.stubEnv("QUAK_PASSWORD", "hunter2"); }); afterEach(() => { vi.unstubAllEnvs(); }); it("takes the email and password from the environment without a prompt", async () => { const calls: LoginOptions[] = []; const ctx = loginContext(async (opts) => { calls.push(opts); return loggedIn; }); 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"); const path = join(ctx.sessionDir, "session.json"); expect(stderr.text).toBe( "Authenticating...\n" + `Logged in as cli@example.com (user ${USER_ID})\n` + `Session saved to ${path}\n`, ); }); 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(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700); const path = join(ctx.sessionDir, "session.json"); expect(statSync(path).mode & 0o777).toBe(0o600); expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot); }); it("asks for the TOTP code when the account needs one", async () => { let code: string | undefined; const ctx = loginContext(async (opts) => { code = await opts.totp!(); return loggedIn; }); expect(await loginCommand(ctx)).toBe(0); expect(prompts).toEqual(["TOTP code: "]); expect(code).toBe("123456"); }); it("a failed login exits 1, says why and writes no session", async () => { const ctx = loginContext(async () => { throw new Error("HTTP 401 from server"); }); 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); }); }); // These use a real client read from the session file, over a fake API that // records each request and answers with `status`. describe("logout", () => { const snapshot: ClientSnapshot = { email: "cli@example.com", userID: USER_ID, token: "saved-token", masterKey: toBase64(new Uint8Array(32)), secretKey: toBase64(new Uint8Array(32)), publicKey: toBase64(new Uint8Array(32)), }; const requests: Request[] = []; const logoutContext = (status: number): CliContext => ({ ...context(), loadSession: (path) => loadSession(path, { fetch: async (url, init) => { requests.push(new Request(url, init)); return new Response(JSON.stringify({}), { status, headers: { "content-type": "application/json" }, }); }, }), }); beforeEach(() => { requests.length = 0; }); it("ends the session on the server, then deletes the file", async () => { const ctx = logoutContext(200); saveSession(ctx.sessionDir, snapshot); expect(await logoutCommand(ctx)).toBe(0); expect(requests).toHaveLength(1); expect(requests[0]!.method).toBe("POST"); expect(new URL(requests[0]!.url).pathname).toBe("/users/logout"); expect(requests[0]!.headers.get("X-Auth-Token")).toBe("saved-token"); expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); expect(stderr.text).toBe( "Session ended on the server.\n" + "Session deleted.\n" + `Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`, ); }); it("still deletes the file when the server call fails, and says so", async () => { const ctx = logoutContext(500); saveSession(ctx.sessionDir, snapshot); expect(await logoutCommand(ctx)).toBe(1); expect(requests).toHaveLength(1); expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false); expect(stderr.text).toBe( "Could not end the session on the server: HTTP 500\n" + "Session deleted.\n" + `Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`, ); }); it("names the account's default cache directory without --cache-dir", async () => { const ctx = { ...logoutContext(200), cacheDir: undefined }; saveSession(ctx.sessionDir, snapshot); expect(await logoutCommand(ctx)).toBe(0); expect(stderr.text).toContain( `Cache directory ${defaultCacheDirectory(USER_ID)} 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); expect(stderr.text).toBe("No session found.\n"); }); }); describe("whoami", () => { it("prints the account as one line of JSON", async () => { expect(await whoamiCommand(context())).toBe(0); expect(stdout.text).toBe( `{"email":"cli@example.com","userID":${USER_ID}}\n`, ); }); }); describe("collections", () => { it("prints one tab-separated line per album", async () => { expect(await collectionsCommand(context(), {})).toBe(0); expect(stdout.text).toBe( "1\talbum\tVacation\n" + "2\talbum\tWork (shared)\n", ); }); it("prints a JSON array with --json", async () => { expect(await collectionsCommand(context(), { json: true })).toBe(0); expect(JSON.parse(stdout.text)).toEqual([ { id: 1, name: "Vacation", type: "album", ownerID: USER_ID, isShared: false, updationTime: 1, }, { id: 2, name: "Work", type: "album", ownerID: USER_ID, isShared: true, updationTime: 1, }, ]); }); }); describe("files", () => { it("prints one tab-separated line per file", async () => { expect(await filesCommand(context(), { collection: "1" })).toBe(0); expect(stdout.text).toBe( "100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n", ); }); it("prints a JSON array with --json", async () => { const code = await filesCommand(context(), { collection: "2", json: true, }); expect(code).toBe(0); expect(JSON.parse(stdout.text)).toEqual([ { id: 200, title: "diagram.png", fileType: "image", creationTime: 1000, collectionID: 2, }, ]); }); it("exits 1 for an unknown collection", async () => { expect(await filesCommand(context(), { collection: "9" })).toBe(1); expect(stderr.text).toBe("Collection 9 not found\n"); }); it("exits 1 for a collection ID that is not a number", async () => { expect(await filesCommand(context(), { collection: "abc" })).toBe(1); expect(stderr.text).toBe("Invalid collection ID\n"); }); }); describe("get and get-thumb", () => { it("get finds a file in any album without --collection", async () => { const out = join(root, "diagram.png"); expect(await getCommand(context(), "200", { out })).toBe(0); expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200)); expect(stderr.text).toBe(`7 bytes -> ${out}\n`); }); it("get-thumb finds a file in any album without --collection", async () => { const out = join(root, "thumb.jpg"); expect(await getThumbCommand(context(), "200", { out })).toBe(0); expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200)); expect(stderr.text).toBe(`3 bytes -> ${out}\n`); }); it("get exits 1 when no album has the file", async () => { const out = join(root, "x"); expect(await getCommand(context(), "999", { out })).toBe(1); expect(stderr.text).toBe("File 999 not found\n"); expect(existsSync(out)).toBe(false); }); it("get-thumb exits 1 when no album has the file", async () => { const out = join(root, "x"); expect(await getThumbCommand(context(), "999", { out })).toBe(1); expect(stderr.text).toBe("File 999 not found\n"); expect(existsSync(out)).toBe(false); }); it("both exit 1 for a file ID that is not a number", async () => { expect(await getCommand(context(), "abc", {})).toBe(1); expect(await getThumbCommand(context(), "abc", {})).toBe(1); expect(stderr.text).toBe("Invalid file ID\nInvalid file ID\n"); }); }); // A live photo, which Ente stores as one ZIP, is written as its image and its // video, which a photo viewer can open. The account here is Vacation holding // one live photo, 300, downloaded through the real download layer // (test/live-photo.ts). describe("a live photo", () => { const livePhotoClient = async (image = IMAGE): Promise => { const { file: live, body } = await asLivePhoto( file(300, 1, "IMG_0300.HEIC"), livePhotoZip({ "image.heic": image, "video.mov": VIDEO }), livePhotoHash(image, VIDEO), ); const fake = { whoami: () => ({ email: "cli@example.com", userID: USER_ID }), collectionsSince: async () => ({ collections: [collection(1, "Vacation")], deleted: [], cursor: 1, }), filesSince: async () => ({ files: [live], deleted: [], cursor: 1 }), contentSource: () => cdnSource(new Map([[300, body]])), // The ML data request of `backup-metadata`: no file has any. getApiClient: () => ({ postJSON: async () => ({ data: [] }) }), }; return fake as unknown as Client; }; it("get writes its image and video, named after the title with their own extensions", async () => { const ctx = context(await livePhotoClient()); const dir = join(root, "cwd"); mkdirSync(dir); const previous = process.cwd(); process.chdir(dir); try { expect(await getCommand(ctx, "300", {})).toBe(0); } finally { process.chdir(previous); } expect(readdirSync(dir).sort()).toEqual([ "IMG_0300.heic", "IMG_0300.mov", ]); expect(readFileSync(join(dir, "IMG_0300.heic"))).toEqual( Buffer.from(IMAGE), ); expect(readFileSync(join(dir, "IMG_0300.mov"))).toEqual( Buffer.from(VIDEO), ); expect(stderr.text).toBe( `${IMAGE.length} bytes -> IMG_0300.heic\n` + `${VIDEO.length} bytes -> IMG_0300.mov\n`, ); }); it("get --out writes the image there and the video beside it", async () => { const out = join(root, "photo.jpg"); const video = join(root, "photo.mov"); expect( await getCommand(context(await livePhotoClient()), "300", { out }), ).toBe(0); expect(readFileSync(out)).toEqual(Buffer.from(IMAGE)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(stderr.text).toBe( `${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`, ); }); it("get exits 1 and writes nothing when --out has the video's extension", async () => { const out = join(root, "photo.MOV"); expect( await getCommand(context(await livePhotoClient()), "300", { out }), ).toBe(1); expect(existsSync(out)).toBe(false); expect(existsSync(join(root, "photo.mov"))).toBe(false); expect(stderr.text).toBe( `File 300 is a live photo, and its video would also be written to ${out}\n`, ); }); it("backup-metadata --exif reads its image", async () => { // A 4x4 JPEG, whose size can only come from reading the image; the // ZIP and the video are not JPEGs. const jpeg = jpegJs.encode( { data: new Uint8Array(4 * 4 * 4), width: 4, height: 4 }, 50, ).data; const dir = join(root, "dump"); expect( await backupMetadataCommand( context(await livePhotoClient(new Uint8Array(jpeg))), dir, { exif: true }, ), ).toBe(0); const record = JSON.parse( readFileSync( join(dir, "collections", "1-Vacation", "300.json"), "utf-8", ), ); expect(record.imageMetadata).toMatchObject({ format: "jpeg", width: 4, height: 4, }); }); }); describe("backup", () => { it("exits 0 and prints a summary when every file is saved", async () => { const dir = join(root, "backup"); expect(await backupCommand(context(), dir, {})).toBe(0); expect(stderr.text).toContain( "\n--- Backup complete ---\n" + " Total files: 3\n" + " Downloaded: 3\n" + " Skipped: 0\n" + " Failed: 0\n", ); expect(stdout.text).toBe(""); }); // The backup opens its library with the precache off: it fetches the // originals it needs into the backup, and must not also fetch every // thumbnail in the account, or keep originals, in the per-user cache. it("leaves nothing in the cache's originals and thumbnails", async () => { const dir = join(root, "backup"); expect(await backupCommand(context(), dir, {})).toBe(0); expect(readdirSync(join(root, "cache", "thumbnails"))).toEqual([]); expect(readdirSync(join(root, "cache", "originals"))).toEqual([]); }); it("exits 1 and lists the file when one download fails", async () => { const ctx = context(fakeClient({ failID: 101 })); expect(await backupCommand(ctx, join(root, "backup"), {})).toBe(1); expect(stderr.text).toContain(" Failed: 1\n"); expect(stderr.text).toContain( "\nFailed files:\n" + " [Vacation] sunset.jpg (id 101): HTTP 500 from server\n", ); }); it("prints the result as JSON with --json, still exiting 1 on a failure", async () => { const ctx = context(fakeClient({ failID: 101 })); const code = await backupCommand(ctx, join(root, "backup"), { json: true, }); expect(code).toBe(1); const result = JSON.parse(stdout.text); expect(result).toMatchObject({ totalFiles: 3, downloaded: 2, skipped: 0, failed: 1, }); expect(result.errors[0].fileID).toBe(101); expect(stderr.text).toBe("Starting backup...\n"); }); it("exits 1 with the error on one line when the refresh fails", async () => { const client = { ...fakeClient(), collectionsSince: async () => { throw new Error("HTTP 401 from server"); }, } as unknown as Client; const dir = join(root, "backup"); // Through `run`, as `bin/quak.ts` does, which prints a thrown error. const runStderr = new PassThrough(); let runText = ""; runStderr.on("data", (chunk: Buffer) => { runText += chunk.toString(); }); const code = await new Promise((resolve) => { void run( backupCommand(context(client), dir, {}), new PassThrough(), runStderr, resolve, ); }); expect(code).toBe(1); expect(runText).toBe("quak: HTTP 401 from server\n"); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); expect(existsSync(join(dir, "originals"))).toBe(false); }); }); describe("helper list-missing-thumbnails", () => { it("prints one line per file with an empty thumbnail", async () => { const ctx = context(fakeClient({ emptyThumbID: 200 })); expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0); expect(stdout.text).toBe( "200\tdiagram.png\tWork\tempty thumbnail (0 bytes)\n", ); expect(stderr.text).toContain("\n1 file(s) with missing thumbnails:\n"); }); it("says so when nothing is missing", async () => { expect(await listMissingThumbnailsCommand(context(), {})).toBe(0); expect(stdout.text).toBe(""); expect(stderr.text).toContain("No missing thumbnails found.\n"); }); it("prints a JSON array with --json and no progress", async () => { const ctx = context(fakeClient({ emptyThumbID: 200 })); expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0); expect(JSON.parse(stdout.text)).toEqual([ { fileID: 200, title: "diagram.png", collection: "Work", reason: "empty thumbnail (0 bytes)", }, ]); expect(stderr.text).toBe(""); }); }); describe("backup-metadata --exif", () => { // Runs the command and returns what it printed to stderr. const backupMetadata = async (opts: { exif?: boolean; all?: boolean }) => { expect( await backupMetadataCommand(context(), join(root, "dump"), opts), ).toBe(0); return stderr.text; }; it("--exif extracts EXIF", async () => { expect(await backupMetadata({ exif: true })).toContain( "[beach.jpg] Extracting EXIF...\n", ); }); it("--all extracts EXIF", async () => { expect(await backupMetadata({ all: true })).toContain( "[beach.jpg] Extracting EXIF...\n", ); }); it("without either flag extracts no EXIF", async () => { expect(await backupMetadata({})).not.toContain("Extracting EXIF"); }); }); // Each test first runs `collections` so the cache holds the account as it was, // then changes the server under it. describe("backup-metadata and the thumbnail helpers refresh first", () => { beforeEach(async () => { expect(await collectionsCommand(context(), {})).toBe(0); stdout.text = ""; stderr.text = ""; }); it("backup-metadata writes a file added since the cache was written", async () => { const ctx = context(fakeClient({ withNewFile: true })); const dir = join(root, "dump"); expect(await backupMetadataCommand(ctx, dir, {})).toBe(0); expect( existsSync(join(dir, "collections", "1-Vacation", "102.json")), ).toBe(true); }); it("list-missing-thumbnails checks a file added since the cache was written", async () => { const ctx = context( fakeClient({ withNewFile: true, emptyThumbID: 102 }), ); expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0); expect(stdout.text).toBe( "102\tnew.jpg\tVacation\tempty thumbnail (0 bytes)\n", ); }); it("fix-missing-thumbnails finds a file added since the cache was written", async () => { const ctx = context(fakeClient({ withNewFile: true })); expect( await fixMissingThumbnailsCommand(ctx, { file: ["102"], json: true, }), ).toBe(0); // Found, then skipped because the server records no thumbnail size // for it; a file missing from the cache would fail as not found. expect(JSON.parse(stdout.text)).toMatchObject([ { fileID: 102, title: "new.jpg", status: "skipped" }, ]); }); // `run` in `cli-run.ts` prints a thrown error as one line and exits 1. it("all three throw when the refresh fails", async () => { const ctx = context( fakeClient({ refreshError: "HTTP 503 from server" }), ); const dir = join(root, "dump"); await expect(backupMetadataCommand(ctx, dir, {})).rejects.toThrow( "HTTP 503 from server", ); expect(existsSync(dir)).toBe(false); await expect(listMissingThumbnailsCommand(ctx, {})).rejects.toThrow( "HTTP 503 from server", ); await expect( fixMissingThumbnailsCommand(ctx, { file: ["100"] }), ).rejects.toThrow("HTTP 503 from server"); expect(stdout.text).toBe(""); }); });