Skip to content

Commit b9a7d91

Browse files
committed
test(service-automation): pin http node signing on every arm (RED on the unfixed executor)
Measuring pins, committed before the fix. A real local receiver checks what arrived: with signingSecret set, the inline arm and both durable no-outbox fallbacks deliver no X-Objectstack-Signature (19 red), a secret whose template resolves to nothing is sent unsigned instead of refused, and the guard inventory row routes to the fault handler. The no-key, empty-string and outbox-wired controls are green. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 542670d commit b9a7d91

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
4+
import { createServer, type Server } from 'node:http';
5+
import type { AddressInfo } from 'node:net';
6+
import {
7+
MessagingService,
8+
MemoryHttpOutbox,
9+
HttpDispatcher,
10+
signHttpBody,
11+
} from '@objectstack/service-messaging';
12+
import { AutomationEngine } from '../engine.js';
13+
import { registerHttpNodes } from './http-nodes.js';
14+
15+
/**
16+
* `signingSecret` on a flow `http` node means `X-Objectstack-Signature` on the
17+
* wire, on EVERY arm the node can take.
18+
*
19+
* The key is declared without qualification — `HttpConfigSchema.signingSecret`
20+
* and the descriptor's `configSchema` both read "HMAC-SHA256 secret →
21+
* X-Objectstack-Signature" — yet only the durable arm used to hand it to
22+
* anything that signs (the messaging outbox). The inline arm, and the durable
23+
* arm's no-outbox fallback that degrades to it, built their `fetch` from
24+
* `headers` alone: the request went out unsigned and the run said
25+
* `success: true`.
26+
*
27+
* Every assertion here is made by a REAL local receiver, on the bytes and
28+
* headers it actually received, and a signature counts only if it verifies with
29+
* the published scheme (`signHttpBody` over the received body) — the check a
30+
* real receiving endpoint makes. A mocked `fetch` would pin what the node
31+
* INTENDED to send; the receiver pins what arrived.
32+
*
33+
* The refusal cases pin the other half: a secret the author set that did not
34+
* resolve at run time must stop the call, never let it leave unsigned. An
35+
* explicitly empty `signingSecret: ''` is NOT that case — it is the documented
36+
* way to send unsigned on purpose, and it behaves the same on every arm.
37+
*/
38+
39+
const SECRET = 'flow-hook-secret';
40+
41+
/**
42+
* `sha256=` + hex HMAC-SHA256 of the EMPTY body under {@link SECRET}. Pinned as
43+
* a literal so no arm can drift to signing something other than the empty body
44+
* a bodyless request actually carries (`'{}'`, `'null'`, …) while still
45+
* agreeing with a helper that drifted alongside it.
46+
*/
47+
const EMPTY_BODY_SIGNATURE = 'sha256=28c9179fd9763c0e7d41dc5241d9d77607270f8912e3b7692426f677bd5187f7';
48+
49+
const SIGNATURE_HEADER_LC = 'x-objectstack-signature';
50+
51+
interface Received {
52+
method: string;
53+
headers: Record<string, string | string[] | undefined>;
54+
body: string;
55+
}
56+
57+
let server: Server;
58+
let baseUrl: string;
59+
const received: Received[] = [];
60+
61+
beforeAll(async () => {
62+
server = createServer((req, res) => {
63+
const chunks: Buffer[] = [];
64+
req.on('data', (c: Buffer) => chunks.push(c));
65+
req.on('end', () => {
66+
received.push({
67+
method: req.method ?? '',
68+
headers: req.headers,
69+
body: Buffer.concat(chunks).toString('utf8'),
70+
});
71+
res.writeHead(200, { 'Content-Type': 'application/json' });
72+
res.end('{"ok":true}');
73+
});
74+
});
75+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
76+
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
77+
});
78+
79+
afterAll(async () => {
80+
await new Promise<void>((resolve) => server.close(() => resolve()));
81+
});
82+
83+
beforeEach(() => {
84+
received.length = 0;
85+
});
86+
87+
function silentLogger(): any {
88+
const l: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
89+
l.child = () => l;
90+
return l;
91+
}
92+
93+
/** An engine with the `http` node wired against the given `messaging` service (or none). */
94+
function engineWith(messaging?: unknown): AutomationEngine {
95+
const engine = new AutomationEngine(silentLogger());
96+
registerHttpNodes(engine, {
97+
logger: silentLogger(),
98+
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
99+
} as any);
100+
return engine;
101+
}
102+
103+
function flow(config: Record<string, unknown>) {
104+
return {
105+
name: 'signed_callout',
106+
label: 'Signed callout',
107+
type: 'autolaunched' as const,
108+
variables: [{ name: 'signing_key', type: 'text' as const, isInput: true }],
109+
nodes: [
110+
{ id: 'start', type: 'start' as const, label: 'Start' },
111+
{ id: 'call', type: 'http' as const, label: 'Call', config },
112+
{ id: 'end', type: 'end' as const, label: 'End' },
113+
],
114+
edges: [
115+
{ id: 'e1', source: 'start', target: 'call' },
116+
{ id: 'e2', source: 'call', target: 'end' },
117+
],
118+
};
119+
}
120+
121+
async function run(engine: AutomationEngine, config: Record<string, unknown>, params?: Record<string, unknown>) {
122+
engine.registerFlow('signed_callout', flow(config));
123+
return engine.execute('signed_callout', params ? ({ params } as any) : undefined);
124+
}
125+
126+
/** The one received request, asserting there was exactly one. */
127+
function only(): Received {
128+
expect(received).toHaveLength(1);
129+
return received[0];
130+
}
131+
132+
/** The signature a receiver would compute over what it received, with the shared secret. */
133+
function expectVerifies(req: Received, secret: string): void {
134+
const sent = req.headers[SIGNATURE_HEADER_LC];
135+
expect(sent, 'no X-Objectstack-Signature arrived').toBeTypeOf('string');
136+
expect(sent).toBe(signHttpBody(req.body, secret));
137+
}
138+
139+
/**
140+
* The two arms that call `fetch` in-process: the default request/response arm,
141+
* and `durable: true` on a host with no messaging HTTP outbox, which degrades
142+
* to that same inline call. The fallback is driven twice — no `messaging`
143+
* service at all, and a real `MessagingService` with no outbox wired — because
144+
* those are the two compositions that take it.
145+
*/
146+
const INLINE_ARMS: Array<{ arm: string; durable: boolean; messaging: () => unknown }> = [
147+
{ arm: 'inline (default)', durable: false, messaging: () => undefined },
148+
{ arm: 'durable, no messaging service', durable: true, messaging: () => undefined },
149+
{
150+
arm: 'durable, messaging with no HTTP outbox',
151+
durable: true,
152+
messaging: () => new MessagingService({ logger: silentLogger() }),
153+
},
154+
];
155+
156+
describe.each(INLINE_ARMS)('http node signing — $arm arm', ({ durable, messaging }) => {
157+
const base = () => ({ url: `${baseUrl}/hook`, ...(durable ? { durable: true } : {}) });
158+
159+
it('signs a JSON body: the receiver verifies the signature over the bytes it received', async () => {
160+
const result = await run(engineWith(messaging()), {
161+
...base(),
162+
method: 'POST',
163+
headers: { 'X-Control': 'kept' },
164+
signingSecret: SECRET,
165+
body: { order: 42, lines: ['a', 'b'] },
166+
});
167+
168+
expect(result.success).toBe(true);
169+
const req = only();
170+
expect(req.headers['x-control']).toBe('kept');
171+
expect(JSON.parse(req.body)).toEqual({ order: 42, lines: ['a', 'b'] });
172+
expectVerifies(req, SECRET);
173+
});
174+
175+
it('signs the exact serialization it sends — a string body goes out JSON-encoded, and that is what is signed', async () => {
176+
const result = await run(engineWith(messaging()), {
177+
...base(),
178+
method: 'POST',
179+
signingSecret: SECRET,
180+
body: 'hello',
181+
});
182+
183+
expect(result.success).toBe(true);
184+
const req = only();
185+
// The inline arm's own serialization (`JSON.stringify`), which is NOT
186+
// the outbox's `deliveryBody` (that one sends a string verbatim). Each
187+
// arm signs what it sends; the receiver only ever sees the bytes.
188+
expect(req.body).toBe('"hello"');
189+
expectVerifies(req, SECRET);
190+
});
191+
192+
it('signs a GET with no body over the empty body the receiver sees', async () => {
193+
const result = await run(engineWith(messaging()), { ...base(), method: 'GET', signingSecret: SECRET });
194+
195+
expect(result.success).toBe(true);
196+
const req = only();
197+
expect(req.method).toBe('GET');
198+
expect(req.body).toBe('');
199+
expect(req.headers[SIGNATURE_HEADER_LC]).toBe(EMPTY_BODY_SIGNATURE);
200+
expectVerifies(req, SECRET);
201+
});
202+
203+
it('signs with a secret that arrives through a template', async () => {
204+
const result = await run(
205+
engineWith(messaging()),
206+
{ ...base(), method: 'POST', signingSecret: '{signing_key}', body: { a: 1 } },
207+
{ signing_key: SECRET },
208+
);
209+
210+
expect(result.success).toBe(true);
211+
expectVerifies(only(), SECRET);
212+
});
213+
214+
it('sends NO signature header when the node sets no signingSecret', async () => {
215+
const result = await run(engineWith(messaging()), { ...base(), method: 'POST', body: { a: 1 } });
216+
217+
expect(result.success).toBe(true);
218+
expect(only().headers[SIGNATURE_HEADER_LC]).toBeUndefined();
219+
});
220+
221+
it("sends NO signature header for an explicitly empty signingSecret: '' — the unsigned-on-purpose spelling", async () => {
222+
const result = await run(engineWith(messaging()), {
223+
...base(),
224+
method: 'POST',
225+
signingSecret: '',
226+
body: { a: 1 },
227+
});
228+
229+
expect(result.success).toBe(true);
230+
expect(only().headers[SIGNATURE_HEADER_LC]).toBeUndefined();
231+
});
232+
233+
it('REFUSES, and sends nothing, when a templated secret resolves to nothing', async () => {
234+
// `signing_key` is declared but not supplied: the whole-token template
235+
// resolves to no value at all.
236+
const result = await run(engineWith(messaging()), {
237+
...base(),
238+
method: 'POST',
239+
signingSecret: '{signing_key}',
240+
body: { a: 1 },
241+
});
242+
243+
expect(result.success).toBe(false);
244+
expect(result.error).toContain('signingSecret');
245+
expect(received).toHaveLength(0);
246+
});
247+
248+
it('REFUSES, and sends nothing, when a templated secret resolves to the empty string', async () => {
249+
// Only an AUTHORED '' means "unsigned on purpose". A template that
250+
// happens to render '' is a secret that did not resolve.
251+
const result = await run(
252+
engineWith(messaging()),
253+
{ ...base(), method: 'POST', signingSecret: '{signing_key}', body: { a: 1 } },
254+
{ signing_key: '' },
255+
);
256+
257+
expect(result.success).toBe(false);
258+
expect(result.error).toContain('signingSecret');
259+
expect(received).toHaveLength(0);
260+
});
261+
});
262+
263+
/**
264+
* The durable arm with the outbox wired — the arm that already signed. It is
265+
* driven end to end (real `MessagingService` + `MemoryHttpOutbox` +
266+
* `HttpDispatcher` on the global `fetch`) so the same receiver check covers
267+
* both arms: one secret, one header, one verification, whichever arm ran.
268+
*/
269+
describe('http node signing — durable arm with the outbox wired', () => {
270+
function outboxStack() {
271+
const outbox = new MemoryHttpOutbox();
272+
const messaging = new MessagingService({ logger: silentLogger() });
273+
messaging.setHttpOutbox(outbox);
274+
const dispatcher = new HttpDispatcher({
275+
nodeId: 'node-signing-test',
276+
outbox,
277+
partitionCount: 1,
278+
intervalMs: 10_000, // ticked by hand
279+
logger: silentLogger(),
280+
});
281+
return { outbox, dispatcher, engine: engineWith(messaging) };
282+
}
283+
284+
it('delivers a signature the receiver verifies over the bytes it received', async () => {
285+
const { dispatcher, engine } = outboxStack();
286+
const result = await run(engine, { url: `${baseUrl}/hook`, durable: true, signingSecret: SECRET, body: { a: 1 } });
287+
expect(result.success).toBe(true);
288+
289+
await dispatcher.tick();
290+
expectVerifies(only(), SECRET);
291+
});
292+
293+
it("delivers NO signature for signingSecret: ''", async () => {
294+
const { dispatcher, engine } = outboxStack();
295+
const result = await run(engine, { url: `${baseUrl}/hook`, durable: true, signingSecret: '', body: { a: 1 } });
296+
expect(result.success).toBe(true);
297+
298+
await dispatcher.tick();
299+
expect(only().headers[SIGNATURE_HEADER_LC]).toBeUndefined();
300+
});
301+
302+
it('REFUSES, and enqueues nothing, when a templated secret resolves to nothing', async () => {
303+
const { outbox, dispatcher, engine } = outboxStack();
304+
const result = await run(engine, {
305+
url: `${baseUrl}/hook`,
306+
durable: true,
307+
signingSecret: '{signing_key}',
308+
body: { a: 1 },
309+
});
310+
311+
expect(result.success).toBe(false);
312+
expect(result.error).toContain('signingSecret');
313+
expect(await outbox.list()).toHaveLength(0);
314+
await dispatcher.tick();
315+
expect(received).toHaveLength(0);
316+
});
317+
});

‎packages/services/service-automation/src/guard-refusal-inventory.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ const GUARDS: Array<{ name: string; why: string; node: Record<string, unknown>;
153153
strip: 'url',
154154
expect: 'does not satisfy the http contract',
155155
},
156+
{
157+
name: 'http whose signingSecret did not resolve',
158+
why: 'the call would leave unsigned — a fault edge must not turn a missing credential into a sent request',
159+
// The record carries no `signing_key`, so the template renders nothing.
160+
node: { type: 'http', config: { url: 'http://127.0.0.1:9/never', method: 'POST', signingSecret: '{record.signing_key}' } },
161+
expect: 'signingSecret',
162+
},
156163
{
157164
name: 'subflow without flowName',
158165
why: 'a required config key',

0 commit comments

Comments
 (0)