/** * 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, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { type CliContext, saveSession, whoamiCommand, logoutCommand, collectionsCommand, filesCommand, getCommand, getThumbCommand, backupCommand, listMissingThumbnailsCommand, } from "../../src/cli-commands.js"; import { loadSession } from "../../src/cli-session.js"; import type { Client, ClientSnapshot } 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"; 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. const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => { 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 () => ({ collections: COLLECTIONS, deleted: [], cursor: 1, }), filesSince: async (args: { collectionID: number }) => ({ files: FILES[args.collectionID] ?? [], deleted: [], cursor: 1, }), contentSource: () => source, getApiClient: () => ({ 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, }); 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(""); }); }); // 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"); }); }); 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(""); }); 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"); }); }); 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(""); }); });