mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
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:
+500
-1
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user