/** * Tests for the client session lifecycle: `toJSON`, `fromJSON`, `logout`, and * the CLI's `loadSession`, which reads the saved session file back into a * client. */ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import sodium from "libsodium-wrappers-sumo"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { init, toBase64 } from "../../src/crypto/index.js"; import { Client, type ClientSnapshot } from "../../src/client.js"; import { loadSession } from "../../src/cli-session.js"; const validSnapshot = (): ClientSnapshot => { const kp = sodium.crypto_box_keypair(); return { email: "user@example.com", userID: 42, token: "test-token", masterKey: toBase64(sodium.crypto_secretbox_keygen()), secretKey: toBase64(kp.privateKey), publicKey: toBase64(kp.publicKey), }; }; // The client's key buffers are private; the tests read them to prove that // logout wipes them. const keyBuffers = (client: Client): Uint8Array[] => [ client["masterKey"], client["secretKey"], client["publicKey"], ]; beforeAll(async () => { await init(); }); describe("Client.toJSON", () => { it("round-trips through fromJSON unchanged", () => { const snapshot = validSnapshot(); expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot); }); it("throws instead of emitting a snapshot without a token", () => { const client = Client.fromJSON(validSnapshot()); client.getApiClient().clearAuthToken(); expect(() => client.toJSON()).toThrow(/no auth token/); }); }); describe("Client.fromJSON", () => { const shortKey = toBase64(new Uint8Array(16)); it.each([ ["email", undefined], ["email", 7], ["email", ""], ["token", undefined], ["token", null], ["token", ""], ["userID", undefined], ["userID", "42"], ["userID", 4.2], ["masterKey", undefined], ["masterKey", 7], ["masterKey", "not base64!"], ["masterKey", shortKey], ["secretKey", undefined], ["secretKey", "not base64!"], ["secretKey", shortKey], ["publicKey", undefined], ["publicKey", "not base64!"], ["publicKey", shortKey], ])("rejects %s = %j, naming the field", (field, value) => { const snapshot: Record = { ...validSnapshot() }; snapshot[field] = value; expect(() => Client.fromJSON(snapshot)).toThrow( new RegExp(`^Invalid session data: ${field} `), ); }); it.each([null, "a string", 42])("rejects a non-object %j", (value) => { expect(() => Client.fromJSON(value)).toThrow( /^Invalid session data: not a JSON object/, ); }); }); describe("Client.logout", () => { it("zeroes the key buffers and clears the token", () => { const client = Client.fromJSON(validSnapshot()); const api = client.getApiClient(); const keys = keyBuffers(client); client.logout(); for (const key of keys) { expect(key.length).toBe(32); expect(key.every((b) => b === 0)).toBe(true); } expect(api.getAuthToken()).toBeUndefined(); }); it("makes every later operation throw", async () => { const client = Client.fromJSON(validSnapshot()); client.logout(); expect(() => client.whoami()).toThrow(/logged out/); expect(() => client.toJSON()).toThrow(/logged out/); expect(() => client.getApiClient()).toThrow(/logged out/); expect(() => client.contentSource()).toThrow(/logged out/); await expect(client.listCollections()).rejects.toThrow(/logged out/); await expect(client.collectionsSince({ sinceTime: 0 })).rejects.toThrow( /logged out/, ); await expect( client.filesSince({ collectionID: 1, collectionKey: new Uint8Array(32), sinceTime: 0, }), ).rejects.toThrow(/logged out/); await expect( client.fetchMLData({ fileIDs: [1], fileKeys: new Map() }), ).rejects.toThrow(/logged out/); }); it("stops a listing in flight from decrypting with the zeroed keys", async () => { // The server answers only after the client has logged out. If the // listing went on to decrypt this row with all-zero keys it would fail // with a decryption error, not the logged-out one. const row = { id: 1, owner: { id: 42 }, encryptedKey: toBase64(new Uint8Array(48)), keyDecryptionNonce: toBase64(new Uint8Array(24)), updationTime: 1, }; const client: Client = Client.fromJSON(validSnapshot(), { fetch: async () => { client.logout(); return new Response(JSON.stringify({ collections: [row] }), { status: 200, headers: { "content-type": "application/json" }, }); }, }); await expect(client.listCollections()).rejects.toThrow(/logged out/); }); }); describe("loadSession", () => { let dir: string; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), "quak-session-test-")); }); afterAll(() => { rmSync(dir, { recursive: true, force: true }); }); it("returns null when there is no session file", () => { expect(loadSession(join(dir, "missing.json"))).toBeNull(); }); it("restores a client from a valid session file", () => { const path = join(dir, "valid.json"); writeFileSync(path, JSON.stringify(validSnapshot())); expect(loadSession(path)!.whoami()).toEqual({ email: "user@example.com", userID: 42, }); }); it("says the file is corrupt when it is not JSON", () => { const path = join(dir, "truncated.json"); writeFileSync(path, '{"email": "user@exa'); expect(() => loadSession(path)).toThrow( `Session file ${path} is corrupt`, ); }); it("says the file is corrupt and names the bad field", () => { const path = join(dir, "bad-key.json"); writeFileSync( path, JSON.stringify({ ...validSnapshot(), secretKey: "AAAA" }), ); expect(() => loadSession(path)).toThrow( new RegExp(`^Session file ${path} is corrupt: .*secretKey`), ); }); });