Skip to content

Commit 004f70b

Browse files
committed
test(metadata-protocol): pin the first save of a registry-only flow, which persists its overlay without the secret (#20552)
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc1e281 commit 004f70b

1 file changed

Lines changed: 82 additions & 0 deletions

File tree

‎packages/metadata-protocol/src/protocol.metadata-redaction.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,3 +644,85 @@ describe('#20552 — the protocol serves the projection and executes the stored
644644
expect(allStrings(one)).toContain(FLOW_SECRET);
645645
});
646646
});
647+
648+
// ---------------------------------------------------------------------------
649+
// #20552 — the FIRST save of a CODE-AUTHORED flow (no `sys_metadata` row yet)
650+
// ---------------------------------------------------------------------------
651+
//
652+
// The dominant authoring shape for an inbound flow: the secret is a literal in
653+
// the app's own source, the flow reaches the protocol through the registry, and
654+
// NO overlay row exists until the first Studio save. The read serves the
655+
// registry body projected; the save door must carry the secret forward from
656+
// that same body, or the first overlay row is persisted WITHOUT it — and, since
657+
// an overlay wins every merge, the next boot registers a secretless `api` flow
658+
// the engine refuses, while the save said it succeeded.
659+
660+
/** A stub engine whose REGISTRY holds the code-authored flow, and whose `sys_metadata` holds nothing. */
661+
function makeRegistryOnlyEngine() {
662+
const stub = makeStubEngine();
663+
const registryFlow = storedInboundFlow();
664+
stub.engine.registry.getItem = (type: string, name: string) =>
665+
(type === 'flow' || type === 'flows') && name === 'inbound_hook' ? structuredClone(registryFlow) : undefined;
666+
stub.engine.registry.listItems = (type: string) =>
667+
(type === 'flow' || type === 'flows') ? [structuredClone(registryFlow)] : [];
668+
return stub;
669+
}
670+
671+
const overlayRow = (rows: Map<string, Row>, state: 'active' | 'draft') =>
672+
Array.from(rows.values()).find((r) => r.type === 'flow' && r.name === 'inbound_hook' && r.state === state);
673+
674+
describe('#20552 — first save of a registry-only (code-authored) flow keeps its secret', () => {
675+
beforeEach(() => registerMetadataTypeRedactor('flow', flowStandInRedactor));
676+
677+
it('the served body saved straight back persists the overlay row WITH the stored secret', async () => {
678+
const { engine, rows } = makeRegistryOnlyEngine();
679+
const protocol = new ObjectStackProtocolImplementation(engine);
680+
expect(overlayRow(rows, 'active')).toBeUndefined();
681+
682+
const served: any = (await protocol.getMetaItem({ type: 'flow', name: 'inbound_hook' })).item;
683+
expect(allStrings(served)).not.toContain(FLOW_SECRET);
684+
const { _diagnostics: _d, ...editable } = served;
685+
void _d;
686+
await protocol.saveMetaItem({
687+
type: 'flow',
688+
name: 'inbound_hook',
689+
item: { ...editable, label: 'Edited in the designer', nodes: [editable.nodes[1], editable.nodes[0]] },
690+
});
691+
692+
const row = overlayRow(rows, 'active');
693+
expect(row).toBeDefined();
694+
const persisted = JSON.parse(row!.metadata);
695+
expect(persisted.label).toBe('Edited in the designer');
696+
expect(startNodeOf(persisted).config.secret).toBe(FLOW_SECRET);
697+
});
698+
699+
it('a DRAFT save then a publish carries the secret into the active row', async () => {
700+
const { engine, rows } = makeRegistryOnlyEngine();
701+
const protocol = new ObjectStackProtocolImplementation(engine);
702+
703+
const served: any = (await protocol.getMetaItem({ type: 'flow', name: 'inbound_hook' })).item;
704+
const { _diagnostics: _d, ...editable } = served;
705+
void _d;
706+
await protocol.saveMetaItem({ type: 'flow', name: 'inbound_hook', mode: 'draft', item: { ...editable, label: 'Draft edit' } });
707+
expect(startNodeOf(JSON.parse(overlayRow(rows, 'draft')!.metadata)).config.secret).toBe(FLOW_SECRET);
708+
709+
await protocol.publishMetaItem({ type: 'flow', name: 'inbound_hook' });
710+
const active = overlayRow(rows, 'active');
711+
expect(active).toBeDefined();
712+
const published = JSON.parse(active!.metadata);
713+
expect(published.label).toBe('Draft edit');
714+
expect(startNodeOf(published).config.secret).toBe(FLOW_SECRET);
715+
});
716+
717+
it('an explicit value on that first save is the author`s word — a rotation replaces it', async () => {
718+
const { engine, rows } = makeRegistryOnlyEngine();
719+
const protocol = new ObjectStackProtocolImplementation(engine);
720+
721+
const served: any = (await protocol.getMetaItem({ type: 'flow', name: 'inbound_hook' })).item;
722+
const { _diagnostics: _d, ...rotated } = served;
723+
void _d;
724+
startNodeOf(rotated).config.secret = ROTATED;
725+
await protocol.saveMetaItem({ type: 'flow', name: 'inbound_hook', item: rotated });
726+
expect(startNodeOf(JSON.parse(overlayRow(rows, 'active')!.metadata)).config.secret).toBe(ROTATED);
727+
});
728+
});

0 commit comments

Comments
 (0)