Test quak login and backup-metadata --exif (closes #110)
check / check (push) Successful in 46s
check / check (push) Successful in 46s
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
This commit is contained in:
+145
-2
@@ -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<Client>,
|
||||
): 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user