Files
learnharness/tests/chatgpt-file-store.test.ts
T

126 lines
7.0 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { createChatGPT, fileStore } = require("../electron/chatgpt.cjs");
const initial = () => ({ version: 1, hostId: "urn:uuid:scratch-host", activeId: null, registrations: [] });
const account = (id: string, subject: string) => ({
id, clientId: `oaiapp_${id}`, issuer: "https://auth.openai.com", subject, email: `${subject}@example.invalid`,
tokens: { accessToken: `scratch-access-${id}`, refreshToken: `scratch-refresh-${id}`, idToken: `scratch-id-${id}`, expiresAt: Date.now() + 3600000, scopes: ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"] },
});
function service(store: unknown) {
return createChatGPT({ store, openExternal: () => assert.fail("no browser sign-in expected"), fetch: () => assert.fail("no provider request expected") });
}
test("ordinary startup restores saved accounts and global OFF from JSON without OAuth or native storage", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-restore-"));
const file = path.join(dir, "chatgpt-credentials.json");
const state = { ...initial(), activeId: "B", registrations: [account("A", "first"), account("B", "second")], allowBackgroundUse: false };
let connection;
try {
await fileStore(file).write(state);
connection = service(fileStore(file));
const status = await connection.status();
assert.equal(status.available, true);
assert.equal(status.activeId, "B");
assert.equal(status.allowBackgroundUse, false);
assert.deepEqual(status.accounts.map((a: { email: string; connected: boolean }) => [a.email, a.connected]), [["first@example.invalid", true], ["second@example.invalid", true]]);
assert.doesNotMatch(JSON.stringify(status), /scratch-access|scratch-refresh|scratch-id/);
await connection.status();
assert.deepEqual(await fileStore(file).read(), state, "startup/status does not change saved credentials");
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
});
test("new store ignores experimental encrypted files and never alters the separate API-key file", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-fresh-"));
const file = path.join(dir, "chatgpt-credentials.json");
const experimental = path.join(dir, "chatgpt-credentials.enc.json");
const auth = path.join(dir, "auth.json");
const oldBytes = "not an envelope to parse, decrypt, migrate or delete";
const apiBytes = '{"OPENAI_API_KEY":"scratch-api-key"}\n';
let connection;
try {
await fs.writeFile(experimental, oldBytes);
await fs.writeFile(auth, apiBytes);
connection = service(fileStore(file));
const status = await connection.status();
assert.equal(status.available, true); assert.equal(status.allowBackgroundUse, true);
assert.equal(status.activeId, null); assert.deepEqual(status.accounts, []);
assert.deepEqual((await connection.models()).models, []);
await connection.consent(false);
connection.close(); connection = service(fileStore(file));
assert.equal((await connection.status()).allowBackgroundUse, false, "zero-account preference survives a real file-backed restart");
assert.equal(await fs.readFile(experimental, "utf8"), oldBytes);
assert.equal(await fs.readFile(auth, "utf8"), apiBytes);
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
});
for (const bytes of ["{broken-json", "null", "[]", '{"version":1,"encrypted":"ZXhwZXJpbWVudA=="}']) {
test(`invalid JSON/record fails closed without overwriting it: ${bytes}`, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-invalid-"));
const file = path.join(dir, "chatgpt-credentials.json");
let connection;
try {
await fs.writeFile(file, bytes);
connection = service(fileStore(file));
const status = await connection.status();
assert.equal(status.available, false); assert.equal(status.activeId, null);
assert.match(status.error, /could not be read or saved/);
assert.deepEqual((await connection.models()).models, []);
await assert.rejects(connection.login(), /storage/);
assert.equal(await fs.readFile(file, "utf8"), bytes);
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
});
}
for (const phase of ["sync", "rename"] as const) {
test(`failed ${phase} leaves the previous JSON intact and removes temporary files`, async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-atomic-"));
const file = path.join(dir, "chatgpt-credentials.json");
try {
const store = fileStore(file);
await store.write(initial());
const before = await fs.readFile(file, "utf8");
if (phase === "rename") t.mock.method(fs, "rename", async () => { throw new Error("scratch rename failure"); });
else {
const open = fs.open.bind(fs);
t.mock.method(fs, "open", async (...args: Parameters<typeof fs.open>) => {
const handle = await open(...args);
if (args[1] === "wx") t.mock.method(handle, "sync", async () => { throw new Error("scratch sync failure"); });
return handle;
});
}
await assert.rejects(store.write({ ...initial(), allowBackgroundUse: false }), /^Error: storage$/);
assert.equal(await fs.readFile(file, "utf8"), before);
assert.deepEqual(await fs.readdir(dir), ["chatgpt-credentials.json"]);
} finally { await fs.rm(dir, { recursive: true, force: true }); }
});
}
test("file read/write failures are sanitized and cannot leak credential or path details", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-io-"));
try {
// Reading a directory and writing beneath a regular file both fail on all supported platforms.
await assert.rejects(fileStore(dir).read(), /^Error: storage$/);
const blocker = path.join(dir, "scratch-private-path"); await fs.writeFile(blocker, "blocker");
const connection = service(fileStore(path.join(blocker, "chatgpt-credentials.json")));
try {
const status = await connection.status();
assert.equal(status.available, false);
assert.doesNotMatch(status.error, /scratch-private-path|ENOTDIR|EISDIR/);
await assert.rejects(connection.login(), /^Error: storage$/);
} finally { connection.close(); }
await assert.rejects(fileStore(path.join(blocker, "credentials.json")).write({ ...initial(), secret: "scratch-token" }), /^Error: storage$/);
} finally { await fs.rm(dir, { recursive: true, force: true }); }
});
test("desktop wiring uses only the private JSON path, with no Electron safeStorage dependency", async () => {
const main = await fs.readFile(new URL("../electron/main.cjs", import.meta.url), "utf8");
const auth = await fs.readFile(new URL("../electron/chatgpt.cjs", import.meta.url), "utf8");
assert.match(main, /store: fileStore\(path\.join\(app\.getPath\("userData"\), "chatgpt-credentials\.json"\)\)/);
assert.doesNotMatch(main + auth, /safeStorage|encryptedStore|chatgpt-credentials\.enc\.json/);
});