check / check (push) Successful in 54s
logout now calls POST /users/logout with the saved token through the new Client.logoutOnServer(), then deletes session.json even when that call fails; in that case it says the server session could not be ended and exits 1. It prints the account's cache directory and says it still holds decrypted data. The default cache path moves into defaultCacheDirectory(), shared with Library.open, so both name the same directory. Model: opus-5-5
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
/**
|
|
* 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<number, EnteFile[]> = {
|
|
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<Uint8Array>({
|
|
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("");
|
|
});
|
|
});
|