PR #172 fixed the jsonb double-encoding for `tool_allowlist` but the same class of bug, and the same re-derived workaround, remained elsewhere. 1. model_config (agent roles): jsonbObject still used the buggy `::jsonb` bind, so `ai_agent_roles.model_config` round-tripped as a jsonb STRING SCALAR. The read-path `typeof === 'object'` check then failed and the model override was SILENTLY dropped (role fell back to the default model). Fixed to `::text::jsonb` and added `parseModelConfig` + `normalizeRow` so every read self-heals already-corrupted rows (no migration). 2. Centralized the write workaround as `jsonbBind()` in database/utils.ts — one implementation with one explanation of the quirk — replacing the per-repo `jsonbArray` (mcp) and `jsonbObject` (roles). 3. Integration coverage (the fix is a DB round-trip a unit test cannot see; the read-side parser MASKS a write regression): new ai-mcp-server-repo.int-spec asserts `jsonb_typeof(tool_allowlist)='array'` after insert + heals a seeded string-scalar row; ai-agent-roles-repo int-spec gains the same for `model_config` (`'object'` + heal). 4. Updated the stale `ai-mcp-servers.types.ts` comment (the driver returns a JSON string for legacy rows; the repo normalizes every read). 5. Fail-open logging: a corrupt tool_allowlist degrades to "no restriction" (agent gets ALL tools) — normalizeRow now warns (server id only, never contents) so the silent widening leaves a trace. 6. Simplified parseToolAllowlist (normalize the string once, then a single array-of-strings check) — identical behaviour, all 12 cases still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
47 lines
1.7 KiB
TypeScript
47 lines
1.7 KiB
TypeScript
import { parseModelConfig } from './ai-agent-roles.repo';
|
|
|
|
/**
|
|
* Unit tests for parseModelConfig: the read-side normalizer that repairs the
|
|
* jsonb double-encoding regression on `model_config`. Rows written by the old
|
|
* `::jsonb` bind round-trip as a JSON STRING, which the read path's
|
|
* `typeof === 'object'` check rejected — silently dropping the model override.
|
|
* parseModelConfig accepts an already-parsed object, parses a legacy JSON
|
|
* string, and rejects everything that is not an object (null = no override).
|
|
*/
|
|
describe('parseModelConfig', () => {
|
|
it('passes an already-parsed object through', () => {
|
|
expect(parseModelConfig({ driver: 'gemini' })).toEqual({
|
|
driver: 'gemini',
|
|
});
|
|
});
|
|
|
|
it('parses a legacy double-encoded JSON string into an object', () => {
|
|
expect(parseModelConfig('{"driver":"gemini","chatModel":"x"}')).toEqual({
|
|
driver: 'gemini',
|
|
chatModel: 'x',
|
|
});
|
|
});
|
|
|
|
it('returns null for null / undefined', () => {
|
|
expect(parseModelConfig(null)).toBeNull();
|
|
expect(parseModelConfig(undefined)).toBeNull();
|
|
});
|
|
|
|
it('returns null for a non-object JSON value (string/number/array)', () => {
|
|
expect(parseModelConfig('"justastring"')).toBeNull();
|
|
expect(parseModelConfig('42')).toBeNull();
|
|
// An array is an object in JS but not a valid model_config shape.
|
|
expect(parseModelConfig('["a","b"]')).toBeNull();
|
|
expect(parseModelConfig(['a', 'b'])).toBeNull();
|
|
});
|
|
|
|
it('returns null for an unparseable string', () => {
|
|
expect(parseModelConfig('not json at all')).toBeNull();
|
|
});
|
|
|
|
it('returns null for a raw non-object primitive', () => {
|
|
expect(parseModelConfig(42 as unknown)).toBeNull();
|
|
expect(parseModelConfig(true as unknown)).toBeNull();
|
|
});
|
|
});
|