|
| 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 | +}); |
0 commit comments