test/caller-key.test.js
Rama master · réplica de lectura del repositorio original.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { callerKeyFromRequest, extractBodyCallerSubKey } from '../src/caller-key.js';
function fakeReq({ headers = {}, ip = '127.0.0.1' } = {}) {
return { headers, socket: { remoteAddress: ip } };
}
describe('extractBodyCallerSubKey (v2.0.25 HIGH-3)', () => {
it('returns empty for body with no user signal', () => {
assert.equal(extractBodyCallerSubKey({}), '');
assert.equal(extractBodyCallerSubKey(null), '');
assert.equal(extractBodyCallerSubKey({ messages: [], model: 'm' }), '');
});
it('returns a digest when body.user is present (OpenAI chat convention)', () => {
const k = extractBodyCallerSubKey({ user: 'alice@example.com' });
assert.ok(k && k.length === 16);
});
it('different users yield different digests', () => {
const a = extractBodyCallerSubKey({ user: 'alice' });
const b = extractBodyCallerSubKey({ user: 'bob' });
assert.notEqual(a, b);
});
it('audit S6: surrounding whitespace does not split one user into two buckets', () => {
// usableSignal trims, so " alice " and "alice" map to the same tenant scope
// (previously the raw untrimmed value hashed into different :user: buckets).
assert.equal(extractBodyCallerSubKey({ user: ' alice ' }), extractBodyCallerSubKey({ user: 'alice' }));
assert.equal(extractBodyCallerSubKey({ user: '\talice\n' }), extractBodyCallerSubKey({ user: 'alice' }));
// whitespace-only is still no signal
assert.equal(extractBodyCallerSubKey({ user: ' ' }), '');
});
it('does NOT scope on previous_response_id — it changes every turn', () => {
// It used to mint a subkey here. That silently cost cache affinity, and once
// the response store shipped it became a hard failure: turn 1 (no
// previous_response_id) and turn 2 (with one) derived DIFFERENT callerKeys, so
// the store's per-caller isolation check rejected the lookup and a standard
// OpenAI SDK client got a 404 on every chained turn. Live-reproduced before
// this changed.
assert.equal(extractBodyCallerSubKey({ previous_response_id: 'resp_abc123' }), '',
'a per-turn id must not become a per-caller scope');
});
it('a chained caller keeps ONE scope across turns', () => {
// The property the store depends on: whatever scope a caller resolves to on
// turn 1 must still resolve on turn 2, when previous_response_id appears.
const turn1 = extractBodyCallerSubKey({ model: 'm', input: [] });
const turn2 = extractBodyCallerSubKey({ model: 'm', input: [], previous_response_id: 'resp_1' });
assert.equal(turn1, turn2, 'adding previous_response_id must not re-mint the scope');
// And the same holds when the caller does supply a stable signal.
const a = extractBodyCallerSubKey({ user: 'alice' });
const b = extractBodyCallerSubKey({ user: 'alice', previous_response_id: 'resp_9' });
assert.equal(a, b);
});
it('honors safety_identifier and prompt_cache_key as turn-stable scopes', () => {
// OpenAI's successors to `user`. Unlike previous_response_id (changes
// every turn), these are stable by contract — a chained Responses client
// sending prompt_cache_key must keep ONE scope across turns so sticky /
// cascade affinity can accrue.
const a = extractBodyCallerSubKey({ safety_identifier: 'end-user-7' });
assert.ok(a && a.length === 16);
const b = extractBodyCallerSubKey({ prompt_cache_key: 'conv-42' });
assert.ok(b && b.length === 16);
assert.notEqual(a, b);
// Stable across turns even when previous_response_id churns.
const t1 = extractBodyCallerSubKey({ prompt_cache_key: 'conv-42', previous_response_id: 'resp_1' });
const t2 = extractBodyCallerSubKey({ prompt_cache_key: 'conv-42', previous_response_id: 'resp_2' });
assert.equal(t1, t2, 'prompt_cache_key must pin the scope across turns');
// Priority: user > safety_identifier > prompt_cache_key.
assert.equal(
extractBodyCallerSubKey({ user: 'alice', prompt_cache_key: 'conv-42' }),
extractBodyCallerSubKey({ user: 'alice' }),
);
assert.equal(
extractBodyCallerSubKey({ safety_identifier: 'end-user-7', prompt_cache_key: 'conv-42' }),
extractBodyCallerSubKey({ safety_identifier: 'end-user-7' }),
);
// Empty values fall through, never mint a scope (same rule as user:"").
assert.equal(extractBodyCallerSubKey({ prompt_cache_key: ' ' }), '');
assert.equal(extractBodyCallerSubKey({ safety_identifier: '' }), '');
});
it('does NOT inspect metadata.user_id (handled by messages.js parser)', () => {
// metadata.user_id is the Anthropic Claude Code device id field; the
// /v1/messages handler has a specialized parser for its JSON-encoded
// shape and appends `:user:` itself. Two-handler stamping would
// double-prefix the callerKey, so caller-key.js stays out of it.
assert.equal(extractBodyCallerSubKey({ metadata: { user_id: '{"device_id":"abc"}' } }), '');
});
});
describe('extractBodyCallerSubKey empty-value fail-closed (audit)', () => {
// Before the fix, `typeof body.user === 'string'` accepted "" / " " and
// hashed them to the constant sha256("") prefix, minting a shared :user:
// segment across every distinct end user of a common key (cross-tenant
// answer bleed). Empty/whitespace must fall through, never mint a scope.
it('returns empty for user:"" (no constant sha256("") scope)', () => {
assert.equal(extractBodyCallerSubKey({ user: '' }), '');
});
it('returns empty for whitespace-only user', () => {
assert.equal(extractBodyCallerSubKey({ user: ' ' }), '');
assert.equal(extractBodyCallerSubKey({ user: '\t\n ' }), '');
});
it('empty user does NOT collapse to a real user digest', () => {
const empty = extractBodyCallerSubKey({ user: '' });
const real = extractBodyCallerSubKey({ user: 'alice' });
assert.equal(empty, '');
assert.notEqual(empty, real);
});
it('falls through to sibling candidates when user is empty', () => {
// Empty user must not short-circuit; a real conversation id should
// still produce a scope via the candidates path.
const k = extractBodyCallerSubKey({ user: '', conversation: 'conv-xyz' });
assert.ok(k && k.length === 16);
// and it must match the same body without the empty user field
assert.equal(k, extractBodyCallerSubKey({ conversation: 'conv-xyz' }));
});
it('empty sibling fields (conversation / previous_response_id / metadata.*) mint no scope', () => {
assert.equal(extractBodyCallerSubKey({ conversation: '' }), '');
assert.equal(extractBodyCallerSubKey({ conversation: ' ' }), '');
assert.equal(extractBodyCallerSubKey({ previous_response_id: '' }), '');
assert.equal(extractBodyCallerSubKey({ metadata: { conversation_id: '' } }), '');
assert.equal(extractBodyCallerSubKey({ metadata: { session_id: ' ' } }), '');
assert.equal(
extractBodyCallerSubKey({ user: '', conversation: '', previous_response_id: '', metadata: { conversation_id: '', session_id: '' } }),
'',
);
});
});
describe('callerKeyFromRequest empty-user fail-closed (audit)', () => {
it('user:"" does NOT mint :user: — falls back to :client: (IP+UA)', () => {
const k = callerKeyFromRequest(fakeReq(), 'sk-test-key', { user: '' });
assert.doesNotMatch(k, /:user:/);
assert.match(k, /^api:[a-f0-9]+:client:[a-f0-9]{16}$/);
});
it('whitespace user:" " does NOT mint :user:', () => {
const k = callerKeyFromRequest(fakeReq(), 'sk-test-key', { user: ' ' });
assert.doesNotMatch(k, /:user:/);
});
it('empty and whitespace users do NOT collapse into the same callerKey as each other or a real user', () => {
// Distinct physical clients so the :client: fallback keeps them apart;
// if the empty-user bug were present both would carry the same constant
// :user: segment and compare equal regardless of IP/UA.
const kEmpty = callerKeyFromRequest(
fakeReq({ ip: '1.1.1.1', headers: { 'user-agent': 'cli/a' } }), 'shared', { user: '' },
);
const kSpace = callerKeyFromRequest(
fakeReq({ ip: '2.2.2.2', headers: { 'user-agent': 'cli/b' } }), 'shared', { user: ' ' },
);
const kReal = callerKeyFromRequest(
fakeReq({ ip: '3.3.3.3', headers: { 'user-agent': 'cli/c' } }), 'shared', { user: 'alice' },
);
assert.notEqual(kEmpty, kSpace);
assert.notEqual(kEmpty, kReal);
assert.notEqual(kSpace, kReal);
});
it('a real non-empty user still scopes as before', () => {
const k = callerKeyFromRequest(fakeReq(), 'sk-test-key', { user: 'alice' });
assert.match(k, /^api:[a-f0-9]+:user:[a-f0-9]{16}$/);
});
});
describe('callerKeyFromRequest with body', () => {
it('appends :user:<digest> when body has a user signal', () => {
const k = callerKeyFromRequest(fakeReq(), 'sk-test-key', { user: 'alice' });
assert.match(k, /^api:[a-f0-9]+:user:[a-f0-9]{16}$/);
});
it('falls back to :client:<ip+ua-digest> when body has no user signal', () => {
// v2.0.37 (#93 follow-up): bare apiKey + no body user used to drop
// straight to "shared API key, no per-user scope" and disable
// cascade reuse. Now we synthesize a stable per-physical-client
// subkey from IP + UA so single-user self-hosted setups can reuse.
const k = callerKeyFromRequest(fakeReq(), 'sk-test-key', {});
assert.match(k, /^api:[a-f0-9]+:client:[a-f0-9]{16}$/);
});
it('two end-users on same shared API key get different keys', () => {
const ka = callerKeyFromRequest(fakeReq(), 'shared-key', { user: 'alice' });
const kb = callerKeyFromRequest(fakeReq(), 'shared-key', { user: 'bob' });
assert.notEqual(ka, kb);
});
it('two physical clients on same apiKey land on different subkeys via IP+UA', () => {
// The v2.0.37 fallback must not collapse distinct clients into one
// pool — that would re-introduce the cross-user cascade bleed
// v2.0.25 originally guarded against.
const ka = callerKeyFromRequest(
fakeReq({ ip: '1.2.3.4', headers: { 'user-agent': 'claude-cli/1.0' } }),
'shared-key', null,
);
const kb = callerKeyFromRequest(
fakeReq({ ip: '5.6.7.8', headers: { 'user-agent': 'claude-cli/1.0' } }),
'shared-key', null,
);
assert.notEqual(ka, kb);
assert.match(ka, /^api:[a-f0-9]+:client:[a-f0-9]{16}$/);
});
it('same physical client across turns lands on the same subkey (reuse precondition)', () => {
// The whole point of the v2.0.37 fallback: stable identity across
// requests so the cascade pool actually finds the prior entry.
const ka = callerKeyFromRequest(
fakeReq({ ip: '1.2.3.4', headers: { 'user-agent': 'claude-cli/1.0' } }),
'shared-key', null,
);
const kb = callerKeyFromRequest(
fakeReq({ ip: '1.2.3.4', headers: { 'user-agent': 'claude-cli/1.0' } }),
'shared-key', null,
);
assert.equal(ka, kb);
});
it('omits :client: when no IP and no UA are extractable', () => {
// Defensive: if both IP and UA are empty strings the fallback
// produces no useful identity so we fall back to the bare apiKey.
const k = callerKeyFromRequest({ headers: {} }, 'sk-test-key', {});
assert.match(k, /^api:[a-f0-9]+$/);
});
it('falls back to header session id when no API key', () => {
const k = callerKeyFromRequest(fakeReq({ headers: { 'x-session-id': 'sess-xyz' } }));
assert.match(k, /^session:[a-f0-9]+$/);
});
it('falls back to ip+ua when nothing else available', () => {
const k = callerKeyFromRequest(fakeReq({ ip: '1.2.3.4', headers: { 'user-agent': 'Mozilla/X' } }));
assert.match(k, /^client:[a-f0-9]+$/);
});
});
// hasCallerScope() removed 2026-07-11 (audit S1): it was dead + diverged from
// the live gate chat.js:hasPerUserScope. Scope-gate coverage lives with that
// function; these tests exercised the dead twin and were removed with it.