Files
OmniRoute/tests/unit/token-health-check-cursor.test.ts
Will Gordon 57744aeb14 feat(cursor): proactively renews Cursor sessions and fixes manual refresh (#9173)
* refactor(cursor): extracts token extraction into shared lib

Moves tryIdeAuth/tryAgentAuth and supporting helpers out of the
auto-import route into src/lib/cursor/tokenExtractor.ts, and adds
an agent-cli-state.json fallback candidate path to tryAgentAuth
(alongside the existing auth.json candidate) so the extraction
logic can be reused by the upcoming renewal orchestrator.

* feat(cursor): adds cursor-agent-backed token renewal orchestrator

Builds the renewal orchestrator in src/lib/cursor/renewal.ts: a
bounded, unattended-safe --list-models nudge, a side-effect-free
status availability check, an in-flight spawn lock keyed by
command, and renewCursorConnection() which nudges cursor-agent
then independently re-scrapes the IDE and cursor-agent credential
sources to detect whichever refreshed. Extends cursorAgent.ts's
binary resolution and spawn helper with fixed-paths-only mode and
a SIGKILL follow-up for background use. Adds a generic keyed-mutex
utility (src/shared/utils/keyedMutex.ts) for serializing a
connection's renew-then-persist cycle, and forwards a busy-timeout
through driverFactory's node:sqlite fallback path.

* feat(cursor): proactively renews Cursor sessions in the sweep

Adds src/lib/tokenHealthCheckCursor.ts, sweep-side glue that calls
the renewal orchestrator and persists the result, wired into
tokenHealthCheck.ts's checkConnection() via a new Cursor-specific
branch placed ahead of the generic no-refresh-token fallthrough.
Carves out a non-terminal exception for a Cursor connection that
already landed at testStatus "expired" via the request-time 401
path, excluding permanently-dead account_deactivated connections.
Extends buildRefreshFailureUpdate() with an overrides param so
Cursor's failure path can use a distinct, non-terminal errorCode
instead of the generic refresh_failed/expired taxonomy.

* feat(cursor): adds local-only manual refresh route

Adds POST /api/providers/[id]/refresh-cursor, a dedicated
loopback-only route that calls the renewal orchestrator on demand
for a single Cursor connection, bounded by a 30s per-connection
cooldown. Classifies the new route in LOCAL_ONLY_API_PATTERNS and
closes the manage-scope-bypass gap for dynamic-segment spawn-capable
routes under /api/providers/ via a new SPAWN_CAPABLE_PATTERNS /
SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism, which also retroactively
covers the pre-existing /login route. The existing shared
/api/providers/[id]/refresh route is untouched and stays
remote-reachable for every other provider.

* feat(cursor): surfaces a dismissible cursor-agent nudge

Adds GET /api/providers/cursor/agent-availability, a credential-free
LOCAL_ONLY route returning only { cursorAgentAvailable: boolean },
backed by a 5-minute cached wrapper around the renewal orchestrator's
existing availability check. Surfaces a dismissible dashboard banner
on the Cursor provider page suggesting cursor-agent installation
when it isn't detected, following the existing dismissible-banner
convention. Also fixes a pre-existing bracket character in a
routeGuard.ts comment that was silently truncating
check-openapi-security-tiers.mjs's view of LOCAL_ONLY_API_PREFIXES.

* fix(cursor): wires manual refresh button to the new route

Branches handleRefreshToken to call the dedicated Cursor refresh
route instead of the generic /refresh route, which silently 502s
for Cursor connections today since they carry no refresh token.
Every other provider's refresh behavior is unaffected. Adds the
cursorSessionUnchanged i18n key and syncs it (plus a pre-existing,
unrelated 28-key backlog) across all 42 locale files.

* fix(cursor): addresses Phase 4/4.5 review findings

Restores the legacy stdout/stderr auth-pattern fallback in
checkCursorAgentAvailability() that the plan's Task 2 Step 4
required but the implementation had dropped. Threads an optional
deps parameter through checkCursorConnectionIfNeeded() so its
error branch is reachable in tests, and switches both it and the
manual-refresh route to exhaustive switch statements over the
renewal result. Adds a short-lived host-keyed dedup cache around
tryIdeAuth() so multiple due Cursor connections sharing a host
don't each open the same state.vscdb file in one sweep tick.
Adds opportunistic eviction to the manual-refresh cooldown map,
an outer try/catch to the availability route for defense-in-depth
consistency with the plan's other routes, and corrects a stale
JSDoc claim about the /login route's auth check. Documents the
now-empirically-confirmed agent-cli-state.json schema mismatch
found while validating against a real cursor-agent install.

* docs(cursor): adds changelog fragments for the renewal plan

Adds one fragment per user-facing outcome per changelog.d/README.md's
convention for a PR that both fixes and adds. PR number placeholder
to be filled in once the PR is opened.

* fix(i18n): translates the new Cursor keys into Vietnamese

The i18n:sync-ui run in an earlier commit left __MISSING__
sentinels for the 4 new Cursor keys in every locale, but
Vietnamese has a dedicated completeness test requiring zero
internal missing markers. Provides real translations for
cursorSessionUnchanged, cursorAgentNudgeTitle,
cursorAgentNudgeBody, and cursorAgentNudgeDismiss.

* fix(cursor): addresses quality-gate Layer 1.5 findings

Restores a comment that misrepresented execFile's actual argv shape
after an earlier bracket-removal fix, this time avoiding literal
closing-bracket characters entirely so the openapi checker's naive
array parser can't be broken by either version. Bounds the sweep-
and manual-route-triggered tryIdeAuth() busy-timeout to 250ms
(down from the interactive auto-import path's 2000ms), since both
share the main event loop with all other in-flight requests and
should fail fast on a WAL-lock collision rather than block the
whole instance for up to ~4s. Has the manual refresh route bypass
the sweep's IDE-auth dedup cache so a click always sees a fresh
read, consistent with this plan's existing "manual actions never
see stale cached data" convention. Documents the previously-missing
agent-availability route in ROUTE_GUARD_TIERS.md's spawn-capable
table.

* fix(cursor): adds SIGKILL follow-up to the status-check spawn

Matches the nudge spawn's existing SIGTERM+SIGKILL pattern so an
unresponsive cursor-agent status check can't leak a lingering
process if it ignores SIGTERM.

* docs(cursor): fills in the PR number for changelog fragments

Renames the 3 changelog.d fragments to their PR-numbered filenames and replaces the (#PR) placeholder with #9173, now that the PR exists.

* fix(cursor): corrects changelog fragments to reference PR #9173

The prior commit only staged the git mv rename — a git add invocation with a stale (pre-rename) pathspec aborted before the actual (#PR) -> (#9173) content edit was staged, so the rename landed without the fix it was meant to carry. This captures the actual content change.

* docs(cursor): regenerates the agent-skills catalog for the new route

check:agent-skills-sync (CI's Merge integrity gate) requires SKILL.md files to stay in sync with the live route catalog. Adding /api/providers/cursor/agent-availability in an earlier commit needed a regen this branch never ran.

* chore(quality): rebaselines file-size caps grown by agentrouter merges

Two already-merged agentrouter commits (564c204ef, ec150a006) on release/v3.8.50 grew open-sse/executors/base.ts, open-sse/handlers/chatCore.ts, and tests/unit/chatcore-translation-paths.test.ts past their frozen caps before this PR branched — unrelated to the Cursor renewal changes here. No PR branch is left to fix the growth in-place, so the caps are bumped to the current real sizes, following the existing release-green rebaseline precedent in this file.

* fix(sse): imports getModel helpers from db/models, not localDb

A recently-merged agentrouter commit added a @/lib/localDb import in chatCore.ts, violating the no-restricted-imports rule (Hard Rule #2 — never barrel-import from localDb.ts). Points the import at the owning module, src/lib/db/models.ts, where both functions are actually defined, and prunes the now-stale suppression entry.

* fix(sse): scopes CC-relay anthropic-beta to its own requestDefaults

Two already-merged agentrouter commits widened usesClaudeCodeProtocol()'s native-Claude system-transform block (billing header + selectBetaFlags-derived anthropic-beta) to also run for generic CC-compatible relay connections, not just real claude traffic and agentrouter's own wire-image mimicry. selectBetaFlags() has no visibility into a relay's own providerSpecificData.requestDefaults, so its header replacement silently wiped out an earlier context-1m append and force-included redact-thinking regardless of the relay's own opt-in. Restores both for plain CC-compatible relays only; real claude/agentrouter traffic is unaffected.

Also bumps four stale hardcoded Codex/Claude Code CLI version-string test assertions (0.144.1->0.146.0, 2.1.219->2.1.220) that drifted when the same two commits bumped the version constants without updating their tests, and rebaselines base.ts's frozen file-size cap for this fix's own +35 lines.

* fix(sse): preserves bare CC-relay native treatment and context-1m

The previous commit's fix was too broad in one direction: excluding ALL CC-compatible relays from the native-Claude header block broke two pre-existing tests (cc-compatible-provider.test.ts, v3.6.6) that rely on that treatment for a 'vanilla' relay with no providerSpecificData.requestDefaults configured.

Refines the gate to this whole native-Claude header-replacement block: replace headers for real claude traffic, agentrouter's wire-image mimicry, OR a CC-relay with no requestDefaults at all — only a relay with EXPLICIT requestDefaults (context1m/redactThinking/summarizeThinking) gets to keep buildHeaders()'s own correctly-computed header set. A redact-thinking-beta strip (unconditional, a no-op when native treatment didn't apply) covers the one remaining gap: selectBetaFlags() force-includes it for a bare relay's opaque client, which a bare relay never explicitly opted into.

Verified against all three previously-conflicting pre-existing tests simultaneously: executor-default-base.test.ts's '1M beta' test, both cc-compatible-provider.test.ts SSE-forcing tests, and provider-request-failure-pipeline.test.ts's 'keeps request beta headers' test (the last of which was already broken by the raw agentrouter merge, confirmed via direct comparison against that exact commit).

* fix(sse): fills in remaining stale CLI version literals

The same two agentrouter commits bumped Codex/Claude Code CLI version constants (0.144.1->0.146.0, 2.1.219->2.1.220) without updating every hardcoded test assertion. This round covers the ones the previous version-string commit missed: the anthropic-cache-fingerprint billing-version constant, a cc-bridge-transforms body assertion, the UI-mirror parity test's own snapshot plus its RoutingTab.tsx source of truth, an integration test's User-Agent assertion (inconsistent with its own dynamic Version assertion two lines up), and the translate-path golden snapshot. Also updates a stale doc comment referencing the old literal by value instead of by constant name.

* fix(cursor): imports from db/ modules, not the localDb barrel

Both files violated Hard Rule #2 (never barrel-import from localDb.ts) — a genuine lint error that had gone uncaught locally. refresh-cursor/route.ts imported getCachedProviderConnectionById from @/lib/localDb instead of its owning module, @/lib/db/readCache. tokenHealthCheckCursor.ts copied the same pattern from its sibling tokenHealthCheckCopilot.ts (an existing, already-suppressed violation) for updateProviderConnection; imports it from @/lib/db/providers instead, with no circular-import fallout (verified via the existing token-health-check-cursor and refresh-cursor-route test suites).

* fix(db): removes stale raw-SQL allowlist entry for cursor route

The cursor auto-import route no longer contains raw SQL — that query
now lives in src/lib/cursor/tokenExtractor.ts, outside the
route/handler scope check-db-rules scans. The allowlist entry was
stale, tripping the stale-enforcement gate.

* fix(test): registers cursor test files in stryker tap.testFiles

Three unit test files covering mutation-tested modules
(route-guard-cursor-agent-availability, route-guard-cursor-refresh,
cursor-renewal) were missing from stryker.conf.json's tap.testFiles,
tripping the mutation-test-coverage gate's drift detection.

* chore(ci): retriggers checks (stuck GH Actions runner on shard 2/4)

* fix(sse): restores CC-relay context1m/redact-thinking test coverage

Rebasing onto release/v3.8.50's new tip (35405be60, an unrelated
agentrouter protocol-inference commit) silently flipped two assertions
this branch's own earlier fix (687fbda62) depends on, in the same test
files that commit touched for other reasons:

- executor-default-base.test.ts: calls[0] (a bare CC-relay with no
  requestDefaults) expected redact-thinking-beta absent; flipped to
  present. calls[1] (context1m+redactThinking requestDefaults) expected
  the context-1m beta preserved; flipped to absent.
- provider-request-failure-pipeline.test.ts: expected Accept:
  text/event-stream and the context-1m beta present for a relay with
  explicit requestDefaults; flipped to application/json and absent.

35405be60 did not touch open-sse/executors/base.ts at all, so these
were test-only edits made without visibility into the still-unmerged
CC-relay header-preservation fix on this branch — they quietly matched
the assertions back to the pre-fix (buggy) behavior instead. Restores
the original, validated expectations; all three interdependent test
files (executor-default-base, cc-compatible-provider,
provider-request-failure-pipeline) verified passing together again.

* ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved)

* ci: re-trigger checks (previous push event was dropped)

* fix(quality): restore dropped vi.json cursor-renewal keys + rebaseline test growth

vi.json was missing 4 keys (cursorSessionUnchanged, cursorAgentNudgeTitle/Body/Dismiss) that this PR's own pre-merge branch had translated -- the original merge's 'git checkout --theirs' resolution for the 7 conflicted locale files discarded them since upstream's vi.json has no cursor-token-renewal feature. Restored from pre-merge tip a38003e30. Also rebaselines combo-routing-engine.test.ts (3457->3464) for the comment growth from the ALL_ACCOUNTS_INACTIVE fix, caught by CI's PR-mode check:file-size.

* chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions

Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457).
2026-08-11 04:31:24 -03:00

577 lines
21 KiB
TypeScript

/**
* Task 3 — wiring the Cursor renewal orchestrator (src/lib/cursor/renewal.ts,
* Task 2) into the proactive token health-check sweep (src/lib/tokenHealthCheck.ts
* + src/lib/tokenHealthCheckCursor.ts).
*
* Real DB, real checkConnection()/checkCursorConnectionIfNeeded() — matching
* this file family's existing convention (see
* tests/unit/token-health-check.test.ts, tests/unit/token-health-no-refresh-token-expired-5326.test.ts):
* a real SQLite DB under a temp DATA_DIR, real createProviderConnection()/
* updateProviderConnection()/getProviderConnectionById() round-trips, no
* mocking of the DB layer.
*
* checkCursorConnectionIfNeeded() calls the REAL renewCursorConnection()
* (Task 2) with no deps override, so — exactly as in
* tests/unit/cursor-renewal.test.ts — its dependencies are driven via real
* HOME-relative fixture files and a real fake `cursor-agent` binary, never a
* mock. This ALSO means the ambient real cursor-agent install on some dev
* hosts (see tests/unit/cursor-agent-models.test.ts) must always be shadowed
* by a fake binary here too, so no test in this file ever risks invoking a
* real cursor-agent process.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
process.env.NODE_ENV = "test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hc-cursor-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const tokenHealthCheck = await import("../../src/lib/tokenHealthCheck.ts");
const tokenHealthCheckCursor = await import("../../src/lib/tokenHealthCheckCursor.ts");
async function resetStorage() {
core.resetDbInstance();
for (let attempt = 0; attempt < 10; attempt++) {
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (error: unknown) {
const code =
error && typeof error === "object" && "code" in error
? (error as { code?: unknown }).code
: null;
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
} else {
throw error;
}
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
function getId(connection: { id?: unknown }): string {
assert.equal(typeof connection.id, "string");
return connection.id as string;
}
async function freshConn(id: string) {
const conn = await providersDb.getProviderConnectionById(id);
assert.ok(conn, `expected connection ${id} to exist`);
return conn as Record<string, unknown>;
}
// ---- Real fake cursor-agent binary + IDE/agent fixture helpers, mirroring
// tests/unit/cursor-renewal.test.ts and tests/unit/cursor-token-extractor.test.ts ----
const FAKE_CURSOR_AGENT_SCRIPT = `#!/usr/bin/env node
const fs = require("fs");
const args = process.argv.slice(2);
if (process.env.FAKE_CURSOR_AGENT_LOG) {
fs.appendFileSync(process.env.FAKE_CURSOR_AGENT_LOG, JSON.stringify(args) + "\\n");
}
if (args[0] === "status") {
const mode = process.env.FAKE_CURSOR_AGENT_STATUS_MODE || "unauthenticated";
if (mode === "authenticated") {
process.stdout.write(JSON.stringify({ status: "authenticated", isAuthenticated: true }));
} else {
process.stdout.write(JSON.stringify({ status: "unauthenticated", isAuthenticated: false }));
}
}
`;
function writeFakeCursorAgentBinary(destPath: string): void {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.writeFileSync(destPath, FAKE_CURSOR_AGENT_SCRIPT, { mode: 0o755 });
fs.chmodSync(destPath, 0o755);
}
function readLoggedInvocations(logPath: string): string[][] {
if (!fs.existsSync(logPath)) return [];
return fs
.readFileSync(logPath, "utf-8")
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
}
interface CursorEnv {
tmpHome: string;
logPath: string;
writeIdeToken(accessToken: string, machineId?: string): Promise<void>;
writeAgentToken(accessToken: string): void;
cleanup(): void;
}
async function withCursorEnv<T>(fn: (env: CursorEnv) => Promise<T>): Promise<T> {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hc-cursor-env-"));
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
const logPath = path.join(tmpHome, "log.jsonl");
process.env.FAKE_CURSOR_AGENT_LOG = logPath;
// Never authenticated by default — the point of these tests is the sweep
// wiring/DB-update shapes (Task 2 already covers the nudge itself), and
// this also guarantees the real ambient cursor-agent install some hosts
// have is never the one actually resolved (this fake one always shadows
// it, since it's the first fixed candidate resolveCursorAgentBinary checks).
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated";
writeFakeCursorAgentBinary(path.join(tmpHome, ".local", "bin", "cursor-agent"));
const env: CursorEnv = {
tmpHome,
logPath,
async writeIdeToken(accessToken, machineId) {
const { openDatabaseAsync } = await import("../../src/lib/db/adapters/driverFactory.ts");
const dbPath = path.join(
tmpHome,
"Library/Application Support/Cursor/User/globalStorage/state.vscdb"
);
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const seed = await openDatabaseAsync(dbPath);
seed.exec("CREATE TABLE itemTable (key TEXT PRIMARY KEY, value TEXT)");
seed
.prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)")
.run("cursorAuth/accessToken", accessToken);
if (machineId) {
seed
.prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)")
.run("storage.serviceMachineId", machineId);
}
seed.close();
},
writeAgentToken(accessToken) {
const authDir = path.join(tmpHome, ".config", "cursor");
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, "auth.json"), JSON.stringify({ accessToken }));
},
cleanup() {
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
process.env.HOME = originalHome;
if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile;
else delete process.env.USERPROFILE;
delete process.env.FAKE_CURSOR_AGENT_LOG;
delete process.env.FAKE_CURSOR_AGENT_STATUS_MODE;
fs.rmSync(tmpHome, { recursive: true, force: true });
},
};
try {
return await fn(env);
} finally {
env.cleanup();
}
}
const NEAR_EXPIRY_ISO = new Date(Date.now() + 60_000).toISOString(); // 1 min out (< 5 min buffer)
const PAST_EXPIRY_ISO = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1h ago
const FAR_FUTURE_ISO = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
async function createCursorConnection(overrides: Record<string, unknown> = {}) {
const connection = await providersDb.createProviderConnection({
provider: "cursor",
authType: "oauth",
email: "cursor-healthcheck@example.com",
accessToken: "old-token",
refreshToken: null,
isActive: true,
testStatus: "active",
...overrides,
});
return getId(connection);
}
// ============================================================================
// Step 2: checkCursorConnectionIfNeeded DB-update shapes
// ============================================================================
test("checkConnection: Cursor renewed-via-IDE result persists accessToken, ~24h expiry, active status, cleared error fields", async () => {
await resetStorage();
await withCursorEnv(async (env) => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: NEAR_EXPIRY_ISO,
expiresAt: NEAR_EXPIRY_ISO,
providerSpecificData: { machineId: "old-machine" },
});
await env.writeIdeToken("new-ide-token", "new-machine");
const before = Date.now();
await tokenHealthCheck.checkConnection(await freshConn(id));
const after = Date.now();
const updated = await freshConn(id);
assert.equal(updated.accessToken, "new-ide-token");
assert.equal(updated.testStatus, "active");
assert.equal(updated.lastError ?? null, null);
assert.equal(updated.lastErrorAt ?? null, null);
assert.equal(updated.lastErrorType ?? null, null);
assert.equal(updated.errorCode ?? null, null);
assert.equal(updated.expiredRetryCount ?? null, null);
assert.equal(updated.expiredRetryAt ?? null, null);
assert.equal(updated.expiresAt, updated.tokenExpiresAt);
const expiresAtMs = new Date(updated.expiresAt as string).getTime();
assert.ok(
expiresAtMs >= before + 24 * 60 * 60 * 1000 - 5000 &&
expiresAtMs <= after + 24 * 60 * 60 * 1000 + 5000,
`expected expiresAt ~24h out, got ${updated.expiresAt}`
);
const psd = updated.providerSpecificData as Record<string, unknown>;
assert.equal(psd.machineId, "new-machine");
});
});
test('checkConnection: Cursor "unchanged" result marks cursor_session_stale, stays non-terminal (testStatus active, not expired)', async () => {
await resetStorage();
await withCursorEnv(async (env) => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: NEAR_EXPIRY_ISO,
expiresAt: NEAR_EXPIRY_ISO,
});
// No writeIdeToken/writeAgentToken -> both tokenExtractor functions
// gracefully report {found:false} -> renewCursorConnection() returns "unchanged".
void env;
await tokenHealthCheck.checkConnection(await freshConn(id));
const updated = await freshConn(id);
assert.equal(updated.errorCode, "cursor_session_stale");
assert.equal(updated.lastErrorType, "cursor_session_stale");
assert.match(updated.lastError as string, /Cursor session unchanged/);
assert.equal(
updated.testStatus,
"active",
"must NOT be terminal — future sweeps must keep retrying"
);
assert.ok(updated.lastHealthCheckAt);
const psd = updated.providerSpecificData as Record<string, unknown>;
assert.equal((psd.refreshCircuit as { streak?: number })?.streak, 1);
});
});
test('checkCursorConnectionIfNeeded: Cursor "error" result -> DB update shape (via the deps testability seam)', async () => {
await resetStorage();
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: NEAR_EXPIRY_ISO,
expiresAt: NEAR_EXPIRY_ISO,
});
// Closes the gap the skipped test above used to document: checkCursorConnectionIfNeeded()
// now forwards an optional `deps` param straight through to renewCursorConnection() (mirroring
// the seam Task 2 added there), so this can force a {status:"error"} result directly instead
// of going through tokenHealthCheck.checkConnection(), which has no deps param of its own.
const rawMessage =
"Failed to read Cursor IDE database at " +
"/Users/secret-user/project/src/lib/cursor/tokenExtractor.ts:284:15 - permission denied";
const throwingTryIdeAuth = async (): Promise<never> => {
throw new Error(rawMessage);
};
const throwingTryAgentAuth = async (): Promise<never> => {
throw new Error(rawMessage);
};
const errors: string[] = [];
const warnings: string[] = [];
const now = new Date().toISOString();
await tokenHealthCheckCursor.checkCursorConnectionIfNeeded({
conn: await freshConn(id),
now,
buildRefreshFailureUpdate: tokenHealthCheck.buildRefreshFailureUpdate,
log: () => {},
logWarn: (message: string) => warnings.push(message),
logError: (message: string) => errors.push(message),
getConnectionLogLabel: (c) => String(c.email ?? c.id ?? "unknown"),
logPrefix: "[test]",
deps: {
tryIdeAuth: throwingTryIdeAuth,
tryAgentAuth: throwingTryAgentAuth,
checkCursorAgentAvailability: async () => ({ available: false, binaryPath: null }),
},
});
const updated = await freshConn(id);
assert.equal(updated.errorCode, "cursor_session_stale");
assert.equal(updated.lastErrorType, "cursor_session_stale");
assert.match(updated.lastError as string, /Cursor session renewal failed:/);
assert.equal(
updated.testStatus,
"active",
"must NOT be terminal — future sweeps must keep retrying"
);
assert.ok(updated.lastHealthCheckAt);
assert.equal(errors.length, 1, "the error branch must log via logError, not logWarn");
assert.equal(warnings.length, 0);
assert.ok(
!errors[0].includes("/Users/secret-user"),
`raw absolute path must not survive sanitization, got: ${errors[0]}`
);
});
// ============================================================================
// Step 1: buildRefreshFailureUpdate's overrides param — DB-shape-adjacent proof
// (the pure-function unit test lives in tests/unit/token-health-check-circuit-breaker.test.ts;
// this asserts the SAME override plumbing end-to-end through the real DB write above)
// ============================================================================
test("checkConnection: the cursor_session_stale override forces testStatus:active even for a connection whose PRIOR testStatus was expired", async () => {
await resetStorage();
await withCursorEnv(async () => {
// A Cursor connection that landed at "expired" via the pre-existing
// request-time path (src/sse/services/auth.ts::resolveTerminalConnectionStatus)
// — Task 3 Step 3's carve-out lets this reach the branch; Step 1/2's override
// must then force it back to non-terminal, not leave/re-derive "expired".
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: PAST_EXPIRY_ISO,
expiresAt: PAST_EXPIRY_ISO,
testStatus: "expired",
});
await tokenHealthCheck.checkConnection(await freshConn(id));
const updated = await freshConn(id);
assert.equal(
updated.testStatus,
"active",
'buildRefreshFailureUpdate\'s default wasExpired-derived testStatus:"expired" must be overridden'
);
assert.equal(updated.errorCode, "cursor_session_stale");
});
});
// ============================================================================
// Step 3: terminal-status carve-out (isRecoverableCursorExpired)
// ============================================================================
test("checkConnection: Cursor + expired + no lastErrorType is NOT permanently skipped (reaches the Cursor branch)", async () => {
await resetStorage();
await withCursorEnv(async () => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: PAST_EXPIRY_ISO,
expiresAt: PAST_EXPIRY_ISO,
testStatus: "expired",
// no lastErrorType at all
});
await tokenHealthCheck.checkConnection(await freshConn(id));
const updated = await freshConn(id);
assert.ok(
updated.lastHealthCheckAt,
"must have reached the Cursor branch (which always writes lastHealthCheckAt)"
);
assert.equal(updated.testStatus, "active");
});
});
test("checkConnection: Cursor + expired + lastErrorType:account_deactivated STAYS permanently skipped", async () => {
await resetStorage();
await withCursorEnv(async () => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: PAST_EXPIRY_ISO,
expiresAt: PAST_EXPIRY_ISO,
testStatus: "expired",
lastErrorType: "account_deactivated",
lastHealthCheckAt: null,
});
const before = await freshConn(id);
await tokenHealthCheck.checkConnection(before);
const after = await freshConn(id);
assert.deepEqual(after, before, "a permanently-dead account must not be touched at all");
});
});
test("checkConnection: a banned Cursor connection stays skipped regardless of lastErrorType", async () => {
await resetStorage();
await withCursorEnv(async () => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: PAST_EXPIRY_ISO,
expiresAt: PAST_EXPIRY_ISO,
testStatus: "banned",
lastErrorType: "some_other_reason",
});
const before = await freshConn(id);
await tokenHealthCheck.checkConnection(before);
const after = await freshConn(id);
assert.deepEqual(after, before);
});
});
test("checkConnection: a credits_exhausted Cursor connection stays skipped regardless of lastErrorType", async () => {
await resetStorage();
await withCursorEnv(async () => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: PAST_EXPIRY_ISO,
expiresAt: PAST_EXPIRY_ISO,
testStatus: "credits_exhausted",
});
const before = await freshConn(id);
await tokenHealthCheck.checkConnection(before);
const after = await freshConn(id);
assert.deepEqual(after, before);
});
});
// ============================================================================
// Step 4: due/backoff dispatch
// ============================================================================
test("checkConnection: Cursor connection NOT near expiry is left completely untouched (no renewal attempt)", async () => {
await resetStorage();
await withCursorEnv(async (env) => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: FAR_FUTURE_ISO,
expiresAt: FAR_FUTURE_ISO,
});
const before = await freshConn(id);
await tokenHealthCheck.checkConnection(before);
const after = await freshConn(id);
assert.deepEqual(after, before);
assert.equal(
readLoggedInvocations(env.logPath).length,
0,
"must never spawn cursor-agent when not due"
);
});
});
test("checkConnection: Cursor connection near expiry and NOT in backoff triggers a renewal attempt", async () => {
await resetStorage();
await withCursorEnv(async (env) => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: NEAR_EXPIRY_ISO,
expiresAt: NEAR_EXPIRY_ISO,
});
await tokenHealthCheck.checkConnection(await freshConn(id));
assert.ok(
readLoggedInvocations(env.logPath).length >= 1,
"expected a cursor-agent availability check"
);
const updated = await freshConn(id);
assert.ok(updated.lastHealthCheckAt);
});
});
test("checkConnection: Cursor connection near expiry but currently in backoff is skipped", async () => {
await resetStorage();
await withCursorEnv(async (env) => {
const id = await createCursorConnection({
accessToken: "old-token",
tokenExpiresAt: NEAR_EXPIRY_ISO,
expiresAt: NEAR_EXPIRY_ISO,
providerSpecificData: {
refreshCircuit: { streak: 1, until: new Date(Date.now() + 10 * 60 * 1000).toISOString() },
},
});
const before = await freshConn(id);
await tokenHealthCheck.checkConnection(before);
const after = await freshConn(id);
assert.deepEqual(after, before);
assert.equal(readLoggedInvocations(env.logPath).length, 0, "must not spawn while in backoff");
});
});
test("checkConnection: a Cursor connection with no known expiry at all is treated as due", async () => {
await resetStorage();
await withCursorEnv(async (env) => {
const id = await createCursorConnection({
accessToken: "old-token",
// no tokenExpiresAt/expiresAt at all
});
await tokenHealthCheck.checkConnection(await freshConn(id));
assert.ok(
readLoggedInvocations(env.logPath).length >= 1,
"an unknown expiry must be treated as due, not permanently skipped"
);
});
});
// ============================================================================
// Step 5: full sweep-level regression — non-Cursor behavior must be unaffected
// ============================================================================
test("checkConnection: a refresh-capable non-Cursor provider missing its refresh token is still marked expired/no_refresh_token (#5326 unaffected)", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "antigravity",
authType: "oauth",
name: "Antigravity No-Refresh Account (Cursor-plan regression)",
email: "antigravity-cursor-regression@example.com",
accessToken: "access-token-only",
refreshToken: null,
testStatus: "active",
isActive: true,
});
await tokenHealthCheck.checkConnection(connection);
const updated = await providersDb.getProviderConnectionById(getId(connection));
assert.equal(updated?.testStatus, "expired");
assert.equal(updated?.errorCode, "no_refresh_token");
});
test("checkConnection: a banned non-Cursor connection is still skipped (terminal-status guard unaffected by the Cursor carve-out)", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "openai",
authType: "oauth",
email: "openai-banned-regression@example.com",
accessToken: "access-token",
refreshToken: "refresh-token",
testStatus: "banned",
isActive: true,
});
const before = await providersDb.getProviderConnectionById(getId(connection));
await tokenHealthCheck.checkConnection(before);
const after = await providersDb.getProviderConnectionById(getId(connection));
assert.deepEqual(after, before);
});