From 7740ebfd4dbcbabae1df841c6523320f8ccc143e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 23 Sep 2026 09:27:40 +0200 Subject: [PATCH] Test quak login and backup-metadata --exif (closes #110) loginCommand now takes its login function and prompts from CliContext; bin/quak.ts passes Client.login and the terminal prompts, so behaviour is unchanged. Tests cover a login from QUAK_EMAIL/QUAK_PASSWORD with no prompt, the TOTP prompt, a failed login, and the saved session's modes, and show that --exif and --all each turn on EXIF extraction. Also rewraps the header comment of src/cli-commands.ts. Model: opus-5-5 --- TODO.md | 7 ++ bin/quak.ts | 5 ++ src/cli-commands.ts | 31 ++++---- test/cli/commands.test.ts | 147 +++++++++++++++++++++++++++++++++++++- 4 files changed, 174 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index afaa444..c3998ac 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,13 @@ Tag v1.0.0. # Completed Steps +- 2026-09-23: Tested `quak login` and `backup-metadata --exif` (issue 110). + `loginCommand` takes its login function and its prompts from `CliContext`, and + `bin/quak.ts` passes `Client.login` and the terminal prompts. Tests cover a + login from `QUAK_EMAIL` and `QUAK_PASSWORD` with no prompt, the TOTP prompt, a + failed login, and the saved session's modes, and show that `--exif` and + `--all` each turn on EXIF extraction and that it is off without them. + - 2026-09-23: `quak backup` writes each original once and no longer fills the cache (issue 106). An original fetched for a backup is written by the download writer straight into the backup's `originals/`, and the content cache records diff --git a/bin/quak.ts b/bin/quak.ts index 315d82b..4c02183 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { stdout, stderr } from "node:process"; +import { input, password } from "@inquirer/prompts"; import { Command } from "commander"; import envPaths from "env-paths"; import { init } from "../src/crypto/index.js"; @@ -20,6 +21,7 @@ import { } from "../src/cli-commands.js"; import { run as runCommand } from "../src/cli-run.js"; import { loadSession } from "../src/cli-session.js"; +import { Client } from "../src/client.js"; import { VERSION } from "../src/index.js"; const paths = envPaths("quak", { suffix: "" }); @@ -42,6 +44,9 @@ const context = (): CliContext => ({ sessionDir: paths.data, cacheDir: program.opts<{ cacheDir?: string }>().cacheDir, loadSession, + login: (opts) => Client.login(opts), + prompt: (message) => input({ message }), + promptSecret: (message) => password({ message, mask: true }), }); const run = (command: Promise): Promise => diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 041b656..ea4ff18 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -4,10 +4,8 @@ // code; a thrown error is left to the caller. Nothing here calls // `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in // `cli-run.ts` prints a thrown error as one line and exits once output has -// drained. Output must stay byte-identical -// (see `cli-output.ts`). +// drained. Output must stay byte-identical (see `cli-output.ts`). -import { input, password as passwordPrompt } from "@inquirer/prompts"; import { copyFileSync, existsSync, @@ -16,7 +14,11 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; -import { Client, type ClientSnapshot } from "./client.js"; +import { + type Client, + type ClientSnapshot, + type LoginOptions, +} from "./client.js"; import { init } from "./crypto/index.js"; import { defaultCacheDirectory, @@ -44,6 +46,12 @@ export interface CliContext { // Reads the session file into a client, or null when there is none. The // CLI passes `loadSession` from `cli-session.ts`; tests pass a fake client. loadSession: (path: string) => Client | null; + // Used by `login` only. The CLI passes `Client.login` and terminal + // prompts; tests pass fakes. + login: (opts: LoginOptions) => Promise; + prompt: (message: string) => Promise; + // Like `prompt`, but the answer is masked as it is typed. + promptSecret: (message: string) => Promise; } const sessionPath = (ctx: CliContext): string => @@ -109,24 +117,19 @@ const openReadLibrary = (ctx: CliContext, client: Client): Promise => precacheOriginals: false, }); -const prompt = async (message: string): Promise => input({ message }); - -const promptSecret = async (message: string): Promise => - passwordPrompt({ message, mask: true }); - export const loginCommand = async (ctx: CliContext): Promise => { await init(); - const email = process.env.QUAK_EMAIL ?? (await prompt("Email")); + const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email")); const password = - process.env.QUAK_PASSWORD ?? (await promptSecret("Password")); + process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password")); ctx.stderr.write("Authenticating...\n"); try { - const client = await Client.login({ + const client = await ctx.login({ email, password, - totp: async () => prompt("TOTP code: "), - emailOTP: async () => prompt("Email verification code: "), + totp: async () => ctx.prompt("TOTP code: "), + emailOTP: async () => ctx.prompt("Email verification code: "), }); saveSession(ctx.sessionDir, client.toJSON()); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 3f33a5b..7585335 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -22,11 +22,20 @@ import { import { join } from "node:path"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; -import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from "vitest"; import { type CliContext, saveSession, + loginCommand, whoamiCommand, logoutCommand, collectionsCommand, @@ -40,7 +49,7 @@ import { } from "../../src/cli-commands.js"; import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; -import type { Client, ClientSnapshot } from "../../src/client.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"; @@ -166,6 +175,15 @@ const context = (client: Client | null = fakeClient()): CliContext => ({ 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 () => { @@ -223,6 +241,105 @@ describe("session file", () => { }); }); +// 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", () => { @@ -525,6 +642,32 @@ describe("helper list-missing-thumbnails", () => { }); }); +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", () => {