Skip to content

Commit 125ad06

Browse files
fix(runtime): an api forward's two action_invocation rows name each other (outer target, inner forwarded_from) (#28389)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01LjZwNRFiG2fkp8SNf6rHNx?thread=cmsg_01LjZwNRFiG2fkp8SNf6rHNxWFCGbzLH2Q6meq4jHsVCCM)_ Fixes #28281 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`type: 'api'` 动作转发到本服务另一条动作路由时,两条 `action_invocation` 审计行互相点名 —— 外层行记声明的 `target`,内层 REST 行记 `forwarded_from`。 - 为什么改:之前两行只能按时间戳拼接,内层行看起来像人直接调的 REST,管理员分不清是 agent 发起的转发。 - 风险与回滚:只往审计行 `metadata` 里加两个可选键,不改任何门禁或答复;回滚即 revert 本 PR,老行本来就没有这两个键。 - 先看:`packages/runtime/src/action-invocation-audit.ts`(两个读取函数与头注释)、`packages/plugins/plugin-audit/src/action-invocation-audit.ts`(行形状)、`packages/runtime/src/domains/actions.ts`(REST 门写行处)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/action-invocation-audit.test.ts`,预期 30 个用例全绿,含 MCP → api → 动作路由那条。 - 需要你做的:无,席位落地。 ## What changes An `api` action whose `target` is another action route on this server answers through two doors and writes two `sys_audit_log` rows. They now name each other instead of being joined by time alone: - **Outer row: `metadata.target`.** On the `api` branch only, the action's DECLARED `target`. It is the declaration, not the interpolated path: a path built from the caller's params can carry a param value, and this row deliberately records no params (the writer's header says why). `actionInvocationTarget` in `packages/runtime/src/action-invocation-audit.ts` produces it; both doors call it. - **Inner row: `metadata.forwarded_from`.** The forward mark the request arrived carrying (`OBJECT/ACTION` of the outer action), read from the same `x-objectstack-action-forward` header the inner REST door already reads for its loop refusal (`actionInvocationForwardedFrom`). A hint, not a surface: a forged mark mislabels this key and nothing else, and the inner row's `surface: 'rest'` stays the door's own. - `ActionInvocationAuditEvent` gains optional `target` / `forwardedFrom` in both packages; `buildActionInvocationAuditRow` writes `target` only on `branch: 'api'`. **File surface beyond the claim's `packages/runtime/src/**`, and why:** - `packages/plugins/plugin-audit/src/action-invocation-audit.ts`: the row's `metadata` is shaped there from an explicit member list (the runtime hands an event, never a row), so a new key the runtime sends is dropped unless the writer names it. The card names this package as the code's home too. - `packages/runtime/src/action-execution.ts`: ONE added line in `invokeBusinessAction`'s invocation literal. The MCP `run_action` outer row is built there, not in `domains/mcp.ts` (which builds only `resume_run`'s row, a `flow` branch that never forwards). The dispatch's PM assumption placed the producer in `action-invocation-audit.ts`; that file is the helper, and the literal lives in the hot file. The line is an addition beside `branch:`, so it should rebase cleanly over the released unpushed work on that file. ## Tests - `packages/runtime/src/action-invocation-audit.test.ts`, new describe `[#28281]`: the in-process injector routes the forward into the REAL REST action door with the headers `forwardApiAction` actually sent, so the inner row is written by the same door reading the same mark as on a server. - MCP → api → action route: two events; the outer is `surface: mcp`, `branch: api`, `target` = the declared path, no `forwardedFrom`; the inner is `surface: rest`, `branch: script`, `forwardedFrom: aud_lead/forward_lead`, no `target`. - REST → api → action route names the same pair; a direct call names neither. - The outer row records the declaration, not the interpolated path. - A forged mark on a direct call is recorded as the hint it is and moves nothing else on the row (surface stays `rest`). - Helper pins for both readers. - `packages/plugins/plugin-audit/src/action-invocation-audit.test.ts`: the two rows read back through a real engine carry exactly `forwarded_from` / `target`; a `target` on a non-`api` branch is not written. ## Acceptance notes - Only the REST door records `forwardedFrom`, as the card asks. The MCP door reads no mark into its row; a forward whose target is `/mcp` itself is not a case this PR measured, and is left as it was. - The card's "Also noted (S2)" latency item is untouched, as dispatched. - Dispatch asked for `pnpm lint`; the os-dev contract assigns the repo-wide lint to CI. Ran eslint with `--no-inline-config` on the six changed source files instead (exit 0); CI's `Lint & Repo Gates` runs the full sweep. ## Verification All readings at head `6e3c12b63`. <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/runtime test`: Test Files 491 passed (491), Tests 7977 passed, 19 skipped. Lock VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-audit test`: Test Files 68 passed (68), Tests 1099 passed. VERDICT command-exit 0. - `pnpm --filter @objectstack/runtime --filter @objectstack/plugin-audit typecheck` (includes `check:test-typecheck`): VERDICT command-exit 0. The first run caught one TS2345 in the new rig (the in-process request mock typed with no arguments), fixed in `6e3c12b63`. - eslint `--no-inline-config` on the six changed source files: exit 0. </details> <details><summary>Ablation (each leg through scripts/ablation-replace.mjs; restored blob equals HEAD, git diff HEAD empty)</summary> Tests import `src/` relatively in both packages, so no dist rebuild sits between mutation and run. - A: `actionInvocationTarget` returns undefined → runtime file 4 failed / 26 passed (the MCP and REST forward pins, the declared-target pin, the helper pin). - B: the REST door's `forwardedFrom` forced undefined → runtime file 3 failed / 27 passed (MCP forward, REST forward, forged-mark pins). - C: plugin-audit's `forwarded_from` line deleted → 1 failed / 8 passed (the read-back pair). - D: plugin-audit's api-only guard on `target` removed → 1 failed / 8 passed (the non-api pin). </details> <details><summary>Dispatch gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 families derived from 7 paths vs merge base `80b561be1`. Every command run, exit captured to disk before any pipe; `--ran` reconciliation: 66 accounted, 64 run, 2 NOT MEASURED, 0 unrun. - NOT MEASURED (exit 3, PREREQUISITE NOT MET: need a full `pnpm build`): `pnpm check:dual-build-cjs-loads`, `pnpm check:i18n`. Neither reads anything this diff changes; CI's `Lint & Repo Gates` runs both. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 on this shallow clone (its pinned fixture commit was unreachable); after fetching that one commit it exits 0 (48 cases). - Every other derived command exited 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BvaSHKxMfeL8P6juJm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bed8306 commit 125ad06

7 files changed

Lines changed: 277 additions & 6 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/plugin-audit': patch
4+
---
5+
6+
fix(runtime): the two `action_invocation` rows of an `api` action that forwards to another action route name each other
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A `type: 'api'` action whose `target` is another action route on the same server answers through two doors, so it writes two `sys_audit_log` rows: the outer door's (`branch: 'api'`, `surface: 'mcp'` for an agent's `run_action`, `rest` for a click) and the inner REST door's for the action it reached. The two could only be joined by their timestamps, and the inner row read like a person's direct REST call. Now each row names the other:
12+
13+
- **The outer row's `metadata.target`** is the action's declared `target`, on the `api` branch only. It is the declaration, not the interpolated path: a path built from the caller's params can carry a param value, and this row records no params.
14+
- **The inner row's `metadata.forwarded_from`** is the forward mark the request arrived carrying (`<object>/<action>` of the outer action), the same `x-objectstack-action-forward` header the inner door already reads for its loop refusal. It is a hint, not a surface: any caller can send the mark, so a forged one mislabels this key and nothing else, and the inner row's `surface: 'rest'` still names the door the request came through. Because the header is caller text bound for the ledger, it is recorded only in the forwarding door's own shape: two snake_case machine names joined by one slash, at most 255 characters. Any other value is left out of the row, and the call is answered exactly as it would be without it.
15+
16+
**Public surface (widening):** `ActionInvocationAuditEvent`, exported by `@objectstack/plugin-audit`, gains two optional members, `target` and `forwardedFrom`. An event that sets neither writes the same row as before. The runtime's matching event type and helpers are internal to `@objectstack/runtime`. Rows written before this change carry neither key.

‎packages/plugins/plugin-audit/src/action-invocation-audit.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,35 @@ describe('[#27908] the action_invocation row tells an agent\'s call from a click
198198
});
199199
});
200200

201+
it('[#28281] an api forward\'s two rows name each other: the outer row its target, the inner row the forward', async () => {
202+
const engine = await boot();
203+
const writer = createActionInvocationAuditWriter({ getEngine: () => engine as any });
204+
205+
await writer.recordActionInvocation({
206+
context: { userId: 'usr_1', tenantId: 'org_1', surface: 'rest' },
207+
actionName: 'nudge_case', objectName: 'crm_case', recordId: 'case_1', branch: 'script', outcome: 'succeeded',
208+
forwardedFrom: 'crm_case/forward_case',
209+
});
210+
await writer.recordActionInvocation({
211+
...AGENT, actionName: 'forward_case', branch: 'api', target: '/api/v1/actions/crm_case/nudge_case',
212+
});
213+
214+
const [inner, outer] = (await ledgerRows(engine)).map((r) => JSON.parse(r.metadata));
215+
expect(inner).toEqual({
216+
surface: 'rest', action_name: 'nudge_case', branch: 'script', outcome: 'succeeded',
217+
forwarded_from: 'crm_case/forward_case',
218+
});
219+
expect(outer).toEqual({
220+
surface: 'mcp', action_name: 'forward_case', branch: 'api', outcome: 'succeeded',
221+
target: '/api/v1/actions/crm_case/nudge_case', performed_by: 'cli_agent', on_behalf_of: 'usr_1',
222+
});
223+
});
224+
225+
it('[#28281] a target is recorded only on the api branch', () => {
226+
const row = buildActionInvocationAuditRow({ ...CLICK, branch: 'flow', target: 'escalate_flow' });
227+
expect('target' in JSON.parse(String(row.metadata))).toBe(false);
228+
});
229+
201230
it('an object-less action names no object and no record', () => {
202231
const row = buildActionInvocationAuditRow({ ...CLICK, objectName: undefined, recordId: 'r1' });
203232
expect(row.object_name).toBeNull();

‎packages/plugins/plugin-audit/src/action-invocation-audit.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,14 @@
4949
* - `metadata`: `surface`, `action_name`, `branch`, `outcome`, and on a
5050
* failure the HTTP `status` and the error's machine code; `run_id` for a
5151
* `resume_run`; and `performed_by` / `on_behalf_of` when an agent performed
52-
* the call (ADR-0090 D10 rule 4, the same pair the CRUD mirror writes).
52+
* the call (ADR-0090 D10 rule 4, the same pair the CRUD mirror writes);
53+
* - [#28281] the two rows of an `api` forward to another action route name
54+
* each other: the outer row's `target` is the action's declared target
55+
* (the declaration, not the interpolated path, which can carry a param
56+
* value), and the inner row's `forwarded_from` is the forward mark it
57+
* arrived carrying (`<object>/<action>` of the outer action). The mark is
58+
* a HINT, not a surface: a caller can forge it, so it mislabels this key
59+
* and nothing else, and the inner row's `surface` stays its own door's.
5360
*/
5461

5562
import type { IDataEngine } from '@objectstack/spec/contracts';
@@ -100,6 +107,10 @@ export interface ActionInvocationAuditEvent {
100107
errorCode?: string;
101108
/** `resume_run` only: the paused run the call continued. */
102109
runId?: string;
110+
/** `api` branch only: the action's declared `target` (the outer row of a forward). */
111+
target?: string;
112+
/** The forward mark the call arrived carrying (the inner row of a forward) — a hint only. */
113+
forwardedFrom?: string;
103114
}
104115

105116
/** What {@link createActionInvocationAuditWriter} adds to the `audit` slot. */
@@ -158,6 +169,10 @@ export function buildActionInvocationAuditRow(
158169
}
159170
const runId = nonEmpty(event.runId);
160171
if (runId) metadata.run_id = runId;
172+
const target = event.branch === 'api' ? nonEmpty(event.target) : undefined;
173+
if (target) metadata.target = target;
174+
const forwardedFrom = nonEmpty(event.forwardedFrom);
175+
if (forwardedFrom) metadata.forwarded_from = forwardedFrom;
161176
// [ADR-0090 D10 rule 4] The same pair, under the same names, the CRUD mirror
162177
// writes for a delegated write — present only when an agent performed the
163178
// call, so the two shapes stay distinguishable by absence.

‎packages/runtime/src/action-execution.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ import type { FlowFailureDetails } from '@objectstack/spec/api';
7373
import { recordNotFoundError, omitInternalFieldsFromWriteResponse } from '@objectstack/metadata-protocol';
7474
import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js';
7575
import { actorUserFromExecutionContext, resolveActorDisplayName } from './security/actor-user.js';
76-
import { actionInvocationBranch, actionInvocationFailure, recordActionInvocation, withActionSurface } from './action-invocation-audit.js';
76+
import { actionInvocationBranch, actionInvocationFailure, actionInvocationTarget, recordActionInvocation, withActionSurface } from './action-invocation-audit.js';
7777
import type { HttpProtocolContext } from './http-dispatcher.js';
7878
import {
7979
GLOBAL_ACTION_OBJECT_KEY,
@@ -3533,6 +3533,7 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
35333533
objectName: isObjectLessActionKey(objectName) ? undefined : objectName,
35343534
recordId,
35353535
branch: actionInvocationBranch(action, isDeclarativeUpdateAction(action)),
3536+
target: actionInvocationTarget(action, isDeclarativeUpdateAction(action)), // #28281: the outer row names where an api forward went
35363537
};
35373538
let answer: any;
35383539
try {

‎packages/runtime/src/action-invocation-audit.test.ts‎

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
2323

2424
import { HttpDispatcher } from './http-dispatcher.js';
2525
import { API_FORWARD_MARK_HEADER } from './action-execution.js';
26-
import { withActionSurface, actionInvocationBranch, actionInvocationFailure } from './action-invocation-audit.js';
26+
import {
27+
withActionSurface,
28+
actionInvocationBranch,
29+
actionInvocationFailure,
30+
actionInvocationForwardedFrom,
31+
actionInvocationTarget,
32+
ACTION_FORWARD_MARK_MAX_LENGTH,
33+
} from './action-invocation-audit.js';
2734

2835
const OBJECT = 'aud_lead';
2936
const RECORD = 'lead_1';
@@ -35,6 +42,11 @@ const API = {
3542
target: '/api/v1/aud/ban', recordIdParam: 'userId', ai: ai('Ban the lead.'),
3643
};
3744
const FLOW = { name: 'escalate_lead', label: 'Escalate', objectName: OBJECT, type: 'flow', target: 'escalate_flow', ai: ai('Escalate the lead.') };
45+
/** An api action whose target is ANOTHER action route on this server — the forward #28281 joins. */
46+
const FORWARD = {
47+
name: 'forward_lead', label: 'Forward', objectName: OBJECT, type: 'api',
48+
target: `/api/v1/actions/${OBJECT}/${SCRIPT.name}`, ai: ai('Forward the lead to the nudge.'),
49+
};
3850
const UPDATE = { name: 'close_lead', label: 'Close', objectName: OBJECT, operation: 'update', patch: { status: 'closed' }, ai: ai('Close the lead.') };
3951
/** Gated on a capability no caller below holds — the pre-dispatch refusal. */
4052
const GATED = { ...SCRIPT, name: 'purge_lead', target: 'purge_lead', requiredPermissions: ['aud.purge'] };
@@ -54,7 +66,7 @@ function makeRig(fail: Failing = {}) {
5466
const objectDef = {
5567
name: OBJECT,
5668
fields: { status: { type: 'text' }, owner: { type: 'text' } },
57-
actions: [SCRIPT, API, FLOW, UPDATE, GATED],
69+
actions: [SCRIPT, API, FLOW, UPDATE, GATED, FORWARD],
5870
};
5971
const schemaOf = (n: string) => (n === OBJECT ? objectDef : undefined);
6072
const ql: any = {
@@ -78,7 +90,7 @@ function makeRig(fail: Failing = {}) {
7890
insert: vi.fn(),
7991
delete: vi.fn(),
8092
};
81-
const request = vi.fn(async () => new Response(
93+
const request = vi.fn(async (_url: string, _init: RequestInit) => new Response(
8294
JSON.stringify(fail.api
8395
? { success: false, error: { code: 'FORBIDDEN', message: 'The target refused.' } }
8496
: { success: true, data: { banned: true } }),
@@ -261,6 +273,104 @@ describe('[#27908] the surface is the DOOR\'s — nothing on the request moves i
261273
});
262274
});
263275

276+
describe('[#28281] an api forward to another action route: the two rows name each other', () => {
277+
/**
278+
* The in-process injector routes the forward into the REAL REST action
279+
* door, with the headers `forwardApiAction` actually sent — so the inner
280+
* row is written by the same door, reading the same mark, as in a server.
281+
*/
282+
function forwardingRig() {
283+
const rig = makeRig();
284+
const prefix = '/api/v1/actions';
285+
rig.request.mockImplementation(async (url: string, init: RequestInit) => {
286+
const path = new URL(url).pathname;
287+
if (!path.startsWith(`${prefix}/`)) return new Response('{}', { status: 404 });
288+
const headers = Object.fromEntries(Object.entries((init.headers ?? {}) as Record<string, string>));
289+
const body = typeof init.body === 'string' ? JSON.parse(init.body) : {};
290+
const res: any = await rig.dispatcher.handleActions(path.slice(prefix.length), String(init.method ?? 'POST'), body, {
291+
request: { headers },
292+
environmentId: 'platform',
293+
executionContext: { tenantId: 'org_1', systemPermissions: [], userId: 'u_member' },
294+
});
295+
return new Response(JSON.stringify(res.response.body), {
296+
status: res.response.status, headers: { 'content-type': 'application/json' },
297+
});
298+
});
299+
return rig;
300+
}
301+
302+
it('MCP → api → action route: the outer mcp row names the target, the inner rest row names the forward', async () => {
303+
const rig = forwardingRig();
304+
const out = await rig.viaMcp(FORWARD);
305+
306+
expect(out.ok).toBe(true);
307+
expect(rig.request).toHaveBeenCalledTimes(1);
308+
expect(rig.ql.executeAction).toHaveBeenCalledWith(OBJECT, SCRIPT.target, expect.anything());
309+
// The inner door answers before the outer door writes, so the inner row lands first.
310+
expect(rig.events).toHaveLength(2);
311+
const [inner, outer] = rig.events;
312+
313+
expect(outer.context.surface).toBe('mcp');
314+
expect(outer).toMatchObject({
315+
actionName: FORWARD.name, objectName: OBJECT, branch: 'api', outcome: 'succeeded', target: FORWARD.target,
316+
});
317+
expect(outer.forwardedFrom).toBeUndefined();
318+
319+
expect(inner.context.surface).toBe('rest');
320+
expect(inner).toMatchObject({
321+
actionName: SCRIPT.name, objectName: OBJECT, branch: 'script', outcome: 'succeeded',
322+
forwardedFrom: `${OBJECT}/${FORWARD.name}`,
323+
});
324+
expect(inner.target).toBeUndefined();
325+
});
326+
327+
it('REST → api → action route names the same pair; a direct call names neither', async () => {
328+
const rig = forwardingRig();
329+
const res = await rig.viaRest(FORWARD);
330+
expect(res.status).toBe(200);
331+
const [inner, outer] = rig.events;
332+
expect(outer).toMatchObject({ branch: 'api', target: FORWARD.target });
333+
expect(outer.context.surface).toBe('rest');
334+
expect(inner).toMatchObject({ actionName: SCRIPT.name, forwardedFrom: `${OBJECT}/${FORWARD.name}` });
335+
336+
const direct = makeRig();
337+
await direct.viaRest(SCRIPT);
338+
expect(direct.events).toHaveLength(1);
339+
expect(direct.events[0].target).toBeUndefined();
340+
expect(direct.events[0].forwardedFrom).toBeUndefined();
341+
});
342+
343+
it('the outer row records the DECLARED target, never the path interpolated from the caller\'s params', async () => {
344+
const rig = makeRig();
345+
await rig.viaMcp(API);
346+
const sent = String((rig.request.mock.calls[0] as any[])[0]);
347+
expect(sent).toContain('/api/v1/aud/ban');
348+
expect(rig.events[0].target).toBe(API.target);
349+
});
350+
351+
it('a forged mark in the forward\'s shape is recorded as the hint it is, and moves nothing else on the row', async () => {
352+
const rig = makeRig();
353+
const res = await rig.viaRest(SCRIPT, { userId: 'u_member' }, { [API_FORWARD_MARK_HEADER]: 'aud_lead/forged' });
354+
expect(res.status).toBe(200);
355+
expect(rig.events).toHaveLength(1);
356+
expect(rig.events[0]).toMatchObject({ forwardedFrom: 'aud_lead/forged', outcome: 'succeeded', branch: 'script' });
357+
expect(rig.events[0].context.surface).toBe('rest');
358+
});
359+
360+
it.each([
361+
['over-long', `aud_lead/${'x'.repeat(4096)}`],
362+
['malformed', 'aud_lead/forward_lead; DROP TABLE sys_audit_log'],
363+
])('a forged %s mark is left out of the row; the call is answered and recorded exactly as without it', async (_kind, mark) => {
364+
const rig = makeRig();
365+
const res = await rig.viaRest(SCRIPT, { userId: 'u_member' }, { [API_FORWARD_MARK_HEADER]: mark });
366+
expect(res.status).toBe(200);
367+
expect(rig.events).toHaveLength(1);
368+
expect(rig.events[0]).toMatchObject({ actionName: SCRIPT.name, outcome: 'succeeded', branch: 'script' });
369+
expect(rig.events[0].forwardedFrom).toBeUndefined();
370+
expect(rig.events[0].context.surface).toBe('rest');
371+
});
372+
});
373+
264374
describe('[#27908] MCP resume_run continues a run and writes its own row', () => {
265375
function resumeRig(resumeAnswer: any) {
266376
const rig = makeRig();
@@ -310,6 +420,31 @@ describe('[#27908] the helpers', () => {
310420
expect(actionInvocationBranch(undefined, false)).toBe('script');
311421
});
312422

423+
it('actionInvocationTarget names only an api branch\'s declared target', () => {
424+
expect(actionInvocationTarget({ type: 'api', target: '/api/v1/x' }, false)).toBe('/api/v1/x');
425+
expect(actionInvocationTarget({ type: 'api', target: '/api/v1/x', operation: 'update' }, true)).toBeUndefined();
426+
expect(actionInvocationTarget({ type: 'flow', target: 'some_flow' }, false)).toBeUndefined();
427+
expect(actionInvocationTarget({ type: 'api', target: ' ' }, false)).toBeUndefined();
428+
});
429+
430+
it('actionInvocationForwardedFrom records only a mark in the forwarding door\'s own shape', () => {
431+
const read = (mark: unknown) => actionInvocationForwardedFrom(
432+
{ request: { headers: { [API_FORWARD_MARK_HEADER]: mark } } } as any, API_FORWARD_MARK_HEADER);
433+
expect(read('aud_lead/forward_lead')).toBe('aud_lead/forward_lead');
434+
expect(read('global/log_call')).toBe('global/log_call');
435+
const longest = `${'a'.repeat(127)}/${'b'.repeat(127)}`;
436+
expect(longest).toHaveLength(ACTION_FORWARD_MARK_MAX_LENGTH);
437+
expect(read(longest)).toBe(longest);
438+
for (const malformed of [
439+
'', 'aud_lead', 'aud_lead/', '/forward_lead', 'aud_lead/forward_lead/extra', 'Aud_Lead/forward_lead',
440+
'aud_lead/forward lead', ' aud_lead/forward_lead', 'aud_lead/forward_lead\n', '*/log_call', 'a/forward_lead',
441+
'1lead/forward_lead', `${'a'.repeat(128)}/${'b'.repeat(127)}`, ['aud_lead/forward_lead'], 42,
442+
]) {
443+
expect(read(malformed)).toBeUndefined();
444+
}
445+
expect(actionInvocationForwardedFrom(undefined, API_FORWARD_MARK_HEADER)).toBeUndefined();
446+
});
447+
313448
it('actionInvocationFailure reads a declared status and code, and a bare throw as 500', () => {
314449
expect(actionInvocationFailure(Object.assign(new Error('x'), { status: 409, code: 'CONFLICT' }))).toEqual({ status: 409, errorCode: 'CONFLICT' });
315450
expect(actionInvocationFailure(new Error('x')).status).toBe(500);

0 commit comments

Comments
 (0)