test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5) (#55)

* test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5)

ADR 0012 D83 row: comprehensive test coverage for the Phase 5 quota probe
machinery (D80) and dashboard rendering (D82).

## Authorities cited

- ADR 0012 D83 (D-day specification)
- ADR 0013 Rules 2–6 (quota probe constraints being tested)
- D80 PR #52 (anthropic.quotaStatus() + _probeOnce + _parseRateLimitHeaders)
- D81 PR #53 (aggregateProviderQuota quota_v2 shape)
- D82 PR #54 (dashboard.html Claude.ai-style restructure)
- Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md

## Test seams added to lib/providers/anthropic.mjs

Minimal test seams added — 0 production-logic changes:
- `_setQuotaUrlsForTest(apiUrl, oauthUrl)` — redirects probe HTTP to local
  mock server (auto-detects http: vs https: to switch transport module)
- `_resetQuotaProbeStateForTest()` — resets cache + backoff + URLs + auth seam
- `_resetQuotaStateOnlyForTest()` — resets cache + backoff only (URLs stay)
- `_getQuotaProbeStateForTest()` — returns direct reference to quotaProbeState
  for test assertions and controlled state mutation
- `_setQuotaAuthReadFnForTest(fn)` — injects a mock auth reader into quotaStatus()
  so tests are not affected by real ~/.claude/.credentials.json on the machine
  (fixes 38f: no-auth test was finding real keychain credentials)

## Suite 38 — 20 quota-probe unit tests (38a–38t)

38a: all 13 ratelimit-unified-* headers parsed correctly (numeric types, null defaults)
38b: missing overage-reset → overage_reset: null
38c: missing all three overage fields → all three null
38d: new 5h-status + 7d-status fields (NEW vs OCP 2026-04) parsed correctly
38e: quota_probe_enabled: false → null without HTTP call
38f: auth returns null → quotaStatus returns null without HTTP call
38g: 200 + all 13 headers → full shape with stale:false + backoff reset
38h: cache hit within 5min TTL → no second HTTP call (request count stays 1)
38i: expired cache (6min > 5min TTL) → fires fresh HTTP probe
38j: 401 with no refreshToken → null (idempotent-failure per ADR 0002 Amendment 8 §3)
38k: 429 + stale cache → returns stale cache with stale:true + last_fresh_at
38l: 429 + no cache → null + backoff scheduled (backoffUntil in the future)
38m: exponential backoff growth: 60s→120s→240s→cap at 3600s
38n: successful probe resets backoffMs to 60s + backoffUntil to 0
38o: schemaVersion from models-registry.json (falls back to constant)
38p: doctor quota_probe_reachable disabled → ok with opt-in advisory
38q: doctor probe enabled + probe succeeds → ok with utilization in message
38r: doctor probe enabled + fails + stale cache → warn
38s: doctor probe enabled + fails + no cache → fail with fix_commands
38t: doctor probe enabled + no creds → fail with human_steps only

## Suite 39 — 8 dashboard rendering smoke tests (39a–39h)

39a: /dashboard with owner token → 200 + text/html
39b: /dashboard without token → 401
39c: /dashboard with guest key → 401 (owner-only_block enforcement per ADR 0008 §8)
39d: HTML contains "Plan Usage" header (D82 panel)
39e: HTML contains ↻ Refresh button (D82 manual refresh)
39f: HTML contains QUOTA_POLL_INTERVAL_MS = 60000 (D82 1-min refresh)
39g: HTML contains visibilitychange / visibilityState guard (D82 ADR 0012)
39h: HTML contains quota_v2 consumer + legacy renderQuota fallback code

## Test count: 727 → 755 (+28)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: D83 fold-in — fix 38j misleading name + add 38j2 positive-path + fix 38o ESM require

Fresh-context reviewer (PR #55) flagged one blocking issue + 1 nit:

BLOCKING (Q5) — 38j misleading name + uncovered 401→refresh→retry positive path.
The original 38j test description said "401 → refresh succeeds → retry probe
succeeds" but the test actually asserted the OPPOSITE behavior (401 with no
refreshToken → null, no retry). The most important non-trivial control-flow
branch in _probeOnce (anthropic.mjs:451-457 the refresh-and-retry on 401) was
uncovered, and the misleading name actively masked the gap.

Fix:
- Renamed 38j to accurately describe what it tests: "401 with no refreshToken
  → null (idempotent-failure, no refresh attempt)". The test pins valuable
  behavior — idempotent-failure when refreshToken is absent — but now its
  name matches.
- Added 38j2 (NEW) to exercise the actual positive-path: inject creds with
  refreshToken via _setQuotaAuthReadFnForTest, mock returns 401 on first API
  call + 200 with 13 headers on retry. Asserts: 2 API calls, 1 OAuth call,
  retry parsed full shape, OAuth body contains the injected refreshToken
  (Rule 1 credential reuse verification).

NIT (Q5 dead assertion) — 38o tried to read models-registry.json via
require('fs') which is undefined in ESM. The assert was silently never
exercised. Replaced with the already-imported readFileSync from node:fs
(added _readFileSync38 import). Also tightened: schemaVersion MUST equal
the registry's quota_probe.schema_version (no longer conditional on
"if expected" which was always falsy).

Other reviewer nits deferred (non-blocking per reviewer):
- N2: seam naming convention (_ vs __) — defer
- N3: commit message "zero production changes" — note for v0.5.0 close
- N4: _getQuotaProbeStateForTest returns mutable ref — defer
- N5: test seam runtime guard — defer
- N6: coverage gaps (concurrent probes, registry-missing fallback,
       network error path) — v1.x roadmap follow-ups
- N7: Suite 39 stray error listener — defer (benign)

Test count: 755 → 756 (+1 net, +2 new minus existing 38j rename).
All 756 pass; 0 fail. Local re-run: stable.

Authority: PR #55 review thread (D83 fresh-context opus reviewer)
+ ADR 0013 Rule 1 (credential reuse via refresh).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-26 17:47:15 +10:00
committed by GitHub
co-authored by taodeng Claude Sonnet 4.6
parent a41420d0fc
commit 2b07a3bd1b
2 changed files with 1196 additions and 10 deletions
+72 -10
View File
@@ -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;
+1124
View File
File diff suppressed because it is too large Load Diff