Skip to content

Commit b00e3f4

Browse files
fix(console): a declared same-origin type: 'api' click goes through the action door (#27793)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:控制台里点击「对象上声明过、目标是本站路径的 `type: 'api'` 动作」(列表行、工具栏、页面按钮、记录页抬头和相关列表)时,不再由浏览器直接请求动作的 target,而是发到平台动作门 `POST /api/v1/actions/:object/:action`,只带记录 id 和参数,由服务端按声明组装请求再转发。目标在其他域名的 api 动作、没有对应声明的页面内联按钮、不挂在对象上的全局动作,仍然直连。 - 为什么改:这是 #27506 裁定「agent 和人类使用同一套系统」的第 3 步。之前人点按钮绕过了服务端的登录底线、requiredPermissions、启用开关和参数契约:在 Setup 关掉一个打包的 api 动作,按钮照样能执行。现在关掉的动作点下去返回 409 ACTION_DISABLED,界面显示那句说明,目标不会被调用。 - 风险与回滚:所有声明式同源 api 动作(邀请成员、封禁用户、审批同意/驳回、轮换密钥等)的请求路径都变了。服务端动作门目前有两个缺口(#27738):丢掉目标错误里的 details/userMessage,以及拒收审批决策的 `outputs.<key>` 参数。在 #27738 合入之前合本 PR,会让「带决策输出的审批」被拒、套餐升级弹窗缺链接、流程端点的拒绝提示退回节点链。回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/utils/apiActionDoor.ts`(选门规则、发送与 2xx 读取);`hooks/useConsoleActionRuntime.tsx` 的 apiHandler 动作门分支;`views/RecordDetailView.tsx` 的 apiHandler 动作门分支。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/utils/__tests__/apiActionDoor.test.ts packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx packages/app-shell/src/views/RecordDetailView.apiActionDoor-27611.test.tsx`,预期 3 个文件 25 条全绿。 - 需要你做的:无,席位落地。合并顺序是 #27738 在前、本 PR 在后,本 PR 已标 Blocked-by。 Fixes #27611 Blocked-by: #27738 Clause-②: no Metadata-protocol: none ## What changes Step 3 of the ruling on #27506: a person's click and an agent's `run_action` take the same road. The server half is on `main` (#27649: the action door accepts a declared `type: 'api'` action whose `target` is a path on this origin, runs its gates and forwards to the target in-process). This PR moves the console's two api handlers onto it. - **New `ui/packages/app-shell/src/utils/apiActionDoor.ts`** (module-internal, not re-exported): - `isSameOriginApiTarget` restates the server's predicate (`action-execution.ts#isSameOriginApiTarget`): a path that starts with one `/`, not `//` or `/\`. - `findDeclaredApiAction(objects, objectName, actionName, clickedTarget)` decides the door by lookup, as `flowLaunch` looks up a flow action: the click's object and `name` must resolve to a declared `type: 'api'` action whose declared `target` is same-origin and is the clicked target. A `${…}` token the runner already interpolated into the target matches one path segment. - `sendApiActionThroughDoor` sends through `@object-ui/core`'s `createServerActionHandler`, so the `/actions` request (name-only identity, `{ recordId, params }`, the `_rowRecord` strip) stays core's and app-shell still names that route nowhere (`actions-envelope.ratchet.test.ts`). It keeps the raw answer for the host to read. - `readApiActionDoorSuccess` reads a 2xx through the `/actions` envelope rule (`interpretActionResponse`). The door answers the target's payload as its `data`, already unwrapped from the target's `{ success, data }`, so the payload is what the direct fetch handed back for a target that answers bare (a stock better-auth body), and a target's HTTP 200 rejection relayed inside `data` reads as a failure. - **`useConsoleActionRuntime` `apiHandler`** (list rows, toolbar, page actions): for a same-origin target with an object and a name it waits for the object type (as the flow launch does, so "no declaration" is an answer and not a race against a cold store), looks the action up, and on a hit sends `{ recordId: <stashed row's id>, params: <params, {{page.<var>}} resolved> }` to the door. A non-2xx goes through the same reader as the direct fetch (`refused`: the entitlement dialog, then `apiActionFailureMessage`). A 2xx returns the payload and honours `refreshAfter`. - **`RecordDetailView` `apiHandler`** (record header, related lists): the same, looked up in the page's `objects`. The subject id is the stashed row's id, else a related-list row's explicit `recordId`, else this page's id when the action is on this page's object. A non-2xx reads `apiActionFailureMessage` as the direct fetch does, and `refreshAfter === true` keeps this page's meaning. The edit stays inside `apiHandler`, plus its import line and `objects` in its dependency list. - The direct fetch is unchanged for an off-origin target, for a click that names no declared action (an inline page `action:button`), and for a declared action the lookup cannot see (below). `withNewTabUrlFastPath` still wraps both handlers, so a `newTabUrl` click sends nothing on either road. - The six docblock and pin sites that quoted the superseded ADR-0066 D4 text now read the amended D4 (the action door dispatches a declared same-origin api action and runs its `requiredPermissions`; an off-origin target never reaches it): `containers.tsx`, `action-bar.test.tsx`, `page-header-capability-gate.test.tsx`, `bulkActionCapabilityGate.test.tsx`, `plugin-detail/src/RelatedList.tsx`, `RelatedList.toolbarCapabilityGate-9782.test.tsx`. - Changeset: `@object-ui/app-shell` patch. ## Tests New: - `utils/__tests__/apiActionDoor.test.ts` (6): the predicate on every spelling the server's predicate answers; the lookup by object, name, type and target, an interpolated `${ctx.recordId}` target, and no hit for an off-origin declaration, another type, another target, or a missing object, name or metadata. - `hooks/__tests__/useConsoleActionRuntime.apiActionDoor-27611.test.tsx` (12), on the hook's real `apiHandler`: - door road: the request is `POST /api/v1/actions/sys_user/ban_user` with `{ recordId, params }` and nothing reaches the target; `409 ACTION_DISABLED` answers its sentence, never the bare code; a 2xx answers the unwrapped payload and refreshes; a relayed 200 rejection is a failure and does not refresh; `{{page.<var>}}` tokens arrive resolved in `params`. - direct road: an off-origin target; a click that names no declared action; a same-named declaration aimed at another target. - written against what the door answers once #27738 lands: a flow-backed endpoint's `FLOW_FAILED` reads its inner refusal (the form's permission line); `DEV_ENV_PLAN_LOCKED` opens the upgrade dialog with its call to action and no error toast; `DEV_ENV_LIMIT` quotes the usage; an approval decision sends `outputs.<key>` as params. - `views/RecordDetailView.apiActionDoor-27611.test.tsx` (7), on the handler set the record page hands its `ActionProvider`: a header click sends this page's id and hands back the target's payload; a bare target answer comes back at the level the direct fetch handed back, so a `resultDialog` path (`client_secret`) still reads; a relayed 200 rejection is a failure; `409 ACTION_DISABLED` answers its sentence; a related-list child row sends the child row's id to the child object's door; an off-origin target goes direct; the `FLOW_FAILED` inner refusal once #27738 lands. Updated, because they pinned the direct target URL of a DECLARED same-origin action, which now goes through the door (each now pins the door route and its body): `RecordDetailView.approvalDeclaredActions.test.tsx` (the decision goes to `/actions/sys_approval_request/approval_send_back` with the request's id; the output rides as `outputs.next_reviewer` in `params`), `RecordDetailView.accessEndedAfterDecision-26589.test.tsx`, `RecordDetailView.apiNewTabUrl-26747.test.tsx` (CONTROL), `RecordDetailView.printPageHeaderActions-12906.test.tsx` (c), `PageView.relatedListActions-12870.test.tsx`, `ObjectView.inviteFeedback-11817.test.tsx`, and `apps/console/.../approvalRequestsDataSource.decisionPage.test.tsx`. ## Acceptance notes - **Blocked by #27738.** On the door road, three answers lose what the direct fetch kept until #27738 lands: the door drops the target's `error.details` and `userMessage` (a flow-backed endpoint's refusal shows the node chain again; an entitlement 403 loses its upgrade/contact links, plan and usage), and the door's param contract refuses an approval decision's `outputs.<key>` params (`approval_approve` / `approval_reject` declare `comment` and `attachments`), so a decision with declared outputs is refused 400. The four pins in the "once #27738 lands" group are written against the door's answer after that change and are shown here only against a mocked door. - **The `outputs.*` fold moved to the server.** The console now sends `outputs.<key>` as params and the door's `buildApiActionRequest` folds them into `outputs`. No server test pins that fold today; #27738 is the place to add one. - **"Same body as before" is pinned in two halves.** The console side pins the same inputs (the subject row's id and the params bag) against a mocked door. The body the door assembles from them (`recordIdParam` / `recordIdField`, `bodyShape.wrap`, `bodyExtra`, `method`, the target's placeholders, `organizationId`) is pinned server-side in `packages/runtime/src/domains/api-action-forward.test.ts`. - **What the lookup cannot see keeps the direct fetch.** The lookup reads the object metadata the host holds, as the flow launch does. An object-less declared action (`/actions/global/:action`) and a standalone authored action row no object embeds are absent from it, so their click stays direct and their switch does not hold on a click. Noted, not filed. - **Edges that differ on the door road, by reading the code (not reproduced):** - `organizationId` for an `/api/v1/auth/` target now comes from the server's execution context rather than the browser's active organization. - A target that answers HTTP 200 `{ success: false, data: … }` loses its rejection at the door (`apiActionResultOf` hands back `data`), and a 200 `{ success: false, message }` with keys outside the legacy envelope reads as success under the narrow `/actions` rule. The direct fetch caught both through `success === false`. No producer of either shape was measured. - A `{{page.<var>}}` token with no page-variable snapshot is sent as-is; the door refuses it 400, where the direct fetch sent it to the target literally. - On the record page, a 2xx now hands back the payload unwrapped from the target's `{ success, data }` (the list runtime's level), where the direct fetch handed back the envelope; a bare answer is unchanged, and a relayed 200 rejection is now a failure there too. - `content/docs/ui/actions.mdx` already describes the door for declared same-origin api actions; no docs change. - Sibling #27504 (the switched-off button's visibility) is not in this PR. ## Verification <details><summary>Local runs (head b6ee680)</summary> - Final head `b6ee6801` (after merging `origin/main` 6d9419b), under `scripts/pm/os-verify-lock.sh`: vitest on the 3 new files and the 7 updated ones, Test Files 10 passed, Tests 71 passed; then `pnpm --filter @object-ui/app-shell run type-check` (both `tsc --noEmit` and `tsconfig.test.json`), VERDICT command-exit 0. - Affected surface on `bd2818b4` (every ui test file naming an api action, an api handler, the `/actions` route, `useConsoleActionRuntime`, `RecordDetailView`, `ObjectView` or `PageView`, across app-shell, core, components, the plugins and the console app): Test Files 714 passed, Tests 8944 passed, VERDICT command-exit 0. - Ablation (`scripts/ablation-replace.mjs`, marker `'ABLATED_27611'` making `findDeclaredApiAction` answer no declaration, so every click takes the direct fetch): on the 3 new files, 17 red (every door-road case in both hosts and the two positive lookup cases), 8 green (the four direct-road controls and the predicate and negative lookup cases); on the 10-file set, 27 red including every updated pin. Restore: blob == HEAD, `git diff HEAD` empty. Two door pins that stayed green on the first ablation (they read the same answer on both roads) now also assert the door road. - Derived gates (`dispatch-gates --commands`) on `b6ee6801`: 34 derived, 34 run, all exit 0 (including `check-sdui-manifest.mjs` and its `--self-test`, and `check:nul-bytes`); `--ran` reconciliation: 0 NOT-MEASURED, 0 UNRUN. - `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXnX1eGkqKFsNRxR8H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f934a41 commit b00e3f4

20 files changed

Lines changed: 1044 additions & 65 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(console): a click on a declared same-origin `type: 'api'` action goes through the platform action door
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The console's two api handlers (list, toolbar and page actions; the record page's header and related lists) fetched an api action's `target` directly, so the click met none of the platform's gates: switching a packaged api action off in Setup did not stop its button, and a human click took a different road from an agent's `run_action`. A click whose object and `name` resolve to a declared `type: 'api'` action whose `target` is a path on this origin now goes to `POST /api/v1/actions/:object/:action` with the subject row's id and the params bag (`{{page.<var>}}` tokens resolved in the browser). The server runs the sign-in floor, `requiredPermissions`, the activation switch, the param contract and the subject-row load, then forwards to the declared target with the body it assembles from the declaration. A switched-off action answers `409 ACTION_DISABLED`, shown as its sentence, and its target is never called.
11+
12+
An api action whose `target` is on another origin, and an inline page button that names no declared action, keep the direct fetch. The entitlement dialog, the flow-backed refusal message, the `{ success, data }` unwrap, the `newTabUrl` fast path and `refreshAfter` read the door's answer as they read the target's.

‎ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
* - its timeline from `GET /approvals/requests/:id/actions`;
2424
* - the target record of the `record_id` pointer pair from the adapter, drawn
2525
* as A2's card;
26-
* - the decision as the declared action's POST, through `ActionParamDialog`.
26+
* - the decision as the declared action's POST through the action door
27+
* (objectstack#27611), collected in `ActionParamDialog`.
2728
*
2829
* The page reads through `routeApprovalRecordReads`, the console's adapter
2930
* with a request's record reads routed, as every surface reads it. The last
@@ -197,6 +198,8 @@ function json(status: number, body: unknown): Response {
197198
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
198199
}
199200

201+
const APPROVE_DOOR = `/api/v1/actions/${APPROVAL_REQUEST_OBJECT}/approval_approve`;
202+
200203
/** Every request the page sent over HTTP: method, path and query, body. */
201204
let wire: Array<{ method: string; path: string; body?: unknown }> = [];
202205

@@ -209,8 +212,10 @@ const transport = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Pr
209212
wire.push({ method, path: url.pathname + url.search, body });
210213
if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}`) return json(200, ROW);
211214
if (method === 'GET' && url.pathname === `/api/v1/approvals/requests/${REQ}/actions`) return json(200, { data: TIMELINE });
212-
if (method === 'POST' && url.pathname === `/api/v1/approvals/requests/${REQ}/approve`) {
213-
return json(200, { request: ROW, finalized: false });
215+
// The decision goes through the action door (objectstack#27611), which
216+
// forwards to the approve route and answers its payload as `data`.
217+
if (method === 'POST' && url.pathname === APPROVE_DOOR) {
218+
return json(200, { success: true, data: { request: ROW, finalized: false } });
214219
}
215220
return json(200, { success: true, data: [] });
216221
});
@@ -292,7 +297,7 @@ describe("an approval request's record page with record:approval_decision (objec
292297
expect(requestGets().length).toBeGreaterThan(0);
293298
});
294299

295-
it('Approve collects a comment and several files in ActionParamDialog, POSTs the request route, then re-reads the request in place', async () => {
300+
it('Approve collects a comment and several files in ActionParamDialog, POSTs it through the action door, then re-reads the request in place', async () => {
296301
openRequestPage();
297302
const approve = await screen.findByTestId('declared-action-approval_approve', undefined, { timeout: 8000 });
298303
const before = requestGets().length;
@@ -308,11 +313,11 @@ describe("an approval request's record page with record:approval_decision (objec
308313
fireEvent.click(within(dialog).getByRole('button', { name: /Confirm/ }));
309314

310315
await waitFor(
311-
() => expect(wire.some((w) => w.method === 'POST' && w.path === `/api/v1/approvals/requests/${REQ}/approve`)).toBe(true),
316+
() => expect(wire.some((w) => w.method === 'POST' && w.path === APPROVE_DOOR)).toBe(true),
312317
{ timeout: 8000 },
313318
);
314-
const post = wire.find((w) => w.method === 'POST' && w.path === `/api/v1/approvals/requests/${REQ}/approve`);
315-
expect(post?.body).toMatchObject({ comment: 'Looks right' });
319+
const post = wire.find((w) => w.method === 'POST' && w.path === APPROVE_DOOR);
320+
expect(post?.body).toMatchObject({ recordId: REQ, params: { comment: 'Looks right' } });
316321
await waitFor(() => expect(requestGets().length).toBeGreaterThan(before), { timeout: 8000 });
317322
});
318323

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27611 — the shared console runtime sends a click on a DECLARED
11+
* same-origin `type: 'api'` action through the action door, so the gates its
12+
* declaration carries hold for a human click as they do for an agent (step 3
13+
* of the ruling on objectstack#27506, ADR-0066 D4 as amended).
14+
*
15+
* The door is the mocked authenticated fetch: what is pinned here is the
16+
* request the console sends and how it reads each answer. That the door runs
17+
* its gates, never calls a switched-off action's target, and assembles the
18+
* target's body from the declaration is the server's own pin
19+
* (`packages/runtime/src/domains/api-action-forward.test.ts`).
20+
*
21+
* Three answers below are written as the door gives them once objectstack#27738
22+
* relays a target's `error.details` / `userMessage` and admits decision-output
23+
* params: a flow-backed endpoint's inner refusal, the entitlement dialog's call
24+
* to action and usage, and an approval decision carrying its outputs.
25+
*/
26+
27+
import { describe, it, expect, beforeEach, vi } from 'vitest';
28+
import { render, screen, renderHook, act } from '@testing-library/react';
29+
import React from 'react';
30+
import { builtInLocales, registerBuiltInLocales } from '@object-ui/i18n/locales';
31+
32+
vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));
33+
34+
const authFetchSpy = vi.fn();
35+
vi.mock('@object-ui/auth', async (importOriginal) => ({
36+
...(await importOriginal<Record<string, unknown>>()),
37+
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }),
38+
createAuthenticatedFetch: () => authFetchSpy,
39+
}));
40+
41+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
42+
...(await importOriginal<Record<string, unknown>>()),
43+
useObjectLabel: () => ({
44+
fieldLabel: (_o: any, _n: any, l: any) => l,
45+
fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l,
46+
actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback,
47+
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
48+
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
49+
}),
50+
// The entitlement dialog localizes its own copy — stand in with the English
51+
// defaults (+ `{{token}}` interpolation) the real `t` would resolve to.
52+
useObjectTranslation: () => ({
53+
t: (key: string, options?: any) =>
54+
String(options?.defaultValue ?? key).replace(
55+
/\{\{(\w+)\}\}/g,
56+
(_m: string, name: string) => String(options?.[name] ?? ''),
57+
),
58+
language: 'en',
59+
i18n: undefined,
60+
}),
61+
}));
62+
63+
vi.mock('../useActionModal', () => ({
64+
useActionModal: () => ({
65+
modalHandler: vi.fn(async () => ({ success: true })),
66+
modalElement: null,
67+
closeModal: () => {},
68+
resolveModalTarget: vi.fn(async () => null),
69+
}),
70+
}));
71+
72+
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
73+
vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null }));
74+
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
75+
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));
76+
77+
vi.mock('sonner', () => {
78+
const fn: any = vi.fn();
79+
fn.error = vi.fn();
80+
fn.success = vi.fn();
81+
return { toast: fn };
82+
});
83+
84+
import { useConsoleActionRuntime } from '../useConsoleActionRuntime';
85+
86+
registerBuiltInLocales();
87+
88+
const DOOR = '/api/v1/actions/';
89+
90+
const BAN = {
91+
name: 'ban_user',
92+
label: 'Ban User',
93+
type: 'api',
94+
target: '/api/v1/auth/admin/ban-user',
95+
recordIdParam: 'userId',
96+
params: [{ name: 'banReason', label: 'Reason', type: 'text' }],
97+
};
98+
const PING = { name: 'ping_hook', label: 'Ping', type: 'api', target: 'https://hooks.example.test/ping' };
99+
const NOTE = { name: 'post_note', label: 'Post note', type: 'api', target: '/api/v1/notes' };
100+
const CREATE_ENV = { name: 'create_environment', label: 'Create', type: 'api', target: '/api/v1/cloud/environments' };
101+
const RECALC = {
102+
name: 'showcase_recalc_estimate',
103+
label: 'Recalculate Estimate',
104+
type: 'api',
105+
target: '/api/v1/apps/showcase/recalc',
106+
recordIdParam: 'recordId',
107+
};
108+
const APPROVE = {
109+
name: 'approval_approve',
110+
label: 'Approve',
111+
type: 'api',
112+
method: 'POST',
113+
target: '/api/v1/approvals/requests/{id}/approve',
114+
params: [{ name: 'comment', label: 'Comment', type: 'textarea' }],
115+
};
116+
117+
const OBJECTS = [
118+
{ name: 'sys_user', actions: [BAN, PING, NOTE] },
119+
{ name: 'sys_environment', actions: [CREATE_ENV] },
120+
{ name: 'showcase_task', actions: [RECALC] },
121+
{ name: 'sys_approval_request', actions: [APPROVE] },
122+
];
123+
124+
const ROW = { id: 'u_target', email: 'target@example.test' };
125+
const DISABLED = "Action 'ban_user' is disabled — switched off in the packaged-metadata activation ledger "
126+
+ '(sys_metadata_activation, ADR-0126 §8).';
127+
128+
function answer(status: number, body: unknown) {
129+
authFetchSpy.mockResolvedValue({ ok: status >= 200 && status < 300, status, json: async () => body });
130+
}
131+
132+
function mount(objectName: string, objects: unknown[] = OBJECTS) {
133+
const onRefresh = vi.fn();
134+
const hook = renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: objects as any[], objectName, onRefresh }));
135+
return { ...hook, onRefresh };
136+
}
137+
138+
async function click(result: { current: ReturnType<typeof useConsoleActionRuntime> }, action: any, context?: any) {
139+
let out: any;
140+
await act(async () => {
141+
out = await result.current.apiHandler(action, context);
142+
});
143+
return out;
144+
}
145+
146+
/** The one request the click sent: its URL and parsed body. */
147+
function sent(): { url: string; method: string; body: any } {
148+
expect(authFetchSpy).toHaveBeenCalledTimes(1);
149+
const [url, init] = authFetchSpy.mock.calls[0] as [string, any];
150+
return { url: String(url), method: init.method, body: init.body === undefined ? undefined : JSON.parse(init.body) };
151+
}
152+
153+
beforeEach(() => {
154+
authFetchSpy.mockReset();
155+
});
156+
157+
describe('objectstack#27611 — a declared same-origin api click goes through the action door', () => {
158+
it('sends the subject row\'s id and the params bag to the door, and nothing to the target', async () => {
159+
answer(200, { success: true, data: { user: { id: 'u_target', banned: true } } });
160+
const { result } = mount('sys_user');
161+
162+
await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } });
163+
164+
const req = sent();
165+
expect(req.url.endsWith(`${DOOR}sys_user/ban_user`)).toBe(true);
166+
expect(req.method).toBe('POST');
167+
expect(req.body).toEqual({ recordId: 'u_target', params: { banReason: 'spam' } });
168+
});
169+
170+
it('a switched-off action answers the door\'s 409 ACTION_DISABLED as its sentence, never the bare code', async () => {
171+
answer(409, { success: false, error: { code: 'ACTION_DISABLED', message: DISABLED, httpStatus: 409 } });
172+
const { result, onRefresh } = mount('sys_user');
173+
174+
const out = await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } });
175+
176+
expect(sent().url.endsWith(`${DOOR}sys_user/ban_user`)).toBe(true);
177+
expect(out).toEqual({ success: false, error: DISABLED });
178+
expect(onRefresh).not.toHaveBeenCalled();
179+
});
180+
181+
it('a switched-on action answers the target\'s payload, unwrapped, and refreshes', async () => {
182+
answer(200, { success: true, data: { user: { id: 'u_target', banned: true } } });
183+
const { result, onRefresh } = mount('sys_user');
184+
185+
const out = await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } });
186+
187+
expect(sent().url.endsWith(`${DOOR}sys_user/ban_user`)).toBe(true);
188+
expect(out).toEqual({ success: true, data: { user: { id: 'u_target', banned: true } }, reload: true });
189+
expect(onRefresh).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it('a target\'s HTTP 200 rejection, relayed as the door\'s data, is a failure', async () => {
193+
answer(200, { success: true, data: { success: false, error: { message: 'User is already banned' } } });
194+
const { result, onRefresh } = mount('sys_user');
195+
196+
const out = await click(result, { ...BAN, params: { _rowRecord: ROW } });
197+
198+
expect(out).toEqual({ success: false, error: 'User is already banned' });
199+
expect(onRefresh).not.toHaveBeenCalled();
200+
});
201+
202+
it('resolves `{{page.<var>}}` tokens in the browser and sends the values as params', async () => {
203+
answer(200, { success: true, data: { id: 'n1' } });
204+
const { result } = mount('sys_user');
205+
206+
await click(result, { ...NOTE, params: { text: '{{page.draft}}' } }, { pageVariables: { draft: 'hello' } });
207+
208+
const req = sent();
209+
expect(req.url.endsWith(`${DOOR}sys_user/post_note`)).toBe(true);
210+
expect(req.body).toEqual({ params: { text: 'hello' } });
211+
});
212+
});
213+
214+
describe('objectstack#27611 — every other api click keeps the direct fetch', () => {
215+
it('an off-origin target is called directly', async () => {
216+
answer(200, { ok: true });
217+
const { result } = mount('sys_user');
218+
219+
await click(result, { ...PING, params: { _rowRecord: ROW } });
220+
221+
expect(sent().url).toBe('https://hooks.example.test/ping');
222+
});
223+
224+
it('a click that names no declared action (an inline page button) is called directly', async () => {
225+
answer(200, { success: true, data: {} });
226+
const { result } = mount('sys_user', []);
227+
228+
await click(result, { ...BAN, params: { banReason: 'spam', _rowRecord: ROW } });
229+
230+
const req = sent();
231+
expect(req.url.endsWith(BAN.target)).toBe(true);
232+
expect(req.body).toEqual({ banReason: 'spam', userId: 'u_target' });
233+
});
234+
235+
it('a same-named declaration aimed at another target is not the click\'s, so the click is called directly', async () => {
236+
answer(200, { success: true, data: {} });
237+
const { result } = mount('sys_user');
238+
239+
await click(result, { ...BAN, target: '/api/v1/auth/admin/unban-user', params: { _rowRecord: ROW } });
240+
241+
expect(sent().url.endsWith('/api/v1/auth/admin/unban-user')).toBe(true);
242+
});
243+
});
244+
245+
describe('objectstack#27611 — what the door answers once objectstack#27738 lands', () => {
246+
it('a flow-backed endpoint\'s refused run reads its inner refusal, not the node chain', async () => {
247+
const refusal = 'You do not have permission to perform this action. Contact your administrator if you need access.';
248+
answer(400, {
249+
success: false,
250+
error: {
251+
code: 'FLOW_FAILED',
252+
message: `Node 'write_estimate' failed: update_record(showcase_task) failed: ${refusal}`,
253+
httpStatus: 400,
254+
details: { innerRefusal: { code: 'PERMISSION_DENIED', message: refusal } },
255+
},
256+
});
257+
const { result } = mount('showcase_task');
258+
259+
const out = await click(result, { ...RECALC, params: { _rowRecord: { id: 'task-1' } } });
260+
261+
expect(sent().url.endsWith(`${DOOR}showcase_task/showcase_recalc_estimate`)).toBe(true);
262+
expect(out).toEqual({ success: false, error: builtInLocales.en.form.noPermissionToSave });
263+
});
264+
265+
it('an entitlement 403 opens the upgrade dialog with its call to action, not a red toast', async () => {
266+
answer(403, {
267+
success: false,
268+
error: {
269+
code: 'DEV_ENV_PLAN_LOCKED',
270+
message: 'Development environments are a paid feature. Upgrade to add them.',
271+
httpStatus: 403,
272+
details: { upgrade_url: '/settings/billing', plan: 'free' },
273+
},
274+
});
275+
const { result, onRefresh } = mount('sys_environment');
276+
277+
const out = await click(result, { ...CREATE_ENV, params: { displayName: 'x' } });
278+
279+
expect(sent().url.endsWith(`${DOOR}sys_environment/create_environment`)).toBe(true);
280+
expect(out).toEqual({ success: false });
281+
expect(onRefresh).not.toHaveBeenCalled();
282+
render(<>{result.current.dialogs}</>);
283+
expect(await screen.findByText('Development environments are a paid feature')).toBeTruthy();
284+
const cta = screen.getByTestId('entitlement-cta-primary');
285+
expect(cta.textContent).toBe('Upgrade plan');
286+
expect(cta.getAttribute('href')).toMatch(/\/settings\/billing$/);
287+
});
288+
289+
it('an exhausted environment pool quotes the usage the door relays', async () => {
290+
answer(403, {
291+
success: false,
292+
error: {
293+
code: 'DEV_ENV_LIMIT',
294+
message: 'Development environment limit reached.',
295+
httpStatus: 403,
296+
details: { current: 3, limit: 3, upgrade_url: '/settings/billing' },
297+
},
298+
});
299+
const { result } = mount('sys_environment');
300+
301+
expect(await click(result, { ...CREATE_ENV, params: { displayName: 'x' } })).toEqual({ success: false });
302+
expect(sent().url.endsWith(`${DOOR}sys_environment/create_environment`)).toBe(true);
303+
render(<>{result.current.dialogs}</>);
304+
expect(await screen.findByText(/You are using 3 of 3 development environments/)).toBeTruthy();
305+
});
306+
307+
it('an approval decision sends its outputs as `outputs.<key>` params, for the door to fold', async () => {
308+
answer(200, { success: true, data: { request: { id: 'req_1', record_title: 'Q4 budget' } } });
309+
const { result } = mount('sys_approval_request');
310+
311+
const out = await click(result, {
312+
...APPROVE,
313+
params: { comment: 'ok', 'outputs.reason': 'within budget', _rowRecord: { id: 'req_1' } },
314+
});
315+
316+
const req = sent();
317+
expect(req.url.endsWith(`${DOOR}sys_approval_request/approval_approve`)).toBe(true);
318+
expect(req.body).toEqual({ recordId: 'req_1', params: { comment: 'ok', 'outputs.reason': 'within budget' } });
319+
expect(out).toEqual({ success: true, data: { request: { id: 'req_1', record_title: 'Q4 budget' } }, reload: true });
320+
});
321+
});

0 commit comments

Comments
 (0)