Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import fixture from './fixtures/agentconfig-conformance.json';
import { configKeyOverridable, sourceTrusted } from '../glob';
import { validateCron5 } from '../cron5';
import { fieldAccess, sourceKind } from '../field-access';
import { NAME_RE } from '../validation';

interface Conformance {
trustedSources: {
Expand All @@ -28,6 +29,7 @@ interface Conformance {
}[];
sourceKinds: [string, 'oci' | 'local'][];
schedules: { valid: string[]; invalid: string[] };
pluginNames: { valid: string[]; invalid: string[] };
applySafe: {
cases: {
name: string;
Expand Down Expand Up @@ -116,6 +118,14 @@ describe('pkg/agentconfig conformance', () => {
expect(validateCron5(expr)).not.toBeNull();
});

it.each(cases.pluginNames.valid)('PluginNamePattern accepts %j', (name) => {
expect(NAME_RE.test(name)).toBe(true);
});

it.each(cases.pluginNames.invalid)('PluginNamePattern rejects %j', (name) => {
expect(NAME_RE.test(name)).toBe(false);
});

it.each(cases.applySafe.cases)(
'apply_safe: $name',
({ trusted, file, overlay, path, state }) => {
Expand Down
146 changes: 146 additions & 0 deletions src/utils/agent-config/__tests__/config-diff.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import { changedLeafPaths, diffConfigs } from '../config-diff';

describe('diffConfigs', () => {
it('recurses into objects and compares arrays and strings whole', () => {
const before = {
verbosity: 0,
plugins: { a: { policies: ['x'], schedule: 's', gone: 1 } },
};
const after = {
verbosity: 1,
plugins: { a: { policies: ['x', 'y'], schedule: 's' }, b: {} },
};
expect(diffConfigs(before, after)).toEqual([
{ path: '/plugins/a/gone', kind: 'removed', before: 1 },
{
path: '/plugins/a/policies',
kind: 'changed',
before: ['x'],
after: ['x', 'y'],
},
{ path: '/plugins/b', kind: 'added', after: {} },
{ path: '/verbosity', kind: 'changed', before: 0, after: 1 },
]);
});

it('flags multi-line strings and escapes pointer tokens', () => {
const d = diffConfigs(
{ plugins: { p: { policy_data: { 'd/x': 'line a\n' } } } },
{ plugins: { p: { policy_data: { 'd/x': 'line b\n' } } } },
);
expect(d).toEqual([
{
path: '/plugins/p/policy_data/d~1x',
kind: 'changed',
before: 'line a\n',
after: 'line b\n',
multiline: true,
},
]);
});

it('returns nothing for equal documents', () => {
expect(diffConfigs({ a: { b: [1] } }, { a: { b: [1] } })).toEqual([]);
expect(diffConfigs(null, {})).toEqual([]);
});
});

describe('changedLeafPaths', () => {
it('lists differing leaves (arrays whole)', () => {
expect(
changedLeafPaths(
{ verbosity: 1, plugins: { a: { policies: ['x'] } } },
{ plugins: { a: { policies: ['x', 'y'] }, b: { source: 's' } } },
),
).toEqual(['/plugins/a/policies', '/plugins/b/source', '/verbosity']);
});

it('lists an empty object that was added or removed', () => {
const o = { plugins: { p: { policy_data: { n: {} } } } };
expect(changedLeafPaths({}, o)).toEqual(['/plugins/p/policy_data/n']);
expect(changedLeafPaths(o, {})).toEqual(['/plugins/p/policy_data/n']);
expect(changedLeafPaths(o, o)).toEqual([]);
});
});

describe('element-level array changes', () => {
it('pairs a replaced item, and keeps insertions / removals from shifting the rest', async () => {
const { arrayElementChanges } = await import('../config-diff');
expect(arrayElementChanges(['a', 'b', 'c'], ['a', 'B', 'c'])).toEqual([
{
kind: 'changed',
beforeIndex: 1,
afterIndex: 1,
before: 'b',
after: 'B',
},
]);
expect(arrayElementChanges(['a', 'b', 'c'], ['x', 'a', 'b', 'c'])).toEqual([
{ kind: 'added', afterIndex: 0, after: 'x' },
]);
expect(arrayElementChanges(['a', 'b', 'c'], ['a', 'c'])).toEqual([
{ kind: 'removed', beforeIndex: 1, afterIndex: 1, before: 'b' },
]);
expect(arrayElementChanges([{ k: 1 }], [{ k: 1 }])).toEqual([]);
expect(arrayElementChanges([], [1, 2])).toHaveLength(2);
});

it('keeps insertion alignment for arrays past the LCS limit', async () => {
const { arrayElementChanges } = await import('../config-diff');
const before = Array.from({ length: 600 }, (_, i) => `item-${i}`);
// 600 × 601 cells is past the limit: one insertion is still one addition.
expect(arrayElementChanges(before, ['new', ...before])).toEqual([
{ kind: 'added', afterIndex: 0, after: 'new' },
]);
const inserted = [...before.slice(0, 3), 'new', ...before.slice(3)];
expect(arrayElementChanges(before, inserted)).toEqual([
{ kind: 'added', afterIndex: 3, after: 'new' },
]);
const removed = before.filter((_, i) => i !== 10);
expect(arrayElementChanges(before, removed)).toEqual([
{ kind: 'removed', beforeIndex: 10, afterIndex: 10, before: 'item-10' },
]);
const edited = before.map((v, i) => (i === 300 ? 'changed' : v));
expect(arrayElementChanges(before, edited)).toEqual([
{
kind: 'changed',
beforeIndex: 300,
afterIndex: 300,
before: 'item-300',
after: 'changed',
},
]);
});

it('diffConfigs expands policy_data arrays only', () => {
const before = {
plugins: {
p: { policies: ['x'], policy_data: { users: ['a', 'b', 'c'] } },
},
};
const after = {
plugins: {
p: { policies: ['x', 'y'], policy_data: { users: ['a', 'B', 'c'] } },
},
};
expect(diffConfigs(before, after)).toEqual([
{
path: '/plugins/p/policies',
kind: 'changed',
before: ['x'],
after: ['x', 'y'],
},
{
path: '/plugins/p/policy_data/users/1',
kind: 'changed',
before: 'b',
after: 'B',
},
]);
// The underlying documents are untouched; a caller can opt out.
expect(diffConfigs(before, after, () => false)[1].path).toBe(
'/plugins/p/policy_data/users',
);
});
});
43 changes: 43 additions & 0 deletions src/utils/agent-config/__tests__/display.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { formatRelative, sanitizeForDisplay } from '../display';

describe('sanitizeForDisplay', () => {
it('drops api.auth.client_secret from a copy and leaves the input alone', () => {
const doc = {
api: {
url: 'https://api.example.com',
auth: { client_id: 'id', client_secret: 's3cret' },
},
plugins: { ssh: { config: { client_secret: 'kept: not the API key' } } },
};
const input = structuredClone(doc);
const out = sanitizeForDisplay(doc);
expect(out).toEqual({
api: { url: 'https://api.example.com', auth: { client_id: 'id' } },
plugins: { ssh: { config: { client_secret: 'kept: not the API key' } } },
});
expect(doc).toEqual(input);
expect(out).not.toBe(doc);
});

it('passes through documents without api.auth and non-objects', () => {
expect(sanitizeForDisplay({ verbosity: 1 })).toEqual({ verbosity: 1 });
expect(sanitizeForDisplay(null)).toBeNull();
expect(sanitizeForDisplay('x')).toBe('x');
});
});

describe('formatRelative', () => {
it('formats past and future times', () => {
const now = Date.parse('2026-09-30T12:00:00Z');
expect(formatRelative('2026-09-30T10:00:00Z', now, 'en')).toBe(
'2 hours ago',
);
expect(formatRelative('2026-10-03T12:00:00Z', now, 'en')).toBe('in 3 days');
expect(formatRelative('2026-09-30T12:00:20Z', now, 'en')).toBe(
'this minute',
);
expect(formatRelative(null)).toBe('');
expect(formatRelative('garbage')).toBe('');
});
});
148 changes: 148 additions & 0 deletions src/utils/agent-config/__tests__/validation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Client-only checks (R89); every other rule comes from the API preview.
import { describe, expect, it } from 'vitest';
import type { OverlayDoc } from '@/types/agent-config';
import {
byteSize,
coerceStringMaps,
validateOverlayClientSide,
} from '../validation';
import { parseYaml } from '../yaml';

function find(overlay: OverlayDoc, ptr: string) {
return validateOverlayClientSide(overlay).filter((i) => i.ptr === ptr);
}

describe('validateOverlayClientSide (R89: client-only checks)', () => {
it('leaves the API rules to the preview', () => {
expect(
validateOverlayClientSide({
api: null,
verbosity: 9,
plugins: { Bad: { schedule: 'nope', config: { port: '22' } } },
} as OverlayDoc),
).toEqual([]);
});

it('blocks a non-mapping overlay', () => {
expect(
validateOverlayClientSide([] as unknown as OverlayDoc)[0],
).toMatchObject({ ptr: '', blocking: true });
});

it('blocks a masked value copied from a report', () => {
const i = find(
{ plugins: { ssh: { config: { password: '••••' } } } },
'/plugins/ssh/config/password',
);
expect(i.some((x) => x.blocking && /masked/.test(x.message))).toBe(true);
});

it('blocks object/array config values', () => {
const o = {
plugins: { ssh: { config: { k: { a: 1 } } } },
} as unknown as OverlayDoc;
expect(find(o, '/plugins/ssh/config/k')[0].blocking).toBe(true);
});

it('only coerces booleans; every number must be quoted', () => {
const o = {
plugins: { ssh: { config: { v: 1.1, big: 2 ** 60, n: 22, on: true } } },
} as unknown as OverlayDoc;
const c = coerceStringMaps(o);
expect(c.coerced).toEqual(['/plugins/ssh/config/on']);
const issues = validateOverlayClientSide(c.overlay);
expect(
issues
.filter((i) => i.blocking && /Quote this value/.test(i.message))
.map((i) => i.ptr),
).toEqual([
'/plugins/ssh/config/v',
'/plugins/ssh/config/big',
'/plugins/ssh/config/n',
]);
});

it.each(['1.0', '1e3'])(
'blocks the YAML number %s instead of sending a different string',
(text) => {
const r = parseYaml(`plugins:\n ssh:\n config:\n v: ${text}\n`);
if (!r.ok) throw new Error(r.error.message);
const c = coerceStringMaps(r.value);
expect(c.coerced).toEqual([]);
expect(find(c.overlay, '/plugins/ssh/config/v')).toEqual([
{
ptr: '/plugins/ssh/config/v',
message: expect.stringMatching(/Quote this value/),
blocking: true,
},
]);
},
);

it.each(['0644', '0x1F', '01234'])(
'the YAML parser already rejects the ambiguous number %s',
(text) => {
expect(
parseYaml(`plugins:\n ssh:\n config:\n v: ${text}\n`).ok,
).toBe(false);
},
);

it('blocks numbers JSON cannot carry as typed, in every field', () => {
// Raw JSON views: JSON.parse gives Infinity for 1e999 and rounds 20-digit integers.
const o = JSON.parse(
'{"verbosity": 1e999, "plugins": {"p": {"policy_data": {"limit": 1e999, "id": 12345678901234567890, "ok": [1, 2.5, -0, 9007199254740991]}}}}',
) as OverlayDoc;
(o.plugins!.p!.policy_data as Record<string, unknown>).nan = NaN;
const ptrs = validateOverlayClientSide(o)
.filter((i) => i.blocking && /cannot be saved as typed/.test(i.message))
.map((i) => i.ptr);
expect(ptrs).toEqual([
'/verbosity',
'/plugins/p/policy_data/limit',
'/plugins/p/policy_data/id',
'/plugins/p/policy_data/nan',
]);
});
});

describe('coerceStringMaps (R27)', () => {
it('converts booleans (not numbers) in config and labels and reports pointers', () => {
const o = {
plugins: {
ssh: {
config: { port: 2222, on: true, s: 'x' },
labels: { n: 1, off: false },
},
},
} as unknown as OverlayDoc;
const r = coerceStringMaps(o);
expect(r.overlay).toEqual({
plugins: {
ssh: {
config: { port: 2222, on: 'true', s: 'x' },
labels: { n: 1, off: 'false' },
},
},
});
expect(r.coerced).toEqual([
'/plugins/ssh/config/on',
'/plugins/ssh/labels/off',
]);
expect(
(o.plugins as Record<string, { config: Record<string, unknown> }>).ssh
.config.on,
).toBe(true);
});

it('returns the same object when nothing changes', () => {
const o = { plugins: { ssh: { config: { port: '22' } } } };
expect(coerceStringMaps(o).overlay).toBe(o);
});
});

describe('byteSize', () => {
it('counts UTF-8 bytes', () => {
expect(byteSize('••••')).toBe(12);
});
});
Loading
Loading