← back to Kickbacks Ai Upstream
test/auth.test.ts
621 lines
import { describe, it, expect, vi } from "vitest";
import { mkdtempSync, readFileSync, writeFileSync, statSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Mock the log module so these tests never touch the real debug log or
// read dev-machine sentinels / env vars that could flip assertions.
vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
dlogRaw: () => {}, codexEnabled: () => false, codexCliEnabled: () => false,
LOG_PATH: "/tmp/test-log" }));
import { AuthClient } from "../src/auth/client";
import { createVault } from "../src/auth/vault";
import { makeContext, _opened, _shown } from "./mocks/vscode";
// Hermetic fallback file per test (never touch the real ~/.vibe-ads).
const mkAuthFile = () => join(mkdtempSync(join(tmpdir(), "vibe-ads-auth-")), "auth.json");
// Hermetic vault: an unknown platform => "plain" scheme, so seal/open never
// shell out (fixture-only rule). Per-OS behavior is covered by vault.test.ts.
const noExec = (async () => { throw new Error("no exec in tests"); }) as never;
const pv = () => createVault("test", noExec);
describe("AuthClient", () => {
it("uses explicit dev-bypass as an in-memory token only for loopback bases", async () => {
const oldKickbacks = process.env.KICKBACKS_DEV_BYPASS;
const oldVibeAds = process.env.VIBE_ADS_DEV_BYPASS;
process.env.VIBE_ADS_DEV_BYPASS = "1";
delete process.env.KICKBACKS_DEV_BYPASS;
const s = new Map<string, string>(), g = new Map<string, unknown>();
const ctx = {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
const f = vi.fn(async () => ({ ok: false, status: 500 }) as Response);
try {
const a = new AuthClient("http://127.0.0.1:6080", ctx as never,
f as never, 0, mkAuthFile(), pv());
await a.loadCached();
expect(a.accessToken()).toBe("dev-bypass");
expect(a.signedIn()).toBe(true);
expect(await a.refresh()).toBe(true);
expect(f).not.toHaveBeenCalled();
expect(s.size).toBe(0);
} finally {
if (oldKickbacks === undefined) delete process.env.KICKBACKS_DEV_BYPASS;
else process.env.KICKBACKS_DEV_BYPASS = oldKickbacks;
if (oldVibeAds === undefined) delete process.env.VIBE_ADS_DEV_BYPASS;
else process.env.VIBE_ADS_DEV_BYPASS = oldVibeAds;
}
});
it("refuses dev-bypass when the backend base is not loopback", async () => {
const old = process.env.VIBE_ADS_DEV_BYPASS;
process.env.VIBE_ADS_DEV_BYPASS = "1";
const s = new Map<string, string>(), g = new Map<string, unknown>();
const ctx = {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
try {
const a = new AuthClient("https://api.kickbacks.ai", ctx as never,
(async () => ({ ok: false, status: 500 }) as Response) as never,
0, mkAuthFile(), pv());
await a.loadCached();
expect(a.accessToken()).toBeNull();
expect(a.signedIn()).toBe(false);
} finally {
if (old === undefined) delete process.env.VIBE_ADS_DEV_BYPASS;
else process.env.VIBE_ADS_DEV_BYPASS = old;
}
});
it("signIn opens the broker URL then polls until tokens, stores in SecretStorage", async () => {
const ctx = makeContext();
let polls = 0;
const f = vi.fn(async (url: string) => {
if (url.includes("/extension/start"))
return { status: 307,
headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null },
} as unknown as Response;
polls++;
if (polls < 2) return { ok: true, status: 200, json: async () => ({ status: "pending" }) } as Response;
return { ok: true, status: 200, json: async () =>
({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) } as Response;
});
const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
await a.signIn();
expect(_opened.some((u) => u.includes("https://g/auth"))).toBe(true);
expect(await ctx.secrets.get("kickbacks.access")).toBe("AT");
expect(await ctx.secrets.get("kickbacks.refresh")).toBe("RT");
expect(a.accessToken()).toBe("AT");
expect(a.signedIn()).toBe(true);
});
it("refresh swaps access AND persists the rotated refresh token", async () => {
const ctx = makeContext();
await ctx.secrets.store("kickbacks.refresh", "RT");
const f = vi.fn(async (url: string) => {
if (url.includes("/auth/refresh"))
return { ok: true, status: 200, json: async () =>
({ access_token: "AT2", refresh_token: "RT2", expires_in: 3600 }) } as Response;
return { ok: false, status: 500 } as Response;
});
const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
expect(await a.refresh()).toBe(true);
expect(a.accessToken()).toBe("AT2");
expect(await ctx.secrets.get("kickbacks.refresh")).toBe("RT2"); // rotation persisted
});
it("single-flights concurrent refresh() calls so the rotating token is consumed once", async () => {
// Two callers (status-bar earnings 401 + portfolio 401) racing on the
// SAME refresh token used to double-POST /refresh; S1 rotates on first
// use, so the second call sent a consumed token, 401'd, and nulled `at`
// — clobbering the first call's success. Single-flight must collapse
// them to ONE request.
const s = new Map<string, string>(), g = new Map<string, unknown>();
const ctx = {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
await ctx.secrets.store("kickbacks.refresh", "RT");
let calls = 0;
const f = vi.fn(async (url: string) => {
if (url.includes("/auth/refresh")) {
calls++;
return { ok: true, status: 200, json: async () =>
({ access_token: "AT2", refresh_token: "RT2" }) } as Response;
}
return { ok: false, status: 500 } as Response;
});
const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
const [r1, r2] = await Promise.all([a.refresh(), a.refresh()]);
expect(r1).toBe(true);
expect(r2).toBe(true);
expect(calls).toBe(1); // one POST, not two
expect(a.accessToken()).toBe("AT2");
});
it("refresh() recovers the token from the sealed file when SecretStorage is empty", async () => {
// Reinstall / keyring-less: secrets holds nothing, but a prior signIn
// sealed the token to the durable file. refresh() (called WITHOUT an
// explicit token, e.g. via a 401 retry) must find it there.
const isoCtx = () => {
const s = new Map<string, string>(), g = new Map<string, unknown>();
return {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
};
const file = mkAuthFile();
const signInF = vi.fn(async (url: string) =>
url.includes("/extension/start")
? { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
: { ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response);
const a1 = new AuthClient("http://b", isoCtx() as never, signInF as never, 0, file, pv());
await a1.signIn(); // seals plain:1:RT to `file`
const refreshF = vi.fn(async (url: string) =>
url.includes("/auth/refresh")
? { ok: true, status: 200, json: async () =>
({ access_token: "AT3", refresh_token: "RT3" }) } as Response
: { ok: false, status: 500 } as Response);
const a2 = new AuthClient("http://b", isoCtx() as never, refreshF as never, 0, file, pv());
expect(await a2.refresh()).toBe(true); // empty secrets -> file fallback
expect(a2.accessToken()).toBe("AT3");
});
it("fires the onSignedIn login trigger after a successful interactive sign-in", async () => {
const ctx = makeContext();
let fired = 0;
const f = vi.fn(async (url: string) =>
url.includes("/extension/start")
? { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
: { ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response);
const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
a.setOnSignedIn(() => { fired++; });
await a.signIn();
expect(fired).toBe(1); // login trigger → immediate reassert
});
it("always-writes a sealed envelope to the file (the keyring-less Linux fix)", async () => {
const file = mkAuthFile();
const ctx = makeContext();
const f = vi.fn(async (url: string) => {
if (url.includes("/extension/start"))
return { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
return { ok: true, status: 200, json: async () =>
({ access_token: "AT", refresh_token: "RT" }) } as Response;
});
const a = new AuthClient("http://b", ctx as never, f as never, 0, file, pv());
await a.signIn();
const fb = JSON.parse(readFileSync(file, "utf8"));
expect(fb.refresh).toBe("plain:1:RT"); // sealed envelope, not bare token
expect(fb.refresh).not.toBe("RT");
});
it("survives a reinstall: empty SecretStorage recovers via the sealed file", async () => {
const isoCtx = () => {
const s = new Map<string, string>(), g = new Map<string, unknown>();
return {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
};
const file = mkAuthFile();
const ctx1 = isoCtx();
const f1 = vi.fn(async (url: string) => {
if (url.includes("/extension/start"))
return { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
return { ok: true, status: 200, json: async () =>
({ access_token: "AT", refresh_token: "RT" }) } as Response;
});
const a1 = new AuthClient("http://b", ctx1 as never, f1 as never, 0, file, pv());
await a1.signIn();
const ctx2 = isoCtx(); // brand-new namespace (= reinstall/rename)
const f2 = vi.fn(async (url: string) => {
if (url.includes("/auth/refresh"))
return { ok: true, status: 200, json: async () =>
({ access_token: "AT-NEW", refresh_token: "RT2" }) } as Response;
return { ok: false, status: 500 } as Response;
});
const a2 = new AuthClient("http://b", ctx2 as never, f2 as never, 0, file, pv());
expect(a2.accessToken()).toBeNull();
await a2.loadCached();
expect(a2.accessToken()).toBe("AT-NEW"); // no re-sign-in
});
it("upgrades a PRE-VAULT plaintext refresh token in place on first read", async () => {
// Isolated ctx — makeContext() shares a module-global secrets Map, which
// would leak a prior test's tokens and skip the file-recovery path.
const isoCtx = () => {
const s = new Map<string, string>(), g = new Map<string, unknown>();
return {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
};
const file = mkAuthFile();
// Simulate an older build's file: bare token, no envelope prefix.
const seed = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
const cid = seed.clientId();
require("node:fs").writeFileSync(file,
JSON.stringify({ refresh: "LEGACY-RT", clientId: cid }));
const f = vi.fn(async (url: string) =>
url.includes("/auth/refresh")
? { ok: true, status: 200, json: async () =>
({ access_token: "AT", refresh_token: "RT2" }) } as Response
: { ok: false, status: 500 } as Response);
const a = new AuthClient("http://b", isoCtx() as never, f as never, 0, file, pv());
await a.loadCached();
expect(a.accessToken()).toBe("AT"); // legacy token still worked
const fb = JSON.parse(readFileSync(file, "utf8"));
expect(fb.refresh.startsWith("plain:1:")).toBe(true); // re-sealed (upgraded)
});
it("signOut clears all stores incl. the OS vault entry; no silent re-mint", async () => {
const isoCtx = () => {
const s = new Map<string, string>(), g = new Map<string, unknown>();
return {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
};
const file = mkAuthFile();
const vault = pv();
const clearSpy = vi.spyOn(vault, "clear");
const ctx1 = isoCtx();
const f1 = vi.fn(async (url: string) =>
url.includes("/extension/start")
? { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
: { ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response);
const a1 = new AuthClient("http://b", ctx1 as never, f1 as never, 0, file, vault);
await a1.signIn();
const id1 = a1.clientId();
expect(a1.signedIn()).toBe(true);
await a1.signOut();
expect(a1.signedIn()).toBe(false);
expect(a1.accessToken()).toBeNull();
expect(clearSpy).toHaveBeenCalled(); // OS-store entry purged
expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBeUndefined();
expect(JSON.parse(readFileSync(file, "utf8")).clientId).toBe(id1); // anon id kept
const ctx2 = isoCtx(); // reinstall after sign-out MUST stay signed out
const a2 = new AuthClient("http://b", ctx2 as never,
(async () => ({ ok: false, status: 500 })) as never, 0, file, pv());
await a2.loadCached();
expect(a2.accessToken()).toBeNull();
expect(a2.clientId()).toBe(id1);
});
// BL-188: sign-out must also revoke the rotating refresh token SERVER-side
// — pre-fix it stayed mintable for its full TTL after a client-local clear.
it("signOut posts the refresh token to /v1/auth/signout for revocation", async () => {
const file = mkAuthFile();
const calls: Array<{ url: string; init?: RequestInit }> = [];
const f = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return url.includes("/extension/start")
? { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
: { ok: true, status: 200,
json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response;
});
const a = new AuthClient("http://b", makeContext() as never, f as never, 0, file, pv());
await a.signIn();
await a.signOut();
const revoke = calls.find((c) => c.url.includes("/v1/auth/signout"));
expect(revoke).toBeTruthy();
expect(revoke!.init?.method).toBe("POST");
expect(String(revoke!.init?.body)).toContain("RT"); // the stored token
expect(a.signedIn()).toBe(false); // local clear intact
});
it("signOut still completes (and clears locally) when revocation fails", async () => {
const file = mkAuthFile();
const f = vi.fn(async (url: string) => {
if (url.includes("/extension/start")) {
return { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
}
if (url.includes("/v1/auth/signout")) {
throw new Error("offline"); // revocation refused
}
return { ok: true, status: 200,
json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response;
});
const a = new AuthClient("http://b", makeContext() as never, f as never, 0, file, pv());
await a.signIn();
await a.signOut(); // must not throw
expect(a.signedIn()).toBe(false);
expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBeUndefined();
});
it("clientId is a stable persisted anon id (survives a fresh ctx via file)", async () => {
const file = mkAuthFile();
const a1 = new AuthClient("http://b", makeContext() as never, (async () => ({})) as never, 0, file, pv());
const id1 = a1.clientId();
expect(id1.length).toBeGreaterThanOrEqual(16);
const a2 = new AuthClient("http://b", makeContext() as never, (async () => ({})) as never, 0, file, pv());
expect(a2.clientId()).toBe(id1);
});
it("storageInfo reports the active scheme + keyring health", async () => {
const ctx = makeContext();
const a = new AuthClient("http://b", ctx as never,
(async () => ({ ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) })) as never,
0, mkAuthFile(), pv());
expect(a.storageInfo().scheme).toBe("plain");
expect(a.storageInfo().keyringDurable).toBeUndefined(); // not probed yet
});
});
// audit BL-015 (wave-2A H1 coverage): a refresh() failure must clear the
// in-memory access token so signedIn() flips to false at the right moment.
// Pre-fix a dead token lingered until explicit signOut() and every backend
// call 401'd while signedIn() still reported true.
describe("AuthClient refresh failure clears session (H1)", () => {
it("H1: refresh() with no stored token clears in-memory `at`", async () => {
vi.resetModules();
vi.doMock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
dlogRaw: () => {}, codexEnabled: () => false,
LOG_PATH: "/tmp/t" }));
const { AuthClient: AC } = await import("../src/auth/client");
const ctx = makeContext();
// Seed with a "dead" access token (no refresh token in storage).
await ctx.secrets.store("kickbacks.access", "DEAD");
const a = new AC("http://localhost:6080", ctx as never,
(async () => ({ ok: false, status: 401 })) as never,
0, mkAuthFile(), pv());
await a.loadCached(); // pulls DEAD into this.at
expect(a.accessToken()).toBe("DEAD"); // pre-fix behavior persists
const refreshed = await a.refresh(); // no refresh token -> false
expect(refreshed).toBe(false);
// Post-fix: at is cleared, so signedIn() flips to false.
expect(a.accessToken()).toBeNull();
expect(a.signedIn()).toBe(false);
vi.doUnmock("../src/log");
vi.resetModules();
});
});
// Shared isolated ctx for the audit-fix suites below (makeContext shares a
// module-global secrets Map; these need a clean per-test namespace).
const isoCtx = () => {
const s = new Map<string, string>(), g = new Map<string, unknown>();
return {
secrets: { get: async (k: string) => s.get(k),
store: async (k: string, v: string) => { s.set(k, v); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
};
// Locked/absent Secret Service (keyring-less Linux): get works, store THROWS
// — the env the file fallback exists for. `seed` pre-populates the cache.
const lockedCtx = (seed?: [string, string][]) => {
const s = new Map<string, string>(seed), g = new Map<string, unknown>();
return {
secrets: { get: async (k: string) => s.get(k),
store: async () => {
throw new Error("Cannot create an item in a locked collection"); },
delete: async (k: string) => { s.delete(k); } },
globalState: { get: (k: string) => g.get(k),
update: async (k: string, v: unknown) => { g.set(k, v); } },
subscriptions: [],
};
};
// audit 2026-06-09 #11: ctx.secrets.store() is best-effort — a throwing
// keyring must not abort activation (loadCached) nor turn a server-side-
// successful refresh into a sign-out after the rotating token was consumed.
describe("AuthClient keyring store failures are best-effort (#11)", () => {
it("loadCached survives a throwing keyring store and still re-mints from the file", async () => {
const file = mkAuthFile();
writeFileSync(file, JSON.stringify({ refresh: "plain:1:RT", clientId: "cid11" }));
const f = vi.fn(async (url: string) =>
url.includes("/auth/refresh")
? { ok: true, status: 200, json: async () =>
({ access_token: "AT", refresh_token: "RT2" }) } as Response
: { ok: false, status: 500 } as Response);
const a = new AuthClient("http://b", lockedCtx() as never, f as never, 0, file, pv());
await a.loadCached(); // pre-fix: REJECTED at the re-warm store()
expect(a.accessToken()).toBe("AT");
expect(a.signedIn()).toBe(true);
// The rotated token still landed in the durable file (sealToFile).
expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBe("plain:1:RT2");
});
it("a successful rotation with a dead keyring still counts as success", async () => {
const file = mkAuthFile();
const f = vi.fn(async (url: string) =>
url.includes("/auth/refresh")
? { ok: true, status: 200, json: async () =>
({ access_token: "AT2", refresh_token: "RT2" }) } as Response
: { ok: false, status: 500 } as Response);
const a = new AuthClient("http://b",
lockedCtx([["kickbacks.refresh", "RT"]]) as never, f as never, 0, file, pv());
expect(await a.refresh()).toBe(true); // pre-fix: false (store threw)
expect(a.accessToken()).toBe("AT2");
expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBe("plain:1:RT2");
expect(a.storageInfo().keyringDurable).toBe(false); // probe sees dead keyring
});
});
// audit 2026-06-09 #37: interactive sign-in is single-flighted, and a still-
// running poll loop exits silently if sign-in arrived via another path.
describe("AuthClient interactive sign-in single-flight (#37)", () => {
it("concurrent signIn() calls coalesce onto ONE state + browser tab + poll loop", async () => {
const openedBefore = _opened.length;
let starts = 0, polls = 0;
const f = vi.fn(async (url: string) => {
if (url.includes("/extension/start")) {
starts++;
return { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
}
polls++;
if (polls < 2) return { ok: true, status: 200, json: async () => ({ status: "pending" }) } as Response;
return { ok: true, status: 200, json: async () =>
({ access_token: "AT", refresh_token: "RT" }) } as Response;
});
const a = new AuthClient("http://b", isoCtx() as never, f as never, 0, mkAuthFile(), pv());
const [r1, r2] = await Promise.all([a.signIn(), a.signIn()]);
expect(r1).toBe(true);
expect(r2).toBe(true);
expect(starts).toBe(1); // pre-fix: 2 parallel flows
expect(_opened.length - openedBefore).toBe(1); // one browser tab, not two
});
it("a still-polling loop exits silently (no error toast) once signed in via another path", async () => {
const shownBefore = _shown.length;
let polls = 0;
let client: AuthClient | undefined;
const f = vi.fn(async (url: string) => {
if (url.includes("/extension/start"))
return { status: 307, headers: { get: (k: string) =>
k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
if (url.includes("/auth/refresh"))
return { ok: true, status: 200, json: async () =>
({ access_token: "AT-R", refresh_token: "RT2" }) } as Response;
polls++; // poll endpoint: this state NEVER completes
if (polls === 2) await client?.refresh("RT"); // background path signs us in
return { ok: true, status: 200, json: async () => ({ status: "pending" }) } as Response;
});
client = new AuthClient("http://b", isoCtx() as never, f as never, 0, mkAuthFile(), pv());
expect(await client.signIn()).toBe(true); // pre-fix: false after 120 polls
expect(polls).toBeLessThan(120); // pre-fix: full 3-minute loop
expect(client.accessToken()).toBe("AT-R");
// No "sign-in timed out" toast minutes after the user already signed in.
expect(_shown.slice(shownBefore).filter((m) => m.kind === "error")).toEqual([]);
});
});
// audit 2026-06-09 #33: clientId() runs on EVERY metrics send; it must not
// read-merge-write auth.json unless the id is actually missing there — the
// unconditional RMW could resurrect a refresh envelope another window had
// just rotated (single-use token => clobber = sign-out).
describe("AuthClient clientId() write discipline (#33)", () => {
it("does NOT rewrite auth.json when the id is already on disk", async () => {
const file = mkAuthFile();
const a1 = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
const id = a1.clientId(); // first call creates the file
utimesSync(file, 1, 1); // sentinel mtime
const stamp = statSync(file).mtimeMs;
expect(a1.clientId()).toBe(id); // warm path (globalState hit)
const a2 = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
expect(a2.clientId()).toBe(id); // cold path (id read FROM file)
// pre-fix: both calls unconditionally rewrote the file (mtime moves).
expect(statSync(file).mtimeMs).toBe(stamp);
});
it("a needed write merges with the on-disk token envelope, never clobbers it", async () => {
const file = mkAuthFile();
// Another window just sealed a rotated token; this window has no id yet.
writeFileSync(file, JSON.stringify({ refresh: "plain:1:ROTATED" }));
const a = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
const id = a.clientId(); // must write (id missing on disk)
const fb = JSON.parse(readFileSync(file, "utf8"));
expect(fb.clientId).toBe(id);
expect(fb.refresh).toBe("plain:1:ROTATED"); // envelope preserved
});
});
// audit 2026-06-09 #10: only an EXPLICIT server rejection may discard tokens.
// Pre-fix, _refresh's catch (and every !ok status) nulled `at` — a pure
// network blip during the activation-time forced refresh permanently signed
// the user out in-memory (demo demotion, user-credit loss) with no retry path.
describe("AuthClient refresh transient-vs-fatal (#10)", () => {
// Signed-in client with AT0 in memory and RT in secrets; `f` is swappable.
const mkSignedIn = async (f: (url: string) => Promise<unknown>) => {
const ctx = isoCtx();
await ctx.secrets.store("kickbacks.access", "AT0");
await ctx.secrets.store("kickbacks.refresh", "RT");
const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
await a.loadCached();
expect(a.accessToken()).toBe("AT0");
return a;
};
it("a NETWORK throw keeps the tokens; a later refresh() succeeds", async () => {
let online = false;
const a = await mkSignedIn(async (url: string) => {
if (!url.includes("/auth/refresh")) return { ok: false, status: 500 };
if (!online) throw new Error("ENOTFOUND"); // Wi-Fi not up yet
return { ok: true, status: 200, json: async () =>
({ access_token: "AT2", refresh_token: "RT2" }) };
});
expect(await a.refresh()).toBe(false); // transient failure reported...
expect(a.accessToken()).toBe("AT0"); // ...but session NOT discarded
expect(a.signedIn()).toBe(true); // pre-fix: false forever
online = true; // network returns minutes later
expect(await a.refresh()).toBe(true); // retry path works
expect(a.accessToken()).toBe("AT2");
});
it("a 5xx is transient: tokens kept", async () => {
const a = await mkSignedIn(async () => ({ ok: false, status: 503 }));
expect(await a.refresh()).toBe(false);
expect(a.accessToken()).toBe("AT0"); // pre-fix: nulled on any !ok
expect(a.signedIn()).toBe(true);
});
it("an explicit 401 still clears the session (H1 contract preserved)", async () => {
const a = await mkSignedIn(async () => ({ ok: false, status: 401 }));
expect(await a.refresh()).toBe(false);
expect(a.accessToken()).toBeNull(); // explicit rejection => signed out
expect(a.signedIn()).toBe(false);
});
it("a 400 with an invalid_grant-style body is an explicit rejection", async () => {
const a = await mkSignedIn(async () => ({ ok: false, status: 400,
clone: () => ({ text: async () => '{"detail":"invalid_grant"}' }) }));
expect(await a.refresh()).toBe(false);
expect(a.accessToken()).toBeNull();
});
it("a 4xx WITHOUT a rejection body (gateway noise) is transient", async () => {
const a = await mkSignedIn(async () => ({ ok: false, status: 429,
clone: () => ({ text: async () => "rate limited" }) }));
expect(await a.refresh()).toBe(false);
expect(a.accessToken()).toBe("AT0");
});
});