feat+test+docs: D45 — server.mjs auth integration + lib/audit.mjs (#21)

* feat+test+docs: D45 — server.mjs auth integration + lib/audit.mjs (Phase 2 wire-up)

Second Phase 2 implementation D-day. Wires the D44 lib/keys.mjs identity
layer into the request flow + lands lib/audit.mjs per ADR 0007 § 6.2
+ § 8.

Closes ADR § 10 acceptance criteria #1 (per-key cache isolation), #2
(anonymous prod-default off), #3 (anonymous dev-mode on), #6 (post-revoke
401 within next request — full coverage with D45), #8 (audit ndjson
round-trip), #10 (OLP_OWNER_TOKEN env override — full server-side
coverage), #11 (providers_enabled 403 scope). Owner-vs-guest gating for
/health + X-OLP-Fallback-Detail (criteria #4, #5) remains in D46 scope.

NEW lib/audit.mjs (~110 lines):

  - appendAuditEvent(event, opts): one JSON event per line to
    ~/.olp/logs/audit.ndjson (file 0600, dir 0700). § 6.2 retry: warn +
    1 retry; per-process drop counter + warn on second failure; NEVER
    throws. Per-call OLP_HOME env resolution (matches lib/keys.mjs).
  - getAuditDropCount(): for future /health surface.

lib/keys.mjs extended:

  - loadAuthConfigSync({ olpHome }): reads auth block from
    ~/.olp/config.json with ADR § 7.2 defaults (allow_anonymous: false,
    owner_only_endpoints: ['/health'], fallback_detail_header_policy:
    'owner_only'). Never throws; missing file / malformed JSON falls
    back to defaults.
  - _resolveOlpHome(opts): precedence opts.olpHome → process.env.OLP_HOME
    → ~/.olp. Per-call resolution so tests + operator deployments can
    redirect without code edits.

server.mjs auth middleware integration:

  - extractToken(req): parses Authorization Bearer / x-api-key.
  - authenticate(req): validateKey + 401 paths (auth_required vs
    invalid_or_revoked_key).
  - isProviderEnabled(olpIdentity, providerKey): '*' = all; else
    array allowlist.
  - _authConfig loaded at startup; warn auth_allow_anonymous_enabled
    when true. Test seams __setAuthConfig / __resetAuthConfig.
  - handleChatCompletions + handleModels both gated by authenticate at
    top. Audit ctx built throughout; res.on('finish') appends row +
    fires touchLastUsed async.
  - IDENTITY-VS-CREDENTIALS SEPARATION: olpIdentity (new validated
    identity) consumed for cache namespacing + providers_enabled +
    audit; authContext passed to provider.spawn() REMAINS null so
    providers continue their own credential discovery (env / keychain
    / file). Per-provider per-key credential mapping is Phase 3+ per
    ADR § 12.
  - handleChatCompletions chain filtered by isProviderEnabled; empty
    result returns 403 key_no_provider_access.
  - keyId = olpIdentity.keyId (replacing hardcoded '__anonymous__').
  - Audit captures fields throughout: post-auth, post-IR, post-chain
    (success or exhausted). Status + latency populated on
    res.on('finish').

TESTS — Suite 20, +15 (499 → 514):

  20a-d: header parsing + valid key happy paths (Bearer / x-api-key /
    invalid → 401)
  20e: revoked key 401 (criterion #6 end-to-end)
  20f: OLP_OWNER_TOKEN env override returns 200 (criterion #10 full)
  20g: allow_anonymous=true + no header returns 200 (criterion #3)
  20h + 20h-extra: providers_enabled=['mistral'] for anthropic model →
    403; '*' baseline returns 200 (criterion #11)
  20i: per-key cache namespace isolation (criterion #1 end-to-end)
  20j + 20j-401: audit.ndjson written with § 8 schema fields + PII
    guard; 401 path also appends (criterion #8)
  20k: filesystem key last_used_at populated post-request (D45 touch
    wire)
  20l + 20l-200: /v1/models also enforces auth

TEST-MODE SETUP (test-features.mjs):

  - process.env.OLP_HOME = mkdtempSync(...) at module load so audit +
    key writes don't pollute ~/.olp/.
  - __setAuthConfig({ allow_anonymous: true }) after server.mjs imports
    so pre-D45 HTTP integration tests (Suite 18 etc.) continue to pass.
  - Suite 20 explicitly overrides __setAuthConfig per-case to exercise
    production-default-off coverage.

DOCUMENTATION:

  - AGENTS.md: lib/keys.mjs 🟡 marker updated + NEW lib/audit.mjs entry;
    Implementation-status-note + shipped-set updated.
  - README.md: Implementation Status table gains lib/audit.mjs row +
    lib/keys.mjs row updated; Known limitations Multi-key auth note
    rewritten to reflect D45 ship + D46 follow-up; new env-vars
    (OLP_HOME, OLP_OWNER_TOKEN) and auth config block surfaced.
  - CHANGELOG.md: D45 entry under Unreleased per release_kit overlay
    phase_rolling_mode discipline.

AUTHORITY:

  - ADR 0007 (multi-key auth — §§ 5/6.2/7/9.4 implementation
    contracts + § 10 criteria #1/#2/#3/#6/#8/#10/#11 covered).
  - CLAUDE.md release_kit overlay phase_rolling_mode — under
    Unreleased.
  - Phase 2 kickoff handoff (~/.cc-rules/memory/handoffs/
    2026-05-25-phase-2-kickoff.md in cc-rules d9da966).
  - Standing autopilot grant (~/.cc-rules/memory/auto/
    standing_autopilot_phase_2.md in cc-rules bf0ed9a).

Verified: 514/514 pass via npm test (no regression in 499 existing
tests; 15 new Suite 20 tests all green).

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

* fix+test+docs: D45 fold-in — CI fail recovery + opus reviewer P1/P2/P3

Fresh-context opus reviewer (PR #21) returned APPROVE_WITH_MINOR with 4
findings; CI Node 24 separately reported 9 Suite 20 failures (all
200-expecting tests). Root cause of CI: Suite 20 setup did not stub
CLAUDE_CODE_OAUTH_TOKEN before mock spawn, so anthropic.mjs AUTH_MISSING
pre-check fired and tests 502'd. (Local Node 22 had the env from the
maintainer's claude install — masked the gap.)

CI FIX — Suite 20 OAuth env stub

  Added ensureSuite20FakeOAuth / restoreSuite20OAuth helpers in
  makeSuite20Server / teardownSuite20. Matches the existing pattern in
  Suite 9 line ~2154 (test-fake-oauth-token-for-cache-tests).

P1 — Real-streaming path audit fidelity

  Single-hop streaming success (server.mjs ~L1050, the most common
  deployed shape) did not populate auditCtx.provider / tried_providers
  / cache_status. Audit rows for streaming requests carried
  provider: null. Fixed by stamping these at the top of the streaming
  branch and amending error_code on the two streaming failure exit
  paths (streaming_error_after_first_chunk +
  streaming_error_before_first_chunk).

  New regression test 20j-stream: streaming request asserts the audit
  row's provider, cache_status, and tried_providers fields are
  populated.

P2 — Global test tmpdir cleanup

  process.env.OLP_HOME = mkdtempSync(...) at test-features.mjs module
  load left /var/folders/.../olp-test-home-* leak per npm test run.
  Fixed by process.on('exit', () => rmSync(_GLOBAL_TEST_OLP_HOME)).
  Best-effort; swallows errors so exit handler never throws.

P3 — handleModels 401 lacks OLP diagnostic headers

  handleChatCompletions 401 passes olpErrorHeaders({ startMs });
  handleModels 401 did not. Aligned.

DEFERRED — P2 tried_providers semantics on 403

  Reviewer noted that key_no_provider_access 403 stamps original chain
  in tried_providers, but the field name implies hops actually
  dispatched. Either ADR § 8 amendment or D46+ semantic fix. Marked
  in CHANGELOG; not in this fold-in scope.

Test count: 514 → 515 (+1 streaming-audit regression test 20j-stream;
14 existing Suite 20 tests still pass). Verified locally via
npm test. CI Node 24 recovery via the OAuth env stub.

Authority: PR #21 fresh-context opus reviewer findings; CI Node 24
run 26382758946 failure logs; CLAUDE.md release_kit overlay
phase_rolling_mode — under Unreleased.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-25 14:28:45 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent 4b9916341b
commit 40064955ab
7 changed files with 989 additions and 16 deletions
+500 -1
View File
@@ -13,10 +13,47 @@ import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { request as httpRequest } from 'node:http';
import { EventEmitter } from 'node:events';
import { homedir } from 'node:os';
import { homedir, tmpdir as _tmpdirForSetup } from 'node:os';
import { mkdtempSync as _mkdtempSyncForSetup } from 'node:fs';
import { join as _pathJoinForSetup } from 'node:path';
import { computeCacheKey, extractCacheControlMarkers, hasCacheControl } from './lib/cache/keys.mjs';
import { CacheStore } from './lib/cache/store.mjs';
// ── Phase 2 / D45 test-mode setup ─────────────────────────────────────────
// Two adjustments keep pre-D45 tests working alongside the new auth gate:
// 1. process.env.OLP_HOME → tmpdir so audit ndjson / manifest writes
// triggered by handleChatCompletions / handleModels do not pollute
// the user's real ~/.olp/. lib/keys.mjs + lib/audit.mjs resolve the
// env per-call so this takes effect immediately.
// 2. server.mjs __setAuthConfig({ allow_anonymous: true }) so existing
// HTTP integration tests (Suite 18 etc.) that hit /v1/chat/completions
// and /v1/models without an Authorization header continue to pass
// via the anonymous identity. Suite 20 (D45 auth tests) explicitly
// overrides per-case to exercise allow_anonymous: false / valid key /
// revoked / env-owner / providers_enabled paths.
// (ESM imports are hoisted, so all module side effects — including
// server.mjs's startup loadAuthConfigSync() — complete before this body
// code runs. Setting OLP_HOME + __setAuthConfig here applies to all
// suites below.)
const _GLOBAL_TEST_OLP_HOME = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-home-'));
process.env.OLP_HOME = _GLOBAL_TEST_OLP_HOME;
// Clean up the global test tmpdir on process exit so successive npm test runs
// don't accumulate /var/folders/.../olp-test-home-* directories. process.on
// ('exit') fires synchronously after node:test reports all results.
process.on('exit', () => {
try {
// rmSync is imported lower in the file (Suite 19 imports it from 'node:fs').
// ESM hoists all imports to top-of-module so the binding is available here.
rmSync(_GLOBAL_TEST_OLP_HOME, { recursive: true, force: true });
} catch {
// best-effort; do not throw at exit
}
});
// __setAuthConfig is imported below from './server.mjs'; deferring the call
// to a later block (after server.mjs's import-time loadAuthConfigSync runs)
// is necessary because ESM hoists imports before this body code. See the
// "Phase 2 / D45 server-side default override" block below the imports.
// ── Modules under test ────────────────────────────────────────────────────
import { validateIRRequest, validateIRMessage, VALID_ROLES, IR_VERSION } from './lib/ir/types.mjs';
@@ -4302,8 +4339,19 @@ import {
__setFallbackConfig,
__resetFallbackConfig,
__clearCache,
__setAuthConfig,
__resetAuthConfig,
} from './server.mjs';
// ── Phase 2 / D45 server-side default override ────────────────────────────
// Override the auth.allow_anonymous default (false in production) so that
// existing pre-D45 HTTP integration tests (Suite 18 etc.) that make /v1/*
// requests without an Authorization header continue to pass as anonymous.
// New Suite 20 tests explicitly call __setAuthConfig per-case to exercise
// the production-default-off path + valid key / revoked / providers_enabled
// scopes.
__setAuthConfig({ allow_anonymous: true });
// ── 13a: Trigger taxonomy ────────────────────────────────────────────────
describe('Fallback engine — trigger taxonomy (D9)', () => {
@@ -9865,3 +9913,454 @@ describe('Suite 19 — lib/keys.mjs multi-key auth (ADR 0007, D44)', () => {
});
});
// ── Suite 20: server.mjs auth integration (D45, ADR 0007 §§ 5/6.2/7/9.4) ──
//
// HTTP-level tests for the Phase 2 D45 server-side wire-up:
// - auth.allow_anonymous false-by-default 401
// - Authorization Bearer / x-api-key header acceptance
// - revoked-key 401 (acceptance criterion #6 — full coverage with D45)
// - OLP_OWNER_TOKEN env override (acceptance criterion #10 — full coverage)
// - providers_enabled 403 scope enforcement (acceptance criterion #11)
// - per-key cache namespace isolation (acceptance criterion #1)
// - audit ndjson written with correct fields (acceptance criterion #8)
// - touchLastUsed wire updates last_used_at after a request
// - /v1/models gates auth the same way as /v1/chat/completions
import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'node:fs';
import { appendAuditEvent, __resetAuditDropCount } from './lib/audit.mjs';
describe('Suite 20 — server.mjs auth integration (D45, ADR 0007)', () => {
// Each describe block gets its own tmp OLP_HOME so audit + key writes are
// isolated and verifiable. Restore the global test default in after().
const GLOBAL_OLP_HOME = process.env.OLP_HOME;
// Each Suite 20 server requires the anthropic auth-check to pass before the
// mock spawn runs. lib/providers/anthropic.mjs _spawnAndStream checks for an
// OAuth token BEFORE calling the (mock) spawn — without a token the AUTH_MISSING
// pre-check fires and the request 502s before the mock can return chunks. Same
// pattern as Suite 9 cache HTTP tests (line ~2154 "test-fake-oauth-token-for-
// cache-tests"). CI Node 24 has no OAuth env; local dev machines may; CI was
// the trigger that caught this.
let _suite20SavedOAuth;
function ensureSuite20FakeOAuth() {
_suite20SavedOAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'suite20-fake-oauth-token';
}
function restoreSuite20OAuth() {
if (_suite20SavedOAuth !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = _suite20SavedOAuth;
else delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
}
function makeSuite20Server() {
__setProvidersEnabled({ anthropic: true });
__setSpawnImpl(makeMockSpawn(['suite20-mock-response']));
ensureSuite20FakeOAuth();
const server = createOlpServer();
return new Promise(resolve => {
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
});
}
function teardownSuite20(server) {
return new Promise(resolve => {
__resetSpawnImpl();
__setProvidersEnabled({});
__clearCache();
restoreSuite20OAuth();
if (server) server.close(() => resolve());
else resolve();
});
}
// ── 20a-d: header parsing + happy paths ────────────────────────────────
describe('20a-d — header parsing + valid key paths', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20ad-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(server);
process.env.OLP_HOME = GLOBAL_OLP_HOME;
__setAuthConfig({ allow_anonymous: true });
rmSync(TMP, { recursive: true, force: true });
});
it('20a: allow_anonymous=false + no Authorization header → 401 auth_required', async () => {
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20a' }] },
});
assert.equal(r.status, 401);
const err = JSON.parse(r.body);
assert.equal(err.error.type, 'auth_required');
});
it('20b: valid Authorization: Bearer <token> → 200 (filesystem identity)', async () => {
const { plaintext_token } = createKey({ name: '20b-bearer', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20b' }] },
});
assert.equal(r.status, 200);
});
it('20c: x-api-key header alternative → 200 (filesystem identity)', async () => {
const { plaintext_token } = createKey({ name: '20c-xapikey', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { 'x-api-key': plaintext_token },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20c' }] },
});
assert.equal(r.status, 200);
});
it('20d: invalid token → 401 invalid_or_revoked_key', async () => {
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: 'Bearer olp_not-a-real-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20d' }] },
});
assert.equal(r.status, 401);
const err = JSON.parse(r.body);
assert.equal(err.error.type, 'invalid_or_revoked_key');
});
});
// ── 20e-g: revocation + env owner + allow_anonymous true ────────────────
describe('20e-g — revocation + env owner override + anonymous-mode dev', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20eg-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(server);
process.env.OLP_HOME = GLOBAL_OLP_HOME;
__setAuthConfig({ allow_anonymous: true });
delete process.env.ENV_OWNER_VAR;
delete process.env[ENV_OWNER_VAR];
rmSync(TMP, { recursive: true, force: true });
});
it('20e: revoked key → 401 invalid_or_revoked_key (criterion #6 full coverage)', async () => {
const { id, plaintext_token } = createKey({ name: '20e-rev', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
// First request — should succeed
const r1 = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20e-1' }] },
});
assert.equal(r1.status, 200);
// Revoke
await revokeKey({ id, olpHome: TMP });
// Second request — must 401
const r2 = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20e-2' }] },
});
assert.equal(r2.status, 401);
const err = JSON.parse(r2.body);
assert.equal(err.error.type, 'invalid_or_revoked_key');
});
it('20f: OLP_OWNER_TOKEN env override → 200 (env identity, criterion #10 full coverage)', async () => {
const envToken = 'olp_' + 'f'.repeat(43);
process.env[ENV_OWNER_VAR] = envToken;
try {
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${envToken}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20f' }] },
});
assert.equal(r.status, 200);
} finally {
delete process.env[ENV_OWNER_VAR];
}
});
it('20g: allow_anonymous=true + no header → 200 (anonymous identity — dev escape hatch)', async () => {
__setAuthConfig({ allow_anonymous: true });
try {
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20g' }] },
});
assert.equal(r.status, 200);
} finally {
__setAuthConfig({ allow_anonymous: false });
}
});
});
// ── 20h: providers_enabled scope enforcement (criterion #11) ─────────────
describe('20h — providers_enabled scope enforcement (criterion #11)', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20h-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(server);
process.env.OLP_HOME = GLOBAL_OLP_HOME;
__setAuthConfig({ allow_anonymous: true });
rmSync(TMP, { recursive: true, force: true });
});
it('20h: providers_enabled: ["mistral"] + anthropic-routed model → 403 key_no_provider_access', async () => {
const { plaintext_token } = createKey({ name: '20h-scoped', owner_tier: 'guest', providers_enabled: ['mistral'], olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20h' }] },
});
assert.equal(r.status, 403);
const err = JSON.parse(r.body);
assert.equal(err.error.type, 'key_no_provider_access');
});
it('20h-extra: providers_enabled: "*" + anthropic-routed model → 200 (sanity baseline)', async () => {
const { plaintext_token } = createKey({ name: '20h-wildcard', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20h-star' }] },
});
assert.equal(r.status, 200);
});
});
// ── 20i: per-key cache namespace isolation (criterion #1) ────────────────
describe('20i — per-key cache namespace isolation (criterion #1)', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20i-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(server);
process.env.OLP_HOME = GLOBAL_OLP_HOME;
__setAuthConfig({ allow_anonymous: true });
rmSync(TMP, { recursive: true, force: true });
});
it('20i: keys A + B sending identical payload do NOT share cache (per-keyId namespace from ADR 0005 D1)', async () => {
const { plaintext_token: tokenA } = createKey({ name: '20i-A', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
const { plaintext_token: tokenB } = createKey({ name: '20i-B', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
const sharedPayload = {
model: 'claude-sonnet-4-6',
messages: [{ role: 'user', content: '20i-cache-shared-prompt' }],
};
// Key A: first request → miss
const rA1 = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${tokenA}` },
body: sharedPayload,
});
assert.equal(rA1.status, 200);
assert.equal(rA1.headers['x-olp-cache'], 'miss');
// Key A: second identical request → hit
const rA2 = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${tokenA}` },
body: sharedPayload,
});
assert.equal(rA2.status, 200);
assert.equal(rA2.headers['x-olp-cache'], 'hit', 'Key A second request must hit cache within A namespace');
// Key B: identical payload but different key → MUST be miss (isolated namespace)
const rB1 = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${tokenB}` },
body: sharedPayload,
});
assert.equal(rB1.status, 200);
assert.equal(rB1.headers['x-olp-cache'], 'miss',
'Key B first request MUST be miss — per-key cache isolation (ADR 0005 D1 + ADR 0007 § 7)');
});
});
// ── 20j: audit ndjson written with § 8 schema fields ─────────────────────
describe('20j — audit ndjson written per § 8 schema', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20j-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
__resetAuditDropCount();
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(server);
process.env.OLP_HOME = GLOBAL_OLP_HOME;
__setAuthConfig({ allow_anonymous: true });
rmSync(TMP, { recursive: true, force: true });
});
it('20j: successful request appends an audit row with key_id, model, status_code, latency_ms (criterion #8)', async () => {
const { id, plaintext_token } = createKey({ name: '20j-aud', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20j' }] },
});
assert.equal(r.status, 200);
// Allow the res.on('finish') hook to flush the audit write
await new Promise(resolve => setTimeout(resolve, 25));
const auditPath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
assert.ok(fsExistsSync(auditPath), 'audit.ndjson must be created on first request');
const lines = fsReadFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean);
assert.ok(lines.length >= 1, 'at least one audit line expected');
const lastRow = JSON.parse(lines[lines.length - 1]);
assert.equal(lastRow.key_id, id);
assert.equal(lastRow.owner_tier, 'guest');
assert.equal(lastRow.method, 'POST');
assert.equal(lastRow.path, '/v1/chat/completions');
assert.equal(lastRow.model, 'claude-sonnet-4-6');
assert.equal(lastRow.status_code, 200);
assert.ok(typeof lastRow.latency_ms === 'number' && lastRow.latency_ms >= 0);
assert.ok(typeof lastRow.ts === 'string');
assert.equal(lastRow.provider, 'anthropic');
// PII guard: no message / response content in the audit row
assert.ok(!('content' in lastRow), 'no message content in audit row (§ 8 no PII)');
assert.ok(JSON.stringify(lastRow).indexOf('20j') === -1,
'request payload text must NOT appear in audit row (§ 8 no PII)');
});
it('20j-stream: streaming-path audit row populates provider + cache_status (D45 fold-in P1 regression)', async () => {
const { plaintext_token } = createKey({ name: '20j-stream', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP });
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}`, Accept: 'text/event-stream' },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20j-stream' }], stream: true },
});
assert.equal(r.status, 200);
await new Promise(resolve => setTimeout(resolve, 50));
const auditPath = _pathJoinForSetup(TMP, 'logs', 'audit.ndjson');
const lines = fsReadFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean);
const streamingRows = lines.map(l => JSON.parse(l)).filter(row =>
row.path === '/v1/chat/completions' && row.status_code === 200 && row.key_id !== ANONYMOUS_KEY_ID,
);
assert.ok(streamingRows.length >= 1, 'at least one successful authed row expected');
const lastStreamingRow = streamingRows[streamingRows.length - 1];
// Prior to D45 fold-in P1: provider was null on real-streaming success path.
assert.equal(lastStreamingRow.provider, 'anthropic',
'streaming-path audit row MUST populate provider (D45 fold-in P1 regression)');
assert.equal(lastStreamingRow.cache_status, 'miss',
'streaming-path audit row MUST populate cache_status');
assert.deepEqual(lastStreamingRow.tried_providers, ['anthropic']);
});
it('20j-401: 401 unauth request still appends audit row with error_code', async () => {
const before = fsExistsSync(_pathJoinForSetup(TMP, 'logs', 'audit.ndjson'))
? fsReadFileSync(_pathJoinForSetup(TMP, 'logs', 'audit.ndjson'), 'utf-8').split('\n').filter(Boolean).length
: 0;
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20j-401' }] },
});
assert.equal(r.status, 401);
await new Promise(resolve => setTimeout(resolve, 25));
const after = fsReadFileSync(_pathJoinForSetup(TMP, 'logs', 'audit.ndjson'), 'utf-8').split('\n').filter(Boolean);
assert.ok(after.length > before, '401 must also append a row');
const lastRow = JSON.parse(after[after.length - 1]);
assert.equal(lastRow.status_code, 401);
assert.equal(lastRow.error_code, 'auth_required');
});
});
// ── 20k: touchLastUsed wire after successful request ────────────────────
describe('20k — touchLastUsed wire updates last_used_at post-request', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20k-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(server);
process.env.OLP_HOME = GLOBAL_OLP_HOME;
__setAuthConfig({ allow_anonymous: true });
rmSync(TMP, { recursive: true, force: true });
});
it('20k: filesystem key last_used_at populated after first successful request', async () => {
const { id, plaintext_token, manifest: m0 } = createKey({
name: '20k-touch', owner_tier: 'guest', providers_enabled: ['anthropic'], olpHome: TMP,
});
assert.equal(m0.last_used_at, null);
const r = await fetch({
port, method: 'POST', path: '/v1/chat/completions',
headers: { Authorization: `Bearer ${plaintext_token}` },
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: '20k' }] },
});
assert.equal(r.status, 200);
// Allow the async touchLastUsed (fired in res.on('finish')) to settle
await new Promise(resolve => setTimeout(resolve, 50));
const m1 = readManifest(id, { olpHome: TMP });
assert.ok(m1.last_used_at !== null, 'last_used_at must be populated after first request');
assert.equal(m1.revoked_at, null, 'revoked_at unchanged');
});
});
// ── 20l: /v1/models also enforces auth ───────────────────────────────────
describe('20l — /v1/models also enforces auth', () => {
let TMP, server, port;
before(async () => {
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-20l-'));
process.env.OLP_HOME = TMP;
__setAuthConfig({ allow_anonymous: false });
({ server, port } = await makeSuite20Server());
});
after(async () => {
await teardownSuite20(server);
process.env.OLP_HOME = GLOBAL_OLP_HOME;
__setAuthConfig({ allow_anonymous: true });
rmSync(TMP, { recursive: true, force: true });
});
it('20l: /v1/models without auth → 401', async () => {
const r = await fetch({ port, method: 'GET', path: '/v1/models' });
assert.equal(r.status, 401);
});
it('20l-200: /v1/models with valid Bearer → 200 + data array', async () => {
const { plaintext_token } = createKey({ name: '20l-ok', owner_tier: 'guest', providers_enabled: '*', olpHome: TMP });
const r = await fetch({
port, method: 'GET', path: '/v1/models',
headers: { Authorization: `Bearer ${plaintext_token}` },
});
assert.equal(r.status, 200);
const body = JSON.parse(r.body);
assert.equal(body.object, 'list');
assert.ok(Array.isArray(body.data));
});
});
});