mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
Test gap fills surfacing the load-bearing invariants of Slices 1-3:
Gateway tool loop (test/ai/gateway-tool-loop.test.ts, 7 cases):
- end stop_reason exits cleanly with no tools
- single tool call dispatches + result feeds next turn
- persistence callbacks fire in order: onAssistantTurn → onToolCallStart
→ execute → onToolCallComplete (write-ordering invariant pinned)
- replay short-circuit when prior tool execution is complete
- non-idempotent pending replay throws unrecoverable
- max_turns budget capped
- refusal short-circuits without tool dispatch
Budget meter (test/minions/budget-meter.test.ts, 15 cases):
- clientLockKey FNV-1a determinism + collision-rarity + INT32 fit
- reserve under cap / over cap / two-sequential / pending-pushes-over
- settle marks settled + mirrors to mcp_spend_log
- settle idempotency (second call no-op)
- sweep expired pending rows; leaves fresh ones
- getClientDailyCapCents with set/unset/unknown clients
- integration: settled spend feeds next reserve
Agent audit (test/minions/agent-audit.test.ts, 7 cases):
- ISO-week filename rotation (incl. year-boundary edge)
- JSONL line shape + multi-event appending
- regression guard: NEVER logs prompt content (only byte count)
- readRecentAgentEvents newest-first + empty-dir graceful fallback
Pre-existing test fixes for v0.38 semantics:
- test/scope.test.ts: `agent` scope added (size 5 → 6)
- test/oauth.test.ts: operations registry allows scope='agent' for
submit_agent (mutating, contained by client bindings)
- test/model-config.serial.test.ts: enforceSubagentCapable returns
non-Anthropic tool-supporting models unchanged (with cost warn) and
falls back to TIER_DEFAULTS.subagent only on unknown providers
Schema parity:
- pglite-schema.ts + schema.sql get the v83 (budget_usd_per_day) +
v84 (bound_tools, bound_source_id, bound_brain_id,
bound_slug_prefixes, bound_max_concurrent) columns in CREATE TABLE
so fresh installs land in post-migration shape AND the
schema-bootstrap-coverage CI guard sees full coverage.
Pre-existing hybrid-reranker / cross-modal-hybrid integration test
failures are on master before any of this wave — out of scope.
Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
7.8 KiB
TypeScript
209 lines
7.8 KiB
TypeScript
import { test, expect, describe } from 'bun:test';
|
|
import {
|
|
hasScope,
|
|
isScope,
|
|
ALLOWED_SCOPES,
|
|
ALLOWED_SCOPES_LIST,
|
|
assertAllowedScopes,
|
|
InvalidScopeError,
|
|
parseScopeString,
|
|
type Scope,
|
|
} from '../src/core/scope.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hierarchy table — admin → all, write → read, sibling non-implication
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('hasScope — admin implies all (escape hatch)', () => {
|
|
const all: Scope[] = ['read', 'write', 'admin', 'sources_admin', 'users_admin'];
|
|
for (const required of all) {
|
|
test(`admin → ${required}`, () => {
|
|
expect(hasScope(['admin'], required)).toBe(true);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('hasScope — write implies read but not admin variants', () => {
|
|
test('write → read', () => {
|
|
expect(hasScope(['write'], 'read')).toBe(true);
|
|
});
|
|
test('write → write', () => {
|
|
expect(hasScope(['write'], 'write')).toBe(true);
|
|
});
|
|
test('write does NOT imply admin', () => {
|
|
expect(hasScope(['write'], 'admin')).toBe(false);
|
|
});
|
|
test('write does NOT imply sources_admin', () => {
|
|
expect(hasScope(['write'], 'sources_admin')).toBe(false);
|
|
});
|
|
test('write does NOT imply users_admin', () => {
|
|
expect(hasScope(['write'], 'users_admin')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('hasScope — sibling non-implication for *_admin scopes', () => {
|
|
test('sources_admin → sources_admin only', () => {
|
|
expect(hasScope(['sources_admin'], 'sources_admin')).toBe(true);
|
|
expect(hasScope(['sources_admin'], 'users_admin')).toBe(false);
|
|
expect(hasScope(['sources_admin'], 'write')).toBe(false);
|
|
expect(hasScope(['sources_admin'], 'read')).toBe(false);
|
|
expect(hasScope(['sources_admin'], 'admin')).toBe(false);
|
|
});
|
|
test('users_admin → users_admin only', () => {
|
|
expect(hasScope(['users_admin'], 'users_admin')).toBe(true);
|
|
expect(hasScope(['users_admin'], 'sources_admin')).toBe(false);
|
|
expect(hasScope(['users_admin'], 'write')).toBe(false);
|
|
expect(hasScope(['users_admin'], 'read')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('hasScope — read scope', () => {
|
|
test('read → read', () => {
|
|
expect(hasScope(['read'], 'read')).toBe(true);
|
|
});
|
|
test('read does NOT imply write', () => {
|
|
expect(hasScope(['read'], 'write')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('hasScope — empty + unknown granted', () => {
|
|
test('empty granted set returns false', () => {
|
|
expect(hasScope([], 'read')).toBe(false);
|
|
});
|
|
test('unknown scope strings ignored gracefully (forward-compat)', () => {
|
|
expect(hasScope(['flying-unicorn'], 'read')).toBe(false);
|
|
expect(hasScope(['flying-unicorn', 'admin'], 'read')).toBe(true); // admin still implies
|
|
});
|
|
});
|
|
|
|
describe('hasScope — multi-grant', () => {
|
|
test('read + sources_admin combo', () => {
|
|
expect(hasScope(['read', 'sources_admin'], 'read')).toBe(true);
|
|
expect(hasScope(['read', 'sources_admin'], 'sources_admin')).toBe(true);
|
|
expect(hasScope(['read', 'sources_admin'], 'write')).toBe(false);
|
|
});
|
|
test('write + sources_admin combo (gstack /setup-gbrain Path 4 token)', () => {
|
|
expect(hasScope(['write', 'sources_admin'], 'read')).toBe(true);
|
|
expect(hasScope(['write', 'sources_admin'], 'write')).toBe(true);
|
|
expect(hasScope(['write', 'sources_admin'], 'sources_admin')).toBe(true);
|
|
expect(hasScope(['write', 'sources_admin'], 'users_admin')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// F3 invariant: refresh-token requested-subset enforcement uses hasScope.
|
|
// Prove the v0.26.9-correct semantics (admin grant CAN refresh down to a
|
|
// subset; non-implied scope refresh fails).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('F3 refresh-token subset semantics under hasScope', () => {
|
|
test('admin grant → refresh requesting sources_admin succeeds', () => {
|
|
const granted = ['admin'];
|
|
const requested = ['sources_admin'];
|
|
expect(requested.every(s => hasScope(granted, s))).toBe(true);
|
|
});
|
|
test('admin grant → refresh requesting subset (read+write) succeeds', () => {
|
|
const granted = ['admin'];
|
|
const requested = ['read', 'write'];
|
|
expect(requested.every(s => hasScope(granted, s))).toBe(true);
|
|
});
|
|
test('write grant → refresh requesting admin fails', () => {
|
|
const granted = ['write'];
|
|
const requested = ['admin'];
|
|
expect(requested.every(s => hasScope(granted, s))).toBe(false);
|
|
});
|
|
test('write grant → refresh requesting sources_admin fails', () => {
|
|
const granted = ['write'];
|
|
const requested = ['sources_admin'];
|
|
expect(requested.every(s => hasScope(granted, s))).toBe(false);
|
|
});
|
|
test('sources_admin grant → refresh requesting users_admin fails (sibling axis)', () => {
|
|
const granted = ['sources_admin'];
|
|
const requested = ['users_admin'];
|
|
expect(requested.every(s => hasScope(granted, s))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ALLOWED_SCOPES allowlist (D4) — registration-time gate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ALLOWED_SCOPES — exact list pinned', () => {
|
|
test('contains the 6 canonical scopes (v0.38: agent added)', () => {
|
|
expect(ALLOWED_SCOPES.size).toBe(6);
|
|
expect(ALLOWED_SCOPES.has('read')).toBe(true);
|
|
expect(ALLOWED_SCOPES.has('write')).toBe(true);
|
|
expect(ALLOWED_SCOPES.has('admin')).toBe(true);
|
|
expect(ALLOWED_SCOPES.has('sources_admin')).toBe(true);
|
|
expect(ALLOWED_SCOPES.has('users_admin')).toBe(true);
|
|
expect(ALLOWED_SCOPES.has('agent')).toBe(true);
|
|
});
|
|
test('list is sorted alphabetically (deterministic for wire/drift check)', () => {
|
|
expect([...ALLOWED_SCOPES_LIST]).toEqual([
|
|
'admin',
|
|
'agent',
|
|
'read',
|
|
'sources_admin',
|
|
'users_admin',
|
|
'write',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('isScope', () => {
|
|
test('accepts allowed strings', () => {
|
|
expect(isScope('read')).toBe(true);
|
|
expect(isScope('sources_admin')).toBe(true);
|
|
});
|
|
test('rejects unknown strings', () => {
|
|
expect(isScope('flying-unicorn')).toBe(false);
|
|
expect(isScope('')).toBe(false);
|
|
expect(isScope('READ')).toBe(false); // case-sensitive
|
|
});
|
|
});
|
|
|
|
describe('assertAllowedScopes', () => {
|
|
test('passes for valid set', () => {
|
|
expect(() => assertAllowedScopes(['read', 'sources_admin'])).not.toThrow();
|
|
});
|
|
test('passes for empty', () => {
|
|
expect(() => assertAllowedScopes([])).not.toThrow();
|
|
});
|
|
test('throws InvalidScopeError naming the bad scope', () => {
|
|
try {
|
|
assertAllowedScopes(['read', 'flying-unicorn']);
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(InvalidScopeError);
|
|
expect((e as InvalidScopeError).invalidScope).toBe('flying-unicorn');
|
|
expect((e as InvalidScopeError).message).toMatch(/flying-unicorn/);
|
|
expect((e as InvalidScopeError).message).toMatch(/Allowed:/);
|
|
}
|
|
});
|
|
test('throws on first invalid scope (short-circuits)', () => {
|
|
try {
|
|
assertAllowedScopes(['flying-unicorn', 'also-bad']);
|
|
throw new Error('expected throw');
|
|
} catch (e) {
|
|
expect((e as InvalidScopeError).invalidScope).toBe('flying-unicorn');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('parseScopeString', () => {
|
|
test('splits space-separated', () => {
|
|
expect(parseScopeString('read write admin')).toEqual(['read', 'write', 'admin']);
|
|
});
|
|
test('drops empty fragments', () => {
|
|
expect(parseScopeString('read write')).toEqual(['read', 'write']);
|
|
});
|
|
test('handles undefined/null/empty', () => {
|
|
expect(parseScopeString(undefined)).toEqual([]);
|
|
expect(parseScopeString(null)).toEqual([]);
|
|
expect(parseScopeString('')).toEqual([]);
|
|
});
|
|
test('does NOT validate (separation of concerns)', () => {
|
|
expect(parseScopeString('read flying-unicorn')).toEqual(['read', 'flying-unicorn']);
|
|
});
|
|
});
|