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
16 changes: 16 additions & 0 deletions backend/__tests__/service/agentsRuntime.cardKindBoundary.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const User = require('../../models/User');
const Pod = require('../../models/Pod');
const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry');
const catalogueRevision = require('../../config/uiCardCatalogueRevision.json');
const catalogueRevision2 = require('../../config/uiCardCatalogueRevision.rev2.json');

jest.mock('../../services/agentEventService', () => ({
enqueue: jest.fn(async () => ({ _id: 'stub-event' })),
Expand Down Expand Up @@ -170,4 +171,19 @@ describe('agent write path accepts only payload kind "ui" (TASK-657)', () => {
expect(res.body.refused).toBeUndefined();
expect(await storedMessageCount()).toBe(before + 1);
});

// TASK-607: Source names a vendor origin, so only the server may write it.
it.each([['revision 1', catalogueRevision], ['revision 2', catalogueRevision2]])(
'refuses a hand-written Source under %s with invalid_ui_card and stores nothing',
async (_label, pin) => {
const before = await storedMessageCount();
const res = await postCard(uiPayload({
...pin, source: 'root = Card("Issues", [Text("x"), Source("linear", "2026-11-01T09:30:00.000Z")])',
}));

expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toContain('invalid_ui_card');
expect(await storedMessageCount()).toBe(before);
},
);
});
24 changes: 24 additions & 0 deletions backend/__tests__/unit/services/uiCardCatalogueRevision.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,27 @@ describe('Commonly UI catalogue revision 1', () => {
expect(hash).toBe(revision.catalogHash);
});
});

// Revision 2 (TASK-607) is revision 1 plus the server-stamped Source. Same rule:
// never edit these expectations; a change to the grammar is revision 3.
describe('Commonly UI catalogue revision 2', () => {
const catalogue2 = require('../../../config/uiCardCatalogue.rev2.json');
const revision2 = require('../../../config/uiCardCatalogueRevision.rev2.json');
it('pins all positional property orders', () => {
expect(Object.fromEntries(Object.entries(catalogue2.$defs)
.map(([name, def]) => [name, Object.keys(def.properties)]))).toEqual({
Card: ['title', 'children', 'meta', 'summary'], Text: ['body'],
List: ['items', 'ordered'], Steps: ['steps'], Step: ['label', 'state', 'detail'],
DiffSummary: ['files'], File: ['path', 'added', 'removed'],
TaskStatus: ['taskId', 'note'], KeyValue: ['pairs'], Pair: ['key', 'value'],
Table: ['columns', 'rows'], Col: ['label', 'type'], Badge: ['label', 'tone'],
Link: ['label', 'url', 'as'], RunTrace: ['limit'], Source: ['connector', 'fetchedAt'],
});
});
it('pins the complete schema, identity and revision hash', () => {
const hash = `sha256:${createHash('sha256').update(JSON.stringify(catalogue2)).digest('hex')}`;
expect(revision2).toEqual({ catalogId: 'commonly.ui', catalogRev: 2,
catalogHash: 'sha256:1bbf106e81f08dde540996d53b32e8ac12f661ecdc1409e7ceb2c8a26655b7a1' });
expect(hash).toBe(revision2.catalogHash);
});
});
110 changes: 110 additions & 0 deletions backend/__tests__/unit/services/uiCardSource.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* TASK-607: catalogue revision 2 adds Source(connector, fetchedAt). It names a
* vendor origin, so only the server writes it; an agent can never claim one.
*/
const revision1 = require('../../../config/uiCardCatalogueRevision.json');
const revision2 = require('../../../config/uiCardCatalogueRevision.rev2.json');
const catalogue1 = require('../../../config/uiCardCatalogue.json');
const catalogue2 = require('../../../config/uiCardCatalogue.rev2.json');
const {
validateAgentCardPayload, stampCardSource, UI_CARD_SOURCE_CATALOGUE, UI_CARD_SOURCE_CONNECTORS,
} = require('../../../services/uiCardValidation');

const envelope = (source, pin = revision1) => ({
kind: 'ui', v: 1, lang: 'openui@1', source, ...pin,
});
const agent = (payload) => validateAgentCardPayload('card', payload, 'Text fallback');
const issues = 'root = Card("Open issues", [Text("Two need a reply")], "linear")';
const fetchedAt = '2026-11-01T09:30:00.000Z';
const refused = (message) => expect.objectContaining({ statusCode: 400, code: 'invalid_ui_card', message: expect.stringMatching(message) });

describe('catalogue revision 2', () => {
it('adds only Source to revision 1, leaving every revision 1 component unchanged', () => {
const added = Object.keys(catalogue2.$defs).filter((name) => !(name in catalogue1.$defs));
expect(added).toEqual(['Source']);
expect(Object.keys(catalogue2.properties)).toEqual([...Object.keys(catalogue1.properties), 'Source']);
Object.keys(catalogue1.$defs).filter((n) => n !== 'Card').forEach((name) => {
expect(catalogue2.$defs[name]).toEqual(catalogue1.$defs[name]);
});
expect(catalogue2.$defs.Card.properties.children.items.anyOf)
.toEqual([...catalogue1.$defs.Card.properties.children.items.anyOf, { $ref: '#/$defs/Source' }]);
// Positional ABI: connector first, then fetchedAt.
expect(Object.keys(catalogue2.$defs.Source.properties)).toEqual(['connector', 'fetchedAt']);
expect(revision2).toEqual(expect.objectContaining({ catalogId: revision1.catalogId, catalogRev: 2 }));
expect(revision2.catalogHash).not.toBe(revision1.catalogHash);
expect(UI_CARD_SOURCE_CATALOGUE).toEqual(revision2);
expect(UI_CARD_SOURCE_CONNECTORS).toEqual(['linear', 'sentry', 'atlassian']);
});
it('accepts an agent card under either revision when it has no Source', () => {
expect(() => agent(envelope(issues))).not.toThrow();
expect(() => agent(envelope(issues, revision2))).not.toThrow();
});
it.each([{ catalogRev: 3 }, { catalogRev: 2, catalogHash: revision1.catalogHash }, { catalogRev: 1, catalogHash: revision2.catalogHash }])(
'refuses an unknown or mismatched revision tuple %#',
(fields) => expect(() => agent({ ...envelope(issues), ...fields })).toThrow(refused(/catalog/)),
);
});

describe('agent-written Source', () => {
it.each([
['revision 2, in a Card', 'root = Card("Issues", [Text("x"), Source("linear", "2026-11-01T09:30:00.000Z")])', revision2],
['revision 2, as a dormant statement', 'root = Text("x")\nlater = Source("sentry", "2026-11-01T09:30:00.000Z")', revision2],
['revision 2, by reference', 's = Source("linear", "2026-11-01T09:30:00.000Z")\nroot = Card("Issues", [Text("x"), s])', revision2],
['revision 1', 'root = Card("Issues", [Text("x"), Source("linear", "2026-11-01T09:30:00.000Z")])', revision1],
])('is refused: %s', (_label, source, pin) => {
expect(() => agent(envelope(source, pin))).toThrow(refused(/Source is stamped by the server/));
});
it('is refused on a stamped envelope resubmitted through the agent path', () => {
const stamped = stampCardSource(issues, 'Two open issues', { connector: 'linear', fetchedAt });
expect(() => agent(stamped)).toThrow(refused(/Source is stamped by the server/));
});
});

describe('stampCardSource', () => {
it('appends Source to the top-level Card and returns a revision 2 envelope', () => {
const stamped = stampCardSource(issues, 'Two open issues', { connector: 'linear', fetchedAt: new Date(fetchedAt) });
expect(stamped).toEqual({
kind: 'ui',
v: 1,
lang: 'openui@1',
...revision2,
source: 'root = Card("Open issues", [Text("Two need a reply"), Source("linear", "2026-11-01T09:30:00.000Z")], "linear")',
});
});
it('keeps quoted prose, references, links and helpers intact through the reprint', () => {
const source = [
'pair = Pair("Owner", "Sam \\"S\\" X")',
'root = Card("Sentry", [Text(["Line one\\nline two é ", Link("open", "https://sentry.io/x", "text")]), KeyValue([pair, pair]), Table([Col("n", "number")], [[1], [2]])], "2 issues")',
].join('\n');
const { source: out } = stampCardSource(source, 'fallback', { connector: 'sentry', fetchedAt });
expect(out).toBe('root = Card("Sentry", [Text(["Line one\\nline two é ", Link("open", "https://sentry.io/x", "text")]), '
+ 'KeyValue([Pair("Owner", "Sam \\"S\\" X"), Pair("Owner", "Sam \\"S\\" X")]), Table([Col("n", "number")], [[1], [2]]), '
+ 'Source("sentry", "2026-11-01T09:30:00.000Z")], "2 issues")');
});
it('reprints numbers, null table cells and defaulted props exactly', () => {
const source = 'root = Card("Counts", [Table([Col("n", "number"), Col("s")], [[-5, null], [1.5, "x"]]), RunTrace(), '
+ 'DiffSummary([File("a.ts", 0, 12)]), List(["one"], true)])';
const { source: out } = stampCardSource(source, 'fallback', { connector: 'atlassian', fetchedAt });
expect(out).toBe('root = Card("Counts", [Table([Col("n", "number"), Col("s")], [[-5, null], [1.5, "x"]]), RunTrace(3), '
+ 'DiffSummary([File("a.ts", 0, 12)]), List(["one"], true), Source("atlassian", "2026-11-01T09:30:00.000Z")])');
});
it.each(['github', 'Linear', '', undefined])('refuses an unknown connector %p', (connector) => {
expect(() => stampCardSource(issues, 'fallback', { connector, fetchedAt })).toThrow(refused(/connector/));
});
it.each(['yesterday', '', '1', '2026-11-01', '2026-11-01T09:30:00Z', '2026-02-30T09:30:00.000Z', '2026-11-01T24:00:00.000Z', 1761989400000, new Date(Number.NaN), '+275760-09-13T00:00:00.000Z'])('refuses an invalid fetchedAt %p', (value) => {
expect(() => stampCardSource(issues, 'fallback', { connector: 'linear', fetchedAt: value })).toThrow(refused(/fetchedAt|catalogue/));
});
it('holds the input to the agent rules, so vendor data cannot carry its own Source', () => {
const smuggled = 'root = Card("x", [Text("y"), Source("linear", "2026-11-01T09:30:00.000Z")])';
expect(() => stampCardSource(smuggled, 'fallback', { connector: 'sentry', fetchedAt })).toThrow(refused(/Source is stamped/));
expect(() => stampCardSource('root = Query("secret")', 'fallback', { connector: 'linear', fetchedAt })).toThrow(refused(/not allowed/));
});
it('stamps only a Card root, with room for one more child', () => {
expect(() => stampCardSource('root = Text("bare")', 'fallback', { connector: 'linear', fetchedAt })).toThrow(refused(/Card root/));
const full = `root = Card("full", [${Array.from({ length: 6 }, (_, i) => `Text("${i}")`).join(', ')}])`;
expect(() => stampCardSource(full, 'fallback', { connector: 'linear', fetchedAt })).toThrow(refused(/catalogue/));
});
it('requires a text fallback, as every card does', () => {
expect(() => stampCardSource(issues, '', { connector: 'linear', fetchedAt })).toThrow(refused(/fallback/));
});
});
2 changes: 1 addition & 1 deletion backend/__tests__/unit/services/uiCardValidation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const validate = (payload = card(), content = 'Text fallback', type = 'card') =>

describe('untrusted UI card write validation', () => {
it.each([
{ catalogId: 'other.ui' }, { catalogRev: 2 }, { catalogRev: '1' },
{ catalogId: 'other.ui' }, { catalogRev: 2 }, { catalogRev: 3 }, { catalogRev: '1' },
{ catalogHash: 'sha256:forged' }, { catalogRev: undefined },
{ catalogId: undefined }, { catalogHash: undefined },
])('visibly refuses an unsupported catalogue tuple %#', (fields) => {
Expand Down
Loading
Loading