diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index a9f4750..a0c7530 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -68,6 +68,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import * as https from 'node:https'; +import * as http from 'node:http'; import { ProviderError } from './base.mjs'; // ── Binary resolution ───────────────────────────────────────────────────── @@ -139,8 +140,8 @@ export function readAuthArtifact() { // per `strings` output 2026-05-26). const QUOTA_OAUTH_CLIENT_ID = process.env.CLAUDE_CODE_OAUTH_CLIENT_ID ?? '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; -const QUOTA_OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; -const QUOTA_API_URL = 'https://api.anthropic.com/v1/messages'; +const QUOTA_OAUTH_TOKEN_URL_DEFAULT = 'https://platform.claude.com/v1/oauth/token'; +const QUOTA_API_URL_DEFAULT = 'https://api.anthropic.com/v1/messages'; const QUOTA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes (OCP USAGE_CACHE_TTL) const QUOTA_BACKOFF_MIN_MS = 60 * 1000; // 60s (OCP OAUTH_REFRESH_MIN_BACKOFF) const QUOTA_BACKOFF_MAX_MS = 60 * 60 * 1000; // 3600s (OCP OAUTH_REFRESH_MAX_BACKOFF) @@ -149,6 +150,65 @@ const QUOTA_BACKOFF_MAX_MS = 60 * 60 * 1000; // 3600s (OCP OAUTH_REFRESH_MA // If the registry field is absent or fails to load, this constant takes over. const QUOTA_SCHEMA_VERSION = '2026-05-26'; +// ── Test seams for quota probe (D83) ───────────────────────────────────── +// These variables allow tests to redirect HTTP requests to a local mock server +// without modifying production logic. In production, they always hold the +// DEFAULT values. _setQuotaUrlsForTest() / _resetQuotaProbeStateForTest() are +// the ONLY permitted mutations — production code must never call them. +// +// The _quotaHttpMod seam switches from https to http so test mock servers can +// be plain HTTP servers on ephemeral ports (no TLS cert management in tests). +// +// ADR 0012 D83: test seam discipline — injected via export, not monkey-patch. +let _quotaApiUrl = QUOTA_API_URL_DEFAULT; +let _quotaOauthTokenUrl = QUOTA_OAUTH_TOKEN_URL_DEFAULT; +let _quotaHttpMod = https; // switched to http by _setQuotaUrlsForTest when protocol is http: + +export function _setQuotaUrlsForTest(apiUrl, oauthUrl) { + _quotaApiUrl = apiUrl; + _quotaOauthTokenUrl = oauthUrl ?? _quotaOauthTokenUrl; + // Auto-detect protocol: if test URL is http, use http module (no TLS) + const proto = new URL(apiUrl).protocol; + _quotaHttpMod = proto === 'http:' ? http : https; +} + +export function _resetQuotaProbeStateForTest() { + // Resets probe runtime state (cache + backoff), restores production URLs, + // and clears the auth-read override. Call in test finally blocks. + _quotaApiUrl = QUOTA_API_URL_DEFAULT; + _quotaOauthTokenUrl = QUOTA_OAUTH_TOKEN_URL_DEFAULT; + _quotaHttpMod = https; + _quotaAuthReadFnForTest = null; + quotaProbeState.cache = null; + quotaProbeState.backoffUntil = 0; + quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS; +} + +// Convenience: reset state only, leaving URL overrides in place. +// Useful when a test sets URLs first, then wants a clean cache/backoff. +export function _resetQuotaStateOnlyForTest() { + quotaProbeState.cache = null; + quotaProbeState.backoffUntil = 0; + quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS; +} + +// Returns a direct reference to the internal quotaProbeState object. +// Tests MUST NOT store this reference across calls — the object is stable +// but its properties are mutated by the probe machinery. Direct mutation +// (e.g. state.backoffUntil = 0) is safe ONLY in test contexts where a +// controlled probe sequence is being driven. Production code never mutates +// this object from outside this module. +export function _getQuotaProbeStateForTest() { + return quotaProbeState; +} + +// Optional override for readAuthArtifact used by quotaStatus() and doctorChecks(). +// When non-null, replaces readAuthArtifact() for probe calls. Reset to null via +// _resetQuotaProbeStateForTest() or explicitly by assigning null. +// This seam prevents real credential files from interfering with unit tests. +let _quotaAuthReadFnForTest = null; +export function _setQuotaAuthReadFnForTest(fn) { _quotaAuthReadFnForTest = fn; } + /** * Read quota_probe.schema_version from models-registry.json (D81). * ADR 0013 Rule 5: schema_version lives in models-registry.json so downstream @@ -272,10 +332,10 @@ async function _refreshAccessToken(refreshToken) { client_id: QUOTA_OAUTH_CLIENT_ID, scope: 'user:inference user:profile', }); - const url = new URL(QUOTA_OAUTH_TOKEN_URL); + const url = new URL(_quotaOauthTokenUrl); const options = { hostname: url.hostname, - port: 443, + port: url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'POST', headers: { @@ -283,7 +343,7 @@ async function _refreshAccessToken(refreshToken) { 'Content-Length': Buffer.byteLength(body), }, }; - const req = https.request(options, (res) => { + const req = _quotaHttpMod.request(options, (res) => { let raw = ''; res.on('data', (chunk) => { raw += chunk; }); res.on('end', () => { @@ -351,10 +411,10 @@ async function _probeOnce(creds) { }); const doRequest = (bearerToken) => new Promise((resolve, reject) => { - const url = new URL(QUOTA_API_URL); + const url = new URL(_quotaApiUrl); const options = { hostname: url.hostname, - port: 443, + port: url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'POST', headers: { @@ -365,7 +425,7 @@ async function _probeOnce(creds) { 'Content-Length': Buffer.byteLength(body), }, }; - const req = https.request(options, (res) => { + const req = _quotaHttpMod.request(options, (res) => { // Drain and discard the response body — ADR 0013 Rule 2: headers-only. res.on('data', () => {}); res.on('end', () => { @@ -799,8 +859,10 @@ export async function quotaStatus(_authContext) { return null; // no stale cache } - // Read auth artifact (ADR 0013 Rule 1 — same credentials as spawn path) - const creds = readAuthArtifact(); + // Read auth artifact (ADR 0013 Rule 1 — same credentials as spawn path). + // _quotaAuthReadFnForTest allows tests to inject a mock without real credential files. + const _authReadFn = _quotaAuthReadFnForTest ?? readAuthArtifact; + const creds = _authReadFn(); if (!creds?.accessToken) { // No credentials at all — return stale cache or null; do not hammer API with 401s return null; diff --git a/test-features.mjs b/test-features.mjs index 598a619..9917d8f 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -15590,3 +15590,1127 @@ describe('Suite 37 — D81 aggregateProviderQuota() smoke tests (Phase 5, ADR 00 '37g: quota_probe.anthropic.fields_pinned must have exactly 13 entries (all parsed fields)'); }); }); + +// ── Suite 38: D83 quota-probe unit tests (Phase 5, ADR 0012 D83) ────────── +// +// Comprehensive unit tests for the anthropic plugin quota probe machinery: +// - _parseRateLimitHeaders: all 13 fields, missing fields, types +// - quotaStatus: cache TTL, backoff, 401-refresh, 429-stale, disabled, no-creds +// - doctorChecks: anthropic.quota_probe_reachable (all 5 status paths) +// - schemaVersion: from models-registry.json + constant fallback +// +// Approach: local Node http server mimics api.anthropic.com/v1/messages and +// platform.claude.com/v1/oauth/token. Test seams _setQuotaUrlsForTest + +// _resetQuotaProbeStateForTest redirect the plugin to the local servers. +// +// Authority: ADR 0012 D83 + ADR 0013 (Rules 2-6) + D80 PR #52 (producers) +// Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md + +import { createServer as _httpCreateServer } from 'node:http'; +import { + _setQuotaUrlsForTest as setQuotaUrls38, + _resetQuotaProbeStateForTest as resetQuotaProbe38, + _resetQuotaStateOnlyForTest as resetQuotaStateOnly38, + _getQuotaProbeStateForTest as getQuotaProbeState38, + _setQuotaAuthReadFnForTest as setQuotaAuthFn38, + quotaStatus as quotaStatus38, + doctorChecks as doctorChecks38, +} from './lib/providers/anthropic.mjs'; +import { writeFileSync as _writeFileSync38, mkdtempSync as _mkdtempSync38, rmSync as _rmSync38, mkdirSync as _mkdirSync38, readFileSync as _readFileSync38 } from 'node:fs'; +import { join as _pathJoin38 } from 'node:path'; +import { tmpdir as _tmpdir38 } from 'node:os'; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** All 13 anthropic-ratelimit-unified-* response headers (full set) */ +const _ALL_13_HEADERS = { + 'anthropic-ratelimit-unified-status': 'active', + 'anthropic-ratelimit-unified-representative-claim': 'five_hour', + 'anthropic-ratelimit-unified-reset': '1748300000', + 'anthropic-ratelimit-unified-fallback-percentage': '0.5', + 'anthropic-ratelimit-unified-5h-status': 'active', + 'anthropic-ratelimit-unified-5h-utilization': '0.49', + 'anthropic-ratelimit-unified-5h-reset': '1748290000', + 'anthropic-ratelimit-unified-7d-status': 'active', + 'anthropic-ratelimit-unified-7d-utilization': '0.31', + 'anthropic-ratelimit-unified-7d-reset': '1748500000', + 'anthropic-ratelimit-unified-overage-status': 'rejected', + 'anthropic-ratelimit-unified-overage-disabled-reason': 'org_level_disabled_until', + 'anthropic-ratelimit-unified-overage-reset': '1748400000', +}; + +/** + * Start a local HTTP mock server. The handler receives (req, res). + * Returns { server, url, close } where url is 'http://127.0.0.1:'. + */ +function _startMockServer(handler) { + return new Promise((resolve) => { + const server = _httpCreateServer(handler); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve({ + server, + url: `http://127.0.0.1:${port}`, + close: () => new Promise(r => server.close(r)), + }); + }); + }); +} + +/** + * Write a minimal config.json enabling the quota probe under olpHome. + */ +function _writeProbeConfig(olpHome, extra = {}) { + _mkdirSync38(olpHome, { recursive: true }); + _writeFileSync38(_pathJoin38(olpHome, 'config.json'), JSON.stringify({ + providers: { + anthropic: { + quota_probe_enabled: true, + ...extra, + }, + }, + })); +} + +// Save and restore OLP_HOME around each test that mutates it. +let _savedOlpHome38; +function _saveEnv38() { _savedOlpHome38 = process.env.OLP_HOME; } +function _restoreEnv38() { + if (_savedOlpHome38 === undefined) delete process.env.OLP_HOME; + else process.env.OLP_HOME = _savedOlpHome38; +} + +describe('Suite 38 — D83 quota-probe unit tests (Phase 5, ADR 0012 D83 + ADR 0013)', () => { + + // ── 38a–38d: _parseRateLimitHeaders parsing ─────────────────────────────── + // We call quotaStatus() with a mock server and assert the parsed fields + // rather than calling the private _parseRateLimitHeaders directly. + // The 38a test exercises all 13 fields by checking the quotaStatus() return. + + it('38a — parse: all 13 headers present → parsed correctly (numeric types, null for missing)', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38a-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38a'; + mock = await _startMockServer((_req, res) => { + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + + assert.ok(result !== null, '38a: quotaStatus should return non-null on 200 with headers'); + assert.equal(result.stale, false, '38a: stale should be false on fresh probe'); + assert.equal(result.source, 'anthropic-ratelimit-unified-headers', '38a: source'); + assert.ok(typeof result.schemaVersion === 'string', '38a: schemaVersion should be a string'); + // All 13 fields + const f = result.fields; + assert.equal(f.status, 'active', '38a: status'); + assert.equal(f.representative_claim, 'five_hour', '38a: representative_claim'); + assert.equal(f.reset, 1748300000, '38a: reset should be integer'); + assert.equal(typeof f.reset, 'number', '38a: reset should be number type'); + assert.equal(f.fallback_percentage, 0.5, '38a: fallback_percentage'); + assert.equal(typeof f.fallback_percentage, 'number', '38a: fallback_percentage type'); + assert.equal(f.status_5h, 'active', '38a: status_5h'); + assert.equal(f.utilization_5h, 0.49, '38a: utilization_5h'); + assert.equal(f.reset_5h, 1748290000, '38a: reset_5h'); + assert.equal(f.status_7d, 'active', '38a: status_7d'); + assert.equal(f.utilization_7d, 0.31, '38a: utilization_7d'); + assert.equal(f.reset_7d, 1748500000, '38a: reset_7d'); + assert.equal(f.overage_status, 'rejected', '38a: overage_status'); + assert.equal(f.overage_disabled_reason, 'org_level_disabled_until', '38a: overage_disabled_reason'); + assert.equal(f.overage_reset, 1748400000, '38a: overage_reset (new vs OCP 2026-04)'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38b — parse: missing overage-reset header → overage_reset: null', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38b-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38b'; + const headers = { ..._ALL_13_HEADERS }; + delete headers['anthropic-ratelimit-unified-overage-reset']; + mock = await _startMockServer((_req, res) => { + res.writeHead(200, headers); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.ok(result !== null, '38b: should return non-null'); + assert.equal(result.fields.overage_reset, null, '38b: missing overage-reset → null'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38c — parse: missing all overage fields → all three null', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38c-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38c'; + const headers = { ..._ALL_13_HEADERS }; + delete headers['anthropic-ratelimit-unified-overage-status']; + delete headers['anthropic-ratelimit-unified-overage-disabled-reason']; + delete headers['anthropic-ratelimit-unified-overage-reset']; + mock = await _startMockServer((_req, res) => { + res.writeHead(200, headers); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.ok(result !== null, '38c: should return non-null'); + assert.equal(result.fields.overage_status, null, '38c: missing overage-status → null'); + assert.equal(result.fields.overage_disabled_reason, null, '38c: missing overage-disabled-reason → null'); + assert.equal(result.fields.overage_reset, null, '38c: missing overage-reset → null'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38d — parse: new 5h-status + 7d-status headers (fields new vs OCP 2026-04) correctly parsed', async () => { + // These two headers are the most important "new vs OCP" additions. + // Exercise explicitly to lock the ADR 0013 + audit-memory claim. + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38d-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38d'; + mock = await _startMockServer((_req, res) => { + res.writeHead(200, { + ..._ALL_13_HEADERS, + 'anthropic-ratelimit-unified-5h-status': 'approaching_limit', + 'anthropic-ratelimit-unified-7d-status': 'limit_reached', + }); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.ok(result !== null, '38d: should return non-null'); + assert.equal(result.fields.status_5h, 'approaching_limit', '38d: status_5h new field'); + assert.equal(result.fields.status_7d, 'limit_reached', '38d: status_7d new field'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38e–38f: opt-in + no-auth early exits ──────────────────────────────── + + it('38e — quotaStatus disabled in config → returns null without HTTP', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38e-')); + let requestCount = 0; + let mock; + try { + // Write config WITHOUT quota_probe_enabled (or explicitly false) + _mkdirSync38(TMP, { recursive: true }); + _writeFileSync38(_pathJoin38(TMP, 'config.json'), JSON.stringify({ + providers: { anthropic: { quota_probe_enabled: false } }, + })); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38e'; + mock = await _startMockServer((_req, res) => { + requestCount++; + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.equal(result, null, '38e: null when disabled'); + assert.equal(requestCount, 0, '38e: no HTTP request should be made when disabled'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38f — quotaStatus enabled + no auth → returns null without HTTP', async () => { + // Uses _setQuotaAuthReadFnForTest to inject a "no credentials" auth reader. + // This prevents the test from being affected by real ~/.claude/.credentials.json + // on the developer's machine (which would cause a false pass/fail). + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38f-')); + let requestCount = 0; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + mock = await _startMockServer((_req, res) => { + requestCount++; + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + // Inject a "no credentials" auth reader (bypasses real keychain/files) + setQuotaAuthFn38(() => null); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.equal(result, null, '38f: null when auth returns null (no creds)'); + assert.equal(requestCount, 0, '38f: no HTTP request should be made without auth'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); // also resets _quotaAuthReadFnForTest + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38g: successful probe returns correct shape ─────────────────────────── + + it('38g — enabled + auth + mock 200 → returns 13-field shape with stale:false', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38g-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38g'; + mock = await _startMockServer((_req, res) => { + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.ok(result !== null, '38g: should return non-null'); + assert.equal(result.stale, false, '38g: stale=false on fresh probe'); + assert.equal(result.source, 'anthropic-ratelimit-unified-headers', '38g: source field'); + assert.ok(typeof result.probedAt === 'number', '38g: probedAt should be a number'); + assert.ok(typeof result.schemaVersion === 'string', '38g: schemaVersion should be a string'); + assert.ok(typeof result.fields === 'object', '38g: fields should be an object'); + assert.ok(typeof result.raw === 'object', '38g: raw should be an object'); + // Check backoff was reset + const state = getQuotaProbeState38(); + assert.equal(state.backoffUntil, 0, '38g: backoffUntil reset to 0 on success'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38h–38i: cache behaviour ────────────────────────────────────────────── + + it('38h — cache hit within 5min TTL → no second HTTP call', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38h-')); + let requestCount = 0; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38h'; + mock = await _startMockServer((_req, res) => { + requestCount++; + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const r1 = await quotaStatus38(); + assert.ok(r1 !== null, '38h: first call should succeed'); + assert.equal(requestCount, 1, '38h: first call should fire one HTTP request'); + + // Second call within TTL + const r2 = await quotaStatus38(); + assert.ok(r2 !== null, '38h: second call should return cached result'); + assert.equal(requestCount, 1, '38h: second call should NOT fire another HTTP request'); + assert.equal(r2.stale, false, '38h: cached result should have stale=false'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38i — expired cache (TTL + 1ms) → fires fresh HTTP probe', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38i-')); + let requestCount = 0; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38i'; + mock = await _startMockServer((_req, res) => { + requestCount++; + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + // Seed cache manually with an expired fetchedAt (6 min ago > 5 min TTL) + const SIX_MIN_AGO = Date.now() - (6 * 60 * 1000); + // Do a real probe first to seed state, then backdate it + const r1 = await quotaStatus38(); + assert.ok(r1 !== null, '38i: first call should succeed'); + // Backdate the cache entry + const state = getQuotaProbeState38(); + if (state.cache) state.cache.fetchedAt = SIX_MIN_AGO; + + // Second call should miss cache and fire a fresh probe + const priorCount = requestCount; + const r2 = await quotaStatus38(); + assert.ok(r2 !== null, '38i: second call should succeed after cache miss'); + assert.ok(requestCount > priorCount, '38i: should fire a fresh HTTP request after cache expiry'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38j: 401 with no refreshToken → null (idempotent-failure) ───────────── + + it('38j — 401 with no refreshToken → null (idempotent-failure, no refresh attempt)', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38j-')); + let apiCallCount = 0; + let oauthCallCount = 0; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + // Env-path creds return { accessToken } only (no refreshToken). Per + // anthropic.mjs:452 _probeOnce, the 401 → refresh-and-retry only fires + // when refreshToken is present. This test pins the no-refreshToken + // idempotent-failure branch (returns null, no OAuth call). + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'expired-token-38j'; + mock = await _startMockServer((req, res) => { + if (req.url.includes('/oauth/token')) { + oauthCallCount++; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'fresh-token-38j' })); + } else { + apiCallCount++; + res.writeHead(401, {}); + res.end('{"error":"unauthorized"}'); + } + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.equal(result, null, '38j: 401 with no refreshToken → null (idempotent-failure per ADR 0002 Amendment 8)'); + assert.equal(apiCallCount, 1, '38j: only one API call made (no retry without refreshToken)'); + assert.equal(oauthCallCount, 0, '38j: no OAuth refresh call without refreshToken'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38j2: 401 WITH refreshToken → refresh succeeds → retry returns 200 ──── + + it('38j2 — 401 with refreshToken → refresh succeeds → retry probe returns 200 with 13 fields', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38j2-')); + let apiCallCount = 0; + let oauthCallCount = 0; + let oauthBodyRefreshToken = null; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + // Inject creds with BOTH accessToken AND refreshToken via the test seam. + // The 401 → refresh-and-retry branch at anthropic.mjs:452 fires only when + // refreshToken is present, exercising the positive-path control flow + // documented in ADR 0013 § Rule 1 (credential reuse + refresh). + setQuotaAuthFn38(() => ({ + accessToken: 'expired-token-38j2', + refreshToken: 'valid-refresh-token-38j2', + expiresAt: null, // skip pre-emptive refresh at line 402 + })); + mock = await _startMockServer((req, res) => { + if (req.url.includes('/oauth/token')) { + // Capture the refresh_token sent in the OAuth body to verify routing + oauthCallCount++; + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', () => { + try { + const parsed = JSON.parse(body); + oauthBodyRefreshToken = parsed?.refresh_token ?? null; + } catch { /* ignore */ } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'fresh-token-38j2' })); + }); + } else { + apiCallCount++; + if (apiCallCount === 1) { + // First API call (with expired token) → 401 to trigger refresh + res.writeHead(401, {}); + res.end('{"error":"token_expired"}'); + } else { + // Second API call (after refresh) → 200 with all 13 headers + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + } + } + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.ok(result !== null, '38j2: refresh-retry path should return non-null'); + assert.equal(result.stale, false, '38j2: returned data is fresh (not stale)'); + assert.equal(apiCallCount, 2, '38j2: exactly 2 API calls (401 then retry 200)'); + assert.equal(oauthCallCount, 1, '38j2: exactly 1 OAuth refresh call between API calls'); + assert.equal(oauthBodyRefreshToken, 'valid-refresh-token-38j2', + '38j2: OAuth refresh sent the injected refreshToken (Rule 1 credential reuse)'); + // Verify the 13 fields landed (refresh succeeded → retry parsed full shape) + assert.equal(typeof result.fields?.utilization_5h, 'number', + '38j2: retry parsed utilization_5h from second-attempt response headers'); + assert.equal(typeof result.fields?.utilization_7d, 'number', + '38j2: retry parsed utilization_7d'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38k–38l: 429 stale cache ────────────────────────────────────────────── + + it('38k — mock 429 + cache exists → returns stale cache with stale:true', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38k-')); + let callCount = 0; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38k'; + mock = await _startMockServer((_req, res) => { + callCount++; + if (callCount === 1) { + // First call: 200 with headers to seed cache + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + } else { + // Subsequent: 429 no headers → triggers stale path + res.writeHead(429, {}); + res.end('{"error":"rate limited"}'); + } + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + // Seed the cache with a fresh probe + const r1 = await quotaStatus38(); + assert.ok(r1 !== null, '38k: first call should succeed'); + const freshFetchedAt = r1.probedAt; + + // Force cache to look stale (older than TTL) so next call re-probes + const state = getQuotaProbeState38(); + if (state.cache) state.cache.fetchedAt = Date.now() - (6 * 60 * 1000); + // Also clear backoff so the probe attempts + state.backoffUntil = 0; + state.backoffMs = 60_000; + + // Second call hits 429 → should return stale cache + const r2 = await quotaStatus38(); + assert.ok(r2 !== null, '38k: 429 with stale cache → should return stale cache (not null)'); + assert.equal(r2.stale, true, '38k: stale should be true'); + assert.ok(typeof r2.last_fresh_at === 'number', '38k: last_fresh_at should be present'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38l — mock 429 + no cache → returns null', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38l-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38l'; + mock = await _startMockServer((_req, res) => { + // Return 429 with no rate-limit headers → failure, no cache yet + res.writeHead(429, {}); + res.end('{"error":"rate limited"}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.equal(result, null, '38l: 429 with no cache → null'); + + // Verify backoff was scheduled + const state = getQuotaProbeState38(); + assert.ok(state.backoffUntil > Date.now(), '38l: backoffUntil should be in the future after 429'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38m–38n: backoff growth and reset ──────────────────────────────────── + + it('38m — backoff exponential growth (60s → 120s → 240s → cap at 3600s)', async () => { + // Test _scheduleBackoff() progression by triggering failures directly. + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38m-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38m'; + mock = await _startMockServer((_req, res) => { + res.writeHead(429, {}); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + // Verify initial backoffMs = 60s + assert.equal(getQuotaProbeState38().backoffMs, 60_000, '38m: initial backoffMs should be 60000ms'); + + // First failure → schedules 60s backoff, advances backoffMs to 120s + await quotaStatus38(); + const s1 = getQuotaProbeState38(); + assert.equal(s1.backoffMs, 120_000, '38m: after 1st failure: backoffMs = 120s'); + + // Clear backoffUntil to allow retry, fire again → 240s + s1.backoffUntil = 0; + await quotaStatus38(); + const s2 = getQuotaProbeState38(); + assert.equal(s2.backoffMs, 240_000, '38m: after 2nd failure: backoffMs = 240s'); + + // Simulate multiple failures to reach cap (3600s) + s2.backoffUntil = 0; + s2.backoffMs = 1800_000; // pre-set to 1800s (half of cap) + await quotaStatus38(); + const s3 = getQuotaProbeState38(); + assert.equal(s3.backoffMs, 3600_000, '38m: backoffMs should cap at 3600s'); + + // One more failure → stays at cap + s3.backoffUntil = 0; + await quotaStatus38(); + const s4 = getQuotaProbeState38(); + assert.equal(s4.backoffMs, 3600_000, '38m: backoffMs stays capped at 3600s'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38n — successful probe resets backoff to 60s', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38n-')); + let callCount = 0; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38n'; + mock = await _startMockServer((_req, res) => { + callCount++; + if (callCount === 1) { + // First: failure to build up backoff + res.writeHead(429, {}); + res.end('{}'); + } else { + // Second: success to reset backoff + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + } + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + // First call fails → backoff scheduled + backoffMs doubled + await quotaStatus38(); + const s1 = getQuotaProbeState38(); + assert.equal(s1.backoffMs, 120_000, '38n: backoffMs should be 120s after failure'); + + // Clear backoff window to allow retry + s1.backoffUntil = 0; + // Force cache miss so probe runs + if (s1.cache) s1.cache.fetchedAt = 0; + + // Second call succeeds → backoff should reset to 60s + const r2 = await quotaStatus38(); + assert.ok(r2 !== null, '38n: second call should succeed'); + const s2 = getQuotaProbeState38(); + assert.equal(s2.backoffMs, 60_000, '38n: successful probe resets backoffMs to 60s'); + assert.equal(s2.backoffUntil, 0, '38n: successful probe resets backoffUntil to 0'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38o: schemaVersion from models-registry.json ───────────────────────── + + it('38o — schemaVersion sourced from models-registry.json (not only the constant)', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38o-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38o'; + mock = await _startMockServer((_req, res) => { + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const result = await quotaStatus38(); + assert.ok(result !== null, '38o: should return non-null'); + // models-registry.json has quota_probe.schema_version = '2026-05-26' + // The returned schemaVersion must be a non-empty string (from registry or constant) + assert.ok(typeof result.schemaVersion === 'string' && result.schemaVersion.length > 0, + '38o: schemaVersion must be a non-empty string from registry or constant fallback'); + // Confirm it matches what the registry says (ties to Suite 37g regression). + // ESM has no `require`; read the registry directly via node:fs. + const registryPath = _pathJoin38(import.meta.dirname ?? process.cwd(), 'models-registry.json'); + let registry = {}; + try { registry = JSON.parse(_readFileSync38(registryPath, 'utf8')); } catch { /* ignore */ } + const expected = registry?.quota_probe?.schema_version; + assert.ok(expected, + '38o: models-registry.json must define quota_probe.schema_version (ADR 0013 Rule 5)'); + assert.equal(result.schemaVersion, expected, + '38o: schemaVersion should match models-registry.json quota_probe.schema_version'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + // ── 38p–38t: doctorChecks anthropic.quota_probe_reachable (5 status paths) ─ + + it('38p — doctor: quota_probe_reachable disabled → ok with advisory message', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38p-')); + try { + // Write config with probe disabled (or missing = default disabled) + _mkdirSync38(TMP, { recursive: true }); + _writeFileSync38(_pathJoin38(TMP, 'config.json'), JSON.stringify({ + providers: { anthropic: { quota_probe_enabled: false } }, + })); + process.env.OLP_HOME = TMP; + resetQuotaProbe38(); + + const checks = doctorChecks38({ + _binaryExistsFn: () => true, + _authReadFn: () => ({ accessToken: 'tok' }), + }); + const probeCheck = checks.find(c => c.id === 'anthropic.quota_probe_reachable'); + assert.ok(probeCheck, '38p: quota_probe_reachable check must exist'); + + const result = await probeCheck.run(); + assert.equal(result.status, 'ok', '38p: disabled probe → ok'); + assert.ok(result.message.includes('disabled') || result.message.includes('opt-in'), + '38p: message should mention disabled/opt-in'); + } finally { + resetQuotaProbe38(); + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38q — doctor: probe enabled + probe succeeds → ok with utilization in message', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38q-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38q'; + mock = await _startMockServer((_req, res) => { + res.writeHead(200, _ALL_13_HEADERS); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + const checks = doctorChecks38({ + _binaryExistsFn: () => true, + _authReadFn: () => ({ accessToken: 'test-token-38q' }), + }); + const probeCheck = checks.find(c => c.id === 'anthropic.quota_probe_reachable'); + const result = await probeCheck.run(); + assert.equal(result.status, 'ok', '38q: successful probe → ok'); + assert.ok(result.message.includes('OK') || result.message.includes('utilization'), + '38q: message should include OK or utilization info'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38r — doctor: probe enabled + probe fails + cache stale → warn', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38r-')); + let callCount = 0; + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38r'; + mock = await _startMockServer((_req, res) => { + callCount++; + if (callCount === 1) { + // First: succeed to seed cache + res.writeHead(200, _ALL_13_HEADERS); + } else { + // Subsequent: fail + res.writeHead(500, {}); + } + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); + + // Seed cache with a successful probe + await quotaStatus38(); + const state = getQuotaProbeState38(); + // Set cache to expired so doctor re-probes (doctor calls _probeOnce directly) + // Doctor check calls _probeOnce(auth) directly — it always makes a fresh probe + // regardless of cache. After _probeOnce fails, it checks quotaProbeState.cache. + // The state.cache is still the seeded one. + + const checks = doctorChecks38({ + _binaryExistsFn: () => true, + _authReadFn: () => ({ accessToken: 'test-token-38r' }), + }); + const probeCheck = checks.find(c => c.id === 'anthropic.quota_probe_reachable'); + const result = await probeCheck.run(); + assert.equal(result.status, 'warn', '38r: failed probe with stale cache → warn'); + assert.ok(result.message.includes('stale') || result.message.includes('failed'), + '38r: message should mention stale or failure'); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38s — doctor: probe enabled + probe fails + no cache → fail with fix_commands', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38s-')); + let mock; + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token-38s'; + mock = await _startMockServer((_req, res) => { + // Always fail with no rate-limit headers → _probeOnce returns null + res.writeHead(500, {}); + res.end('{}'); + }); + setQuotaUrls38(`${mock.url}/v1/messages`, `${mock.url}/v1/oauth/token`); + resetQuotaStateOnly38(); // No cache + + const checks = doctorChecks38({ + _binaryExistsFn: () => true, + _authReadFn: () => ({ accessToken: 'test-token-38s' }), + }); + const probeCheck = checks.find(c => c.id === 'anthropic.quota_probe_reachable'); + const result = await probeCheck.run(); + assert.equal(result.status, 'fail', '38s: failed probe + no cache → fail'); + assert.ok(result.evidence, '38s: evidence block should be present'); + assert.ok( + Array.isArray(result.evidence.fix_commands) || Array.isArray(result.evidence.human_steps), + '38s: fix_commands or human_steps should be present in evidence', + ); + } finally { + if (mock) await mock.close(); + resetQuotaProbe38(); + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + + it('38t — doctor: probe enabled + no creds → fail with human-only recovery steps', async () => { + _saveEnv38(); + const TMP = _mkdtempSync38(_pathJoin38(_tmpdir38(), 'olp-38t-')); + try { + _writeProbeConfig(TMP); + process.env.OLP_HOME = TMP; + resetQuotaProbe38(); + + const checks = doctorChecks38({ + _binaryExistsFn: () => true, + _authReadFn: () => null, // no credentials + }); + const probeCheck = checks.find(c => c.id === 'anthropic.quota_probe_reachable'); + const result = await probeCheck.run(); + assert.equal(result.status, 'fail', '38t: no creds → fail'); + assert.ok(result.evidence, '38t: evidence block should be present'); + assert.ok(Array.isArray(result.evidence.human_steps), + '38t: human_steps should be present for no-creds failure'); + assert.ok(result.evidence.human_steps.length > 0, + '38t: human_steps should not be empty'); + } finally { + resetQuotaProbe38(); + _restoreEnv38(); + _rmSync38(TMP, { recursive: true, force: true }); + } + }); + +}); + +// ── Suite 39: D83 dashboard rendering smoke tests (Phase 5, ADR 0012 D83) ── +// +// Smoke tests for the /dashboard route (D82). Boots an ephemeral OLP server +// with an owner key, performs HTTP GETs against /dashboard, and asserts the +// served HTML body contains the key UI strings that lock the D82 deliverable. +// +// These tests do NOT exercise the dashboard's runtime JavaScript — they verify +// the static HTML structure that guarantees the D82 features are actually +// embedded in the served file. +// +// Approach: +// 1. Per-test: create a fresh tmpdir + write a minimal config +// 2. Create an owner key via createKey() +// 3. Boot an ephemeral OLP server on port 0 +// 4. HTTP GET /dashboard with owner token → assert 200 + HTML body strings +// +// Authority: ADR 0012 D83 + ADR 0008 § 6 (owner-only_block) + +// ADR 0008 Amendment (D82 — quota_v2 Claude.ai-style restructure) + D82 PR #54 + +import { + createOlpServer as createOlpServerS39, + __setAuthConfig as setAuthConfigS39, + __resetAuthConfig as resetAuthConfigS39, + __setProvidersEnabled as setProvidersEnabledS39, + __resetProvidersEnabled as resetProvidersEnabledS39, + __clearCache as clearCacheS39, + __clearRecentErrors as clearRecentErrorsS39, + __resetRequestCounters as resetRequestCountersS39, +} from './server.mjs'; +import { createKey as createKeyS39 } from './lib/keys.mjs'; +import { mkdtempSync as mkdtempSyncS39, rmSync as rmSyncS39, mkdirSync as mkdirSyncS39 } from 'node:fs'; +import { join as pathJoinS39 } from 'node:path'; +import { tmpdir as tmpdirS39 } from 'node:os'; + +/** Start an ephemeral OLP server. Returns { server, port, close }. */ +async function _startOlpServer() { + const server = createOlpServerS39(); + await new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', resolve); + server.once('error', reject); + }); + const port = server.address().port; + return { server, port, close: () => new Promise(r => server.close(r)) }; +} + +/** HTTP GET helper (plain strings, reuses the top-level httpRequest helper pattern) */ +function _getHttp(port, path, headers = {}) { + return new Promise((resolve, reject) => { + const req = httpRequest({ + hostname: '127.0.0.1', + port, + method: 'GET', + path, + headers, + }, res => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); + }); + req.on('error', reject); + req.end(); + }); +} + +describe('Suite 39 — D83 dashboard rendering smoke tests (Phase 5, ADR 0012 D83)', () => { + let TMP39; + let srv39; + let ownerToken39; + + before(async () => { + TMP39 = mkdtempSyncS39(pathJoinS39(tmpdirS39(), 'olp-39-')); + process.env.OLP_HOME = TMP39; + + // Minimal config (no providers needed — dashboard is auth + HTML only) + setProvidersEnabledS39({}); + setAuthConfigS39({ + allow_anonymous: false, + owner_only_endpoints: ['/dashboard', '/v0/management/dashboard-data'], + fallback_detail_header_policy: 'owner', + }); + clearCacheS39(); + clearRecentErrorsS39(); + resetRequestCountersS39(); + + // Create an owner key so we can authenticate to /dashboard + const { plaintext_token } = createKeyS39({ + name: '39-owner', + owner_tier: 'owner', + providers_enabled: '*', + olpHome: TMP39, + }); + ownerToken39 = plaintext_token; + + srv39 = await _startOlpServer(); + }); + + after(async () => { + if (srv39) await srv39.close(); + resetProvidersEnabledS39(); + resetAuthConfigS39(); + rmSyncS39(TMP39, { recursive: true, force: true }); + // Restore global test defaults set at module top + setAuthConfigS39({ + allow_anonymous: true, + owner_only_endpoints: [], + fallback_detail_header_policy: 'all', + }); + }); + + it('39a — /dashboard with owner token returns 200 + HTML content-type', async () => { + const res = await _getHttp(srv39.port, '/dashboard', { + Authorization: `Bearer ${ownerToken39}`, + }); + assert.equal(res.status, 200, '39a: owner token should get 200'); + assert.ok( + (res.headers['content-type'] ?? '').includes('text/html'), + `39a: content-type should be text/html, got: ${res.headers['content-type']}`, + ); + assert.ok(res.body.length > 0, '39a: response body should not be empty'); + }); + + it('39b — /dashboard without token returns 401', async () => { + const res = await _getHttp(srv39.port, '/dashboard'); + assert.equal(res.status, 401, '39b: no token should get 401'); + }); + + it('39c — /dashboard with non-owner guest key returns 401 (owner-only_block enforcement)', async () => { + // Create a guest key — it must NOT be allowed to access /dashboard + const { plaintext_token: guestTok } = createKeyS39({ + name: '39-guest', + owner_tier: 'guest', + providers_enabled: '*', + olpHome: TMP39, + }); + const res = await _getHttp(srv39.port, '/dashboard', { + Authorization: `Bearer ${guestTok}`, + }); + assert.equal(res.status, 401, '39c: guest key should get 401 on owner-only /dashboard'); + }); + + it('39d — dashboard HTML contains "Plan Usage" header', async () => { + const res = await _getHttp(srv39.port, '/dashboard', { + Authorization: `Bearer ${ownerToken39}`, + }); + assert.ok(res.body.includes('Plan Usage'), + '39d: dashboard HTML must contain "Plan Usage" (D82 panel header)'); + }); + + it('39e — dashboard HTML contains the manual refresh button (↻ Refresh)', async () => { + const res = await _getHttp(srv39.port, '/dashboard', { + Authorization: `Bearer ${ownerToken39}`, + }); + assert.ok(res.body.includes('↻') && res.body.includes('Refresh'), + '39e: dashboard HTML must contain the ↻ Refresh button (D82 manual refresh)'); + }); + + it('39f — dashboard HTML contains 60s quota poll interval constant', async () => { + const res = await _getHttp(srv39.port, '/dashboard', { + Authorization: `Bearer ${ownerToken39}`, + }); + assert.ok(res.body.includes('QUOTA_POLL_INTERVAL_MS') && res.body.includes('60000'), + '39f: dashboard HTML must declare QUOTA_POLL_INTERVAL_MS = 60000 (D82 1-min refresh)'); + }); + + it('39g — dashboard HTML contains visibilityState listener code', async () => { + const res = await _getHttp(srv39.port, '/dashboard', { + Authorization: `Bearer ${ownerToken39}`, + }); + assert.ok(res.body.includes('visibilityState') || res.body.includes('visibilitychange'), + '39g: dashboard HTML must contain visibilityState / visibilitychange guard (D82 ADR 0012)'); + }); + + it('39h — dashboard HTML contains both quota_v2 consumer and legacy quota fallback code', async () => { + const res = await _getHttp(srv39.port, '/dashboard', { + Authorization: `Bearer ${ownerToken39}`, + }); + assert.ok(res.body.includes('quota_v2'), + '39h: dashboard HTML must contain quota_v2 consumer code (D82 enriched shape)'); + assert.ok(res.body.includes('renderQuota') || res.body.includes('legacy'), + '39h: dashboard HTML must contain legacy quota fallback path (graceful-degradation proof)'); + }); +});