An expired session exits 3 with one line saying to run quak login (closes #164)
check / check (push) Successful in 2m20s

A 401 from the server reaches run in src/cli-run.ts as the ApiError the
refresh threw, so run recognises it there, prints one line saying to run
"quak login" and exits 3. A missing or corrupt session file keeps its message
and also exits 3. quak backup meets the 401 on its first refresh, before it
touches any file. Every other error still exits 1.

Judgement call: quak logout is unchanged; it handles its own errors and
deletes the session file whatever the server answers.
Judgement call: quak backup still prints its two progress lines before the
error line.

Model: opus-5-5
This commit is contained in:
2026-10-06 01:21:30 +00:00
parent 14ac7d05ef
commit 1283048ce2
6 changed files with 141 additions and 37 deletions
+68 -18
View File
@@ -230,20 +230,30 @@ describe("session file", () => {
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
});
it("a missing session exits 1 with 'Not logged in'", async () => {
it("a missing session exits 3 with 'Not logged in' from every command that needs one", async () => {
const ctx = { ...context(), loadSession };
expect(await whoamiCommand(ctx)).toBe(1);
expect(stderr.text).toBe(
const dir = join(root, "backup");
expect(await whoamiCommand(ctx)).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
expect(await getCommand(ctx, "100", {})).toBe(3);
expect(await getThumbCommand(ctx, "100", {})).toBe(3);
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
expect(await backupCommand(ctx, dir, {})).toBe(3);
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(3);
expect(await fixMissingThumbnailsCommand(ctx, {})).toBe(3);
const notLoggedIn =
`Not logged in. Run "quak login" first.\n` +
`Session file: ${join(ctx.sessionDir, "session.json")}\n`,
);
`Session file: ${join(ctx.sessionDir, "session.json")}\n`;
expect(stderr.text).toBe(notLoggedIn.repeat(9));
expect(stdout.text).toBe("");
expect(existsSync(dir)).toBe(false);
});
it("a corrupt session exits 1 and says it is corrupt", async () => {
it("a corrupt session exits 3 and says it is corrupt", async () => {
const ctx = { ...context(), loadSession };
saveSession(ctx.sessionDir, snapshot);
expect(await collectionsCommand(ctx, {})).toBe(1);
expect(await collectionsCommand(ctx, {})).toBe(3);
expect(stderr.text).toContain("is corrupt");
expect(stderr.text).toContain(
`Run "quak logout" and then "quak login" to replace it.\n`,
@@ -732,15 +742,12 @@ describe("backup", () => {
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.
// Runs `backup` through `run`, as `bin/quak.ts` does, which prints a thrown
// error; returns the exit code and what `run` printed.
const backupThroughRun = async (
ctx: CliContext,
dir: string,
): Promise<{ code: number; runText: string }> => {
const runStderr = new PassThrough();
let runText = "";
runStderr.on("data", (chunk: Buffer) => {
@@ -748,14 +755,57 @@ describe("backup", () => {
});
const code = await new Promise<number>((resolve) => {
void run(
backupCommand(context(client), dir, {}),
backupCommand(ctx, dir, {}),
new PassThrough(),
runStderr,
resolve,
);
});
return { code, runText };
};
it("exits 1 with the error on one line when the refresh fails", async () => {
const client = {
...fakeClient(),
collectionsSince: async () => {
throw new Error("HTTP 503 from server");
},
} as unknown as Client;
const dir = join(root, "backup");
const { code, runText } = await backupThroughRun(context(client), dir);
expect(code).toBe(1);
expect(runText).toBe("quak: HTTP 401 from server\n");
expect(runText).toBe("quak: HTTP 503 from server\n");
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false);
});
// A real saved session, read back by `loadSession`, whose server answers
// every request with 401, as it does once it no longer accepts the token.
// The context's prompts throw, so a prompt would end the run with another
// line and exit 1.
it("exits 3 with one line saying to log in again when the server answers 401", async () => {
const key = toBase64(new Uint8Array(32));
saveSession(join(root, "session"), {
email: "cli@example.com",
userID: USER_ID,
token: "expired",
masterKey: key,
secretKey: key,
publicKey: key,
});
const unauthorized = async (): Promise<Response> =>
new Response(null, { status: 401 });
const ctx = {
...context(),
loadSession: (path: string) =>
loadSession(path, { fetch: unauthorized }),
};
const dir = join(root, "backup");
const { code, runText } = await backupThroughRun(ctx, dir);
expect(code).toBe(3);
expect(runText).toBe(
`quak: the saved session is no longer valid; run "quak login"\n`,
);
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false);
});