fix(chatgpt): use private JSON credential storage without Keychain
This commit is contained in:
+72
-29
@@ -7,7 +7,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createChatGPT, encryptedStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
||||
const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
||||
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
||||
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
|
||||
const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
|
||||
@@ -16,14 +16,14 @@ async function signed(client: string, nonce: string, subject = "scratch-user", o
|
||||
return new SignJWT({ nonce, email: "dummy@example.invalid", ...overrides }).setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).setIssuer("https://auth.openai.com").setSubject(subject).setAudience(client).setIssuedAt().setExpirationTime("1h").sign(privateKey);
|
||||
}
|
||||
function deferred<T = void>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
||||
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; secure?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
|
||||
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; read?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
|
||||
let saved = structuredClone(options.state ?? initial());
|
||||
let url!: URL;
|
||||
let writeCount = 0;
|
||||
const changed = deferred();
|
||||
const requests: { url: string; form?: URLSearchParams }[] = [];
|
||||
const service = createChatGPT({
|
||||
store: { secure() { options.secure?.(); }, async read() { return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
|
||||
store: { async read() { options.read?.(); return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
|
||||
openExternal: async (value: string) => { url = new URL(value); },
|
||||
timeoutMs: options.timeoutMs ?? 5000,
|
||||
onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
|
||||
@@ -153,7 +153,7 @@ test("missing plan scope retains verified registration but refuses inference and
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("durable encrypted save failure never activates a pending login", async () => {
|
||||
test("durable file save failure never activates a pending login", async () => {
|
||||
const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
|
||||
try {
|
||||
await f.service.login(); await f.callback(); await f.changed.promise;
|
||||
@@ -162,6 +162,40 @@ test("durable encrypted save failure never activates a pending login", async ()
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("rotated-token persistence failure invalidates existing leases and never reloads the stale saved token", async () => {
|
||||
let failWrite = false, reads = 0, refreshes = 0;
|
||||
const pushes: any[] = [];
|
||||
const f = await loggedIn({ read: () => { reads++; }, change: (push) => pushes.push(push),
|
||||
write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); },
|
||||
token: async (form, url) => {
|
||||
if (form.get("grant_type") === "refresh_token") {
|
||||
refreshes++;
|
||||
return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", token_type: "Bearer", expires_in: 3600 });
|
||||
}
|
||||
return Response.json({ access_token: "old-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const catalog = await f.service.models();
|
||||
const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
|
||||
await f.rawLease(false, expected);
|
||||
// Advance time rather than mutating the service's private state.
|
||||
const now = Date.now; Date.now = () => now() + 3600000;
|
||||
try {
|
||||
failWrite = true;
|
||||
await assert.rejects(f.rawLease(false, expected), /storage/);
|
||||
} finally { Date.now = now; }
|
||||
assert.equal(refreshes, 1);
|
||||
assert.equal(f.saved.registrations[0].tokens.refreshToken, "old-refresh", "failed write leaves only the stale predecessor on disk");
|
||||
assert(pushes.some((push) => push.invalidate === "all" && push.stamp.epoch > catalog.epoch));
|
||||
failWrite = false;
|
||||
await f.service.status(); await f.service.models(); f.service.resume(); await f.service.consent(true);
|
||||
await assert.rejects(f.rawLease(false, expected)); await assert.rejects(f.rawLease(true, expected));
|
||||
assert.equal((await f.service.status()).accounts[0].connected, false);
|
||||
assert.equal(reads, 1); assert.equal(refreshes, 1, "no stale-token retry after storage recovers");
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("serialized concurrent refresh rotates once and persists before leases", async () => {
|
||||
let refreshes = 0;
|
||||
const f = await loggedIn({ expires: 1, token: async (form, url) => {
|
||||
@@ -208,28 +242,33 @@ test("account catalog is scoped, visibility filtered, and cleared on disconnect"
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => {
|
||||
test("private JSON file round-trips global state; creation and atomic replacement are owner-only", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
|
||||
const file = path.join(dir, "credentials.enc.json");
|
||||
const secure = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => "keychain", encryptString: (s: string) => Buffer.from(s.split("").reverse().join("")), decryptString: (b: Buffer) => b.toString().split("").reverse().join("") };
|
||||
const file = path.join(dir, "private", "chatgpt-credentials.json");
|
||||
try {
|
||||
const store = encryptedStore(file, secure);
|
||||
const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
|
||||
const store = fileStore(file);
|
||||
assert.equal(await store.read(), null);
|
||||
const data = { ...initial(), allowBackgroundUse: false };
|
||||
await store.write(data);
|
||||
assert(!(await fs.readFile(file, "utf8")).includes(data.marker));
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), data, "ordinary JSON, not an encrypted envelope");
|
||||
assert.deepEqual(await store.read(), data);
|
||||
assert.equal((await fs.stat(path.dirname(file))).mode & 0o777, 0o700);
|
||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
|
||||
assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]);
|
||||
for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) {
|
||||
await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/);
|
||||
await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/);
|
||||
}
|
||||
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
|
||||
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
|
||||
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
|
||||
await fs.chmod(file, 0o644);
|
||||
await store.write({ ...data, allowBackgroundUse: true });
|
||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o600, "replacement never inherits a permissive old mode");
|
||||
assert.equal((await store.read()).allowBackgroundUse, true);
|
||||
assert.deepEqual(await fs.readdir(path.dirname(file)), ["chatgpt-credentials.json"]);
|
||||
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("revocation endpoint remains restricted to the exact secure issuer", () => {
|
||||
assert(allowedAuthEndpoint("https://auth.openai.com/api/accounts/oauth/revoke"));
|
||||
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
|
||||
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
|
||||
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
|
||||
});
|
||||
|
||||
test("invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart", async () => {
|
||||
const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token"
|
||||
? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 })
|
||||
@@ -255,39 +294,43 @@ test("invalid rotating replacement is durably revocation-only; verified mapping
|
||||
assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
|
||||
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
||||
await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise;
|
||||
// Wait for callback's asynchronous encrypted activation, not the prior status push.
|
||||
// Wait for callback's asynchronous durable activation, not the prior status push.
|
||||
for (let i = 0; i < 30 && restarted.saved.registrations[0].reconnectRequired; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
|
||||
} finally { restarted.service.close(); }
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("detected secure backend loss emits full invalidation, denies all leases and cannot be repaired by consent/resume", async () => {
|
||||
let lost = false; const pushes: any[] = [];
|
||||
const f = await loggedIn({ secure: () => { if (lost) throw Object.assign(new Error("secure_storage"), { code: "secure_storage" }); }, change: (push) => pushes.push(push) });
|
||||
test("observed file write failure invalidates all work; status, consent and resume cannot reload stale credentials or repair authority", async () => {
|
||||
let lost = false, reads = 0; const pushes: any[] = [];
|
||||
const f = await loggedIn({ read: () => { reads++; }, write: async () => { if (lost) throw Object.assign(new Error("storage"), { code: "storage" }); }, change: (push) => pushes.push(push) });
|
||||
try {
|
||||
await f.service.lease(false); const epoch = f.service.stamp().epoch;
|
||||
lost = true;
|
||||
assert.equal((await f.service.status()).available, false);
|
||||
await assert.rejects(f.service.consent(false), /storage/);
|
||||
assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch));
|
||||
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
|
||||
lost = false; f.service.resume(); await f.service.consent(true);
|
||||
await f.service.status(); await f.service.models();
|
||||
await assert.rejects(f.service.lease(false));
|
||||
assert.equal((await f.service.status()).accounts[0].connected, false);
|
||||
assert.equal(reads, 1, "a recovered disk never causes a stale credential reload");
|
||||
await f.service.login(f.saved.activeId); await f.callback();
|
||||
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal((await f.service.status()).accounts[0].connected, true, "verified login plus durable save can explicitly reconnect");
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
|
||||
test("strict JSON schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
|
||||
const f = await loggedIn(); f.service.close();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
|
||||
const file = path.join(dir, "credentials.enc.json");
|
||||
const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
|
||||
const file = path.join(dir, "chatgpt-credentials.json");
|
||||
try {
|
||||
for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1, (s: any) => s.allowBackgroundUse = "true", (s: any) => s.allowBackgroundUse = null, (s: any) => s.registrations[0].background = "false"]) {
|
||||
const state = structuredClone(f.saved); mutate(state);
|
||||
const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
|
||||
const bytes = JSON.stringify(state);
|
||||
await fs.writeFile(file, bytes);
|
||||
const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
|
||||
const service = createChatGPT({ store: fileStore(file), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
|
||||
try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); }
|
||||
finally { service.close(); }
|
||||
}
|
||||
@@ -459,7 +502,7 @@ test("disconnect racing refresh revokes the latest rotated handle before forgett
|
||||
} finally { release.resolve(); f.service.close(); }
|
||||
});
|
||||
|
||||
test("disconnect persistence failure is not reported as local forgetting and leaves encrypted state fail-closed", async () => {
|
||||
test("disconnect persistence failure is not reported as local forgetting and leaves stored state fail-closed", async () => {
|
||||
let failWrite = false;
|
||||
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected });
|
||||
const disconnected = (allowBackgroundUse = true): ChatGPTStatus => ({ available: true, pending: false, allowBackgroundUse, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
|
||||
const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnected(allowBackgroundUse), activeId: "private-registration-id", accounts: [account(), ...disconnected().accounts] });
|
||||
const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests.";
|
||||
const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.";
|
||||
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
|
||||
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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/);
|
||||
});
|
||||
@@ -166,7 +166,7 @@ test("case-insensitive Authorization overrides including null are refused before
|
||||
assert.equal(requests.length, before);
|
||||
});
|
||||
|
||||
test("full secure-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||
test("full credential-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
||||
assert(foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert.equal(chatGPTModels().length, 0);
|
||||
|
||||
Reference in New Issue
Block a user