Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit f962cdc

Browse files
feat(app-shell): the suggested-bindings panel shows each suggestion's state and revokes an acceptance (part of objectui#7611) (#12148)
Part of #7611 Clause-②: yes (widening) **Draft by dispatch** (objectui AGENTS.md §9 ②). This is C9 part 2: the `SuggestedBindingsPanel` follow-up recorded in 6110524912 and claimed by the amendment 6110601135, after objectstack#22854 (stage U2, `d112087b8980`). The epic PM of objectstack#15194 moves this PR. The rest of #7611 stays open; part 1 landed as `0df67f237c5a`. ## What objectui now shows On a framework at objectstack `main` `d112087b8980` or later, a `manage_metadata` holder's accept takes effect for the whole deployment, and a dismiss on an accepted row revokes it (objectstack#22801 → A). The panel on the marketplace package page and in the Studio Access pillar used to list `pending` rows only and dropped `bindingCreated`. It now lists every suggestion of the package (`GET /api/v1/security/suggested-bindings?packageId=…`, no status filter) and draws each in the state the server records: | Row on the wire | The panel shows | Actions | |---|---|---| | `status: pending`, anchor `everyone` | the existing prompt | Accept, Dismiss | | `status: pending`, anchor `guest` | the prompt, and "Cannot be granted here: the Guest position takes no sets from accepted suggestions." | Dismiss only | | `status: confirmed`, `deployment_decision: true` | "SET was accepted for all signed-in users (the Everyone position)." | Revoke | | the same row, after this session's accept answered `200` with `bindingCreated: false` | "SET was accepted but is not in force yet: the Everyone position did not change on this request. The server log says why." | Revoke | | `status: dismissed` | "SET is not granted: the suggestion was dismissed or revoked." | none | | `status: confirmed`, `deployment_decision: false` (the baseline names the set) | not drawn | none | - **Revoke** is the existing dismiss call (`POST …/:id/dismiss`) on an accepted row. The success toast says the acceptance was revoked. - **Accept** shows the existing success toast only when `bindingCreated` is `true`. On `false` it shows the not-in-force line as a warning toast, and the row keeps Revoke, because the acceptance is recorded. `confirmSuggestedBinding` now returns `{ suggestion, bindingCreated }` instead of dropping the flag. - **Refusals.** A `403` or `409` on Accept, Dismiss or Revoke shows the server's own `error.message`, never a silent no-op. The panel then reads the list again, so the row shows what the server holds. - **Kept:** a `403` on the list (a caller without `manage_metadata`) still renders nothing. The list's `.catch` in the panel's mount effect still swallows the refusal. - The accepted line claims the decision, not that the set is in force. The list does not say whether `everyone` carries an accepted set, so "not yet in force" lasts until the panel re-reads. See the acceptance notes. - **Styling.** The panel keeps its amber look while a row awaits a decision. With only decided rows it uses the muted look. Each state has its own Lucide shield icon. ### Why a `guest` row shows the reason instead of rendering nothing The server refuses a `guest` accept with `409`, and still accepts a dismiss of a pending `guest` row. The panel therefore draws no Accept, says why, and keeps Dismiss. This follows the convention part 1 of this card set (`0df67f237c5a`): a caller the server would refuse gets no affordance, and the page says why. It also follows objectui#12109's rule that an affordance the server refuses is not drawn. Rendering nothing would leave a pending row that no surface can show or dismiss. No producer emits a `guest` suggestion today, so this branch is dormant until one does. ### Why a baseline row is not drawn Its dismiss answers `409` ("this surface does not decide the baseline"), and its confirm answers `409` too. No action on it can succeed, and nothing is pending. Before this change the panel did not draw it either. ## Measured against objectstack `main` The measurement booted a showcase from a detached objectstack worktree at `55382dc02a` (its `origin/main`, which contains `d112087b8980`). It ran through a throwaway, untracked dogfood-style file, deleted after the run, so `git status` in that worktree is clean. The driver was `sqlite-wasm`. Readings, in order: - With a vanilla `SecurityPlugin` (so `showcase_member_default` is a package suggestion), the list row is `pending`, with `deployment_decision: false`, `resolved_by: null` and `anchor: "everyone"`. - The accept answers `200`, `{ data: { suggestion, bindingCreated: true } }`. The suggestion is `confirmed` with `deployment_decision: true` and `resolved_by` set. - **The list after the accept returns the same row: `status: "confirmed"`, `deployment_decision: true` (a JSON boolean) and `resolved_by` set.** It has no field that says whether the set is in force. - A second accept answers `409 SUGGESTION_STATE`: "… is already confirmed — only pending suggestions can be accepted." - The dismiss on the accepted row answers `200`, and the suggestion is `dismissed` with `deployment_decision: true`. The list then shows the same. **A revoked row and a dismissed pending row are identical on the wire.** - A second dismiss answers `409`: "… is already dismissed — only a pending or accepted suggestion can be dismissed." An accept on that row answers `409` ("… only pending suggestions can be accepted"). - A signed-up member's list answers `403 PERMISSION_DENIED`: "[Security] Access denied: listing audience-binding suggestions requires the `manage_metadata` capability. …" - On the stock showcase, the same set is a baseline row: `confirmed`, `deployment_decision: false`, `resolved_by: null`. Its dismiss answers `409`: "… is observed, not accepted: the deployment's baseline gives the set to everyone, and this surface does not decide the baseline." `bindingCreated: false` on a `200` could not be produced through the public door on that boot: the confirm and the derivation ask the same gates. It is NOT MEASURED live here. objectstack pins it in its unit suite, and #22854's ablation showed the flag turning `false`. The panel's handling is pinned below against the documented envelope. ## Tests The new `packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx` has 15 cases. It renders the real panel and the real `suggestedBindingsApi` client over a stubbed `fetch` that answers the server's envelopes, with row shapes and messages taken from the measurement above. There was no test of this component before. The file follows app-shell's convention for a component with a stubbed transport: `vi.stubGlobal('fetch', …)`, `sonner` mocked, and `@object-ui/components` imported at module scope. - One test per state: pending, accepted, dismissed or revoked, baseline not drawn (with a control that a baseline-only panel renders nothing), and `guest` (no Accept, the reason shown, Dismiss kept). One test pins that a `403` on the list renders nothing, and one that the list asks for every status. - One test per action: Accept with `bindingCreated: true`; Accept with `bindingCreated: false` (not in force, a warning and no success toast); Dismiss; Revoke (the dismiss route, never confirm); Revoke refused `409` (the message is shown, the list is re-read, and the row takes the server's state); Accept refused `403`; and Revoke refused `403` followed by a list `403` (the message is shown, then the panel renders nothing). **Ablation** (one-time, not kept). Each mutation went through objectstack's `scripts/ablation-replace.mjs`, which checks that the anchor hit and that the restore holds. Each restore read blob == HEAD with `git diff HEAD` empty. - `bindingCreated: data?.bindingCreated === true` → `bindingCreated: true`: exactly the not-in-force test went red (1 failed, 14 passed). - `return s.anchor === 'guest' ? 'guest' : 'pending';` → `return 'pending';`: exactly the `guest` test went red (1 failed, 14 passed). - `s.status === 'confirmed' && s.deployment_decision === true` → `s.status === 'confirmed'`: exactly the two baseline tests went red (2 failed, 13 passed). **At head `8d3b3a0eb`** (all runs under the shared verify lock): - `pnpm exec vitest run` over the new file, `packages/app-shell/src/console/marketplace/` (its 29 test files mock the panel), the three Studio Access-pillar suites, `studio-locale.i18n`, `select-placeholder-literal-11252`, all of `packages/i18n/`, and the three i18n script tests: **120 files passed, 1812 tests passed, 13 skipped**. The skips are pre-existing and none is in the new file. - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check`, which is `tsc --noEmit` plus `tsc -p tsconfig.test.json`: both `Done`. `--listFiles` on app-shell's test project lists the new test file once. - `packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts` went red on the first run. It pins the de census, and the five new de values add seven matched `„…“` spans. The census is moved 77 → 84 with `rdq` 0, and a history line is added like those before it. **Narrowed, and declared.** The whole of `packages/app-shell/` (1,271 test files) was started at `--maxWorkers=2`. After 19 minutes it had not finished, and a sibling's build was queued behind it. I stopped it before it printed a result, so that run is NOT MEASURED. CI's `pnpm test` runs the whole package. The diff changes no export and no shared module outside the panel, its client, the two mounts' string objects and the locale tables. ## Gates At `8d3b3a0eb`, each exit 0: `check-i18n-call-site-keys`, `check-i18n-en-drift`, `check-i18n-designer-table-parity`, `check-control-bytes`, `check-new-cross-file-line-citations` (0 new), `check-changeset-presence` (17 source files of 2 released packages, 1 changeset), `check-changeset-no-major`, `check-vi-mock-override-shape`, `check-test-path-roots`. `check-i18n-dead-keys` is report-only and names none of the new keys. A control-byte self-scan over the diff found nothing. **ESLint** (`--no-inline-config`, `--format json`) over the 17 touched TS files reports 1 error and 29 warnings. The base file under the same flags reads the same: `StudioDesignSurface.tsx` 1 error and 14 warnings, `MarketplacePackagePage.tsx` 0 and 11, `suggestedBindingsApi.ts` 0 and 4. The error is the pre-existing `react-hooks/static-components` finding in that file, far from this diff. The panel goes from 1 warning to 0. The population is each package's own `eslint .` under the root `eslint.config.js`. That config enables no type-aware linting, so this diff cannot change a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. **NOT MEASURED locally**: `check-eager-closure-budget` and `check-eager-locale-catalogues`, which need a full console build and are CI's (`performance-budget.yml`). The `en` pack grows by seven short values. A browser drive of the panel is also not measured: a stock showcase has no pending suggestion for the panel to draw, and a vanilla-plugin boot needs an objectstack configuration change this card does not make. The wire was measured instead, above. ## Changeset `.changeset/7611-suggested-binding-states.md`: `@object-ui/app-shell` minor (a new user-visible action) and `@object-ui/i18n` minor. **Clause-②: yes (widening)**: seven locale keys are added to the published packs (`marketplace.suggestedBindings.guestUnsupported`, `acceptedState`, `notInForceState`, `dismissedState`, `revoke`, `revoking`, `revokedToast`), which widens `TranslationKeys`. No export, prop or accept set moves. The panel and its client are not exported from `@object-ui/app-shell`. The Studio Access pillar's own table gains the matching seven `engine.studio.access.suggest…` keys in English and Chinese. No doc names this panel: a grep of `content/` and every `*.md` outside changesets and changelogs finds nothing, so no doc is updated. ## Acceptance notes (out of scope; carrier: none named) - **The list cannot say whether an accepted set is in force.** An accepted row reads the same whether `everyone` carries the set or the derivation withholds it. A withhold happens when a later publish adds a forbidden bit, or the catalog stops serving the set. So the panel's accepted line claims the decision only, and "not yet in force" survives only until the panel re-reads. Closing that would take a server field on the list row. That is an objectstack contract question, not an objectui fix. - **A revoke is final on this surface.** A revoked row is `dismissed` with a deployment decision. The reconcile never re-opens such a row, and confirm takes `pending` only. Re-accepting a revoked set therefore has no route here, which the measurement above shows (`409` on confirm after a revoke). Dismissing a pending row was already final before #22854. - A revoked row and a declined row are the same on the wire, so after a re-read the panel says "dismissed or revoked" for both. ## Size Against base `158c75d` (`git diff --shortstat 158c75d 8d3b3a0`): 18 files, 667 additions and 79 deletions, so 746 changed lines. The new test file is 288 of them, and the ten locale packs are 70. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7d4920 commit f962cdc

18 files changed

Lines changed: 667 additions & 79 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
'@object-ui/i18n': minor
4+
---
5+
6+
The suggested-bindings panel shows each suggestion's real state and can revoke an acceptance (objectui#7611, after objectstack#22854).
7+
8+
On a framework where a `manage_metadata` holder's accept takes effect for the whole deployment (objectstack#22801 → A), the panel on the marketplace package page and in the Studio Access pillar listed pending suggestions only, and an accept was reported as granted whatever the server answered. It now lists every suggestion of the package and draws each one in the state the server records:
9+
10+
- **Pending.** Accept and Dismiss, as before.
11+
- **Pending on the `guest` anchor.** No Accept: the server refuses it with `409`, because the Guest position takes no sets from accepted suggestions. The reason is shown in its place, and Dismiss stays.
12+
- **Accepted.** A Revoke action, which is the existing dismiss call on an accepted row: the server re-derives Everyone without the set. The line says the set was accepted; it does not claim the set is in force, because the list does not say so.
13+
- **Accepted, not yet in force.** An accept answered `200` with `bindingCreated: false` was recorded, but the Everyone position did not change on that request. The panel says so, with a warning toast in place of the success toast, and keeps Revoke. The list does not carry this, so after a reload the row reads as accepted.
14+
- **Dismissed or revoked.** The server records both as `dismissed`. The row says the set is not granted, and offers no action.
15+
16+
A suggestion the deployment's baseline already names (`confirmed` without a deployment decision) is not drawn: this surface does not decide the baseline. A `403` or `409` on Accept, Dismiss or Revoke shows the server's own message, and the panel then reads the list again so it shows what the server holds. A caller without `manage_metadata` is refused the list with `403`, and the panel still renders nothing for them.
17+
18+
**Locale keys.** `marketplace.suggestedBindings.guestUnsupported`, `acceptedState`, `notInForceState`, `dismissedState`, `revoke`, `revoking` and `revokedToast`, in all ten packs. The Studio Access pillar's own table gains the matching `engine.studio.access.suggest…` keys in English and Chinese.
19+
20+
**Clause-②: yes (widening)**: seven locale keys added to `@object-ui/i18n`'s published packs, which widens `TranslationKeys`. No export, prop or accept set moves.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `SuggestedBindingsPanel` after objectstack#22854 (#22801 → A): an accept by a
5+
* `manage_metadata` holder takes effect for the whole deployment, a dismiss on
6+
* an accepted row revokes it, a `guest` row cannot be accepted, and a caller
7+
* without the authority is refused the list (objectui#7611, C9 part 2).
8+
*
9+
* Pinned here, one test per state and per action:
10+
*
11+
* - the states a row is drawn in — pending, accepted, accepted but not yet
12+
* in force (a `200` answered `bindingCreated: false`), dismissed or
13+
* revoked — and the one it is not drawn in (a baseline row, `confirmed`
14+
* without a deployment decision, which this surface does not decide);
15+
* - a `guest` row offers no Accept and says why, and keeps Dismiss;
16+
* - Accept, Dismiss and Revoke each call their route, and the row takes the
17+
* state the server answered;
18+
* - a `403` or `409` on an action shows the server's own message, never a
19+
* silent no-op, and the panel then re-reads the list;
20+
* - a `403` on the list renders nothing.
21+
*
22+
* Real subjects: the panel and the real `suggestedBindingsApi` client over a
23+
* stubbed `fetch` answering the server's envelopes (`{ data: { suggestions,
24+
* synced } }`, `{ data: { suggestion, bindingCreated } }`, `{ data: {
25+
* suggestion } }`, and ADR-0112's `{ error: { code, message } }`). Row shapes
26+
* are the ones the list route answers at objectstack `main` (measured on the
27+
* PR). `sonner` is mocked so the toasts can be read.
28+
*/
29+
30+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
31+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
32+
// Imported at module scope: `@object-ui/components` is a heavy barrel, and
33+
// resolving it mid-assertion races the RTL timeouts (AGENTS.md § 测试纪律).
34+
import '@object-ui/components';
35+
36+
vi.mock('sonner', () => ({
37+
toast: Object.assign(vi.fn(), {
38+
success: vi.fn(), warning: vi.fn(), error: vi.fn(), info: vi.fn(),
39+
}),
40+
}));
41+
42+
import { toast } from 'sonner';
43+
import { SuggestedBindingsPanel, type SuggestedBindingsStrings } from './SuggestedBindingsPanel';
44+
import type { SuggestedBinding } from '../services/suggestedBindingsApi';
45+
46+
const PKG = 'com.acme.crm';
47+
48+
function row(overrides: Partial<SuggestedBinding> & { permission_set_name: string }): SuggestedBinding {
49+
return {
50+
id: `sug_${overrides.permission_set_name}`,
51+
package_id: PKG,
52+
anchor: 'everyone',
53+
status: 'pending',
54+
deployment_decision: false,
55+
resolved_by: null,
56+
resolved_at: null,
57+
...overrides,
58+
};
59+
}
60+
61+
const STRINGS: SuggestedBindingsStrings = {
62+
describe: (s) => `PROMPT ${s.anchor} ${s.permission_set_name}`,
63+
confirm: 'Accept',
64+
confirming: 'Accepting',
65+
dismiss: 'Dismiss',
66+
confirmedToast: (s) => `GRANTED ${s.permission_set_name}`,
67+
dismissedToast: (s) => `DISMISSED ${s.permission_set_name}`,
68+
guestUnsupported: 'GUEST CANNOT BE ACCEPTED',
69+
acceptedState: (s) => `ACCEPTED ${s.permission_set_name}`,
70+
notInForceState: (s) => `NOT IN FORCE ${s.permission_set_name}`,
71+
dismissedState: (s) => `NOT GRANTED ${s.permission_set_name}`,
72+
revoke: 'Revoke',
73+
revoking: 'Revoking',
74+
revokedToast: (s) => `REVOKED ${s.permission_set_name}`,
75+
};
76+
77+
type Answer = { status: number; body: unknown };
78+
const ok = (data: unknown): Answer => ({ status: 200, body: { data } });
79+
const refused = (status: number, code: string, message: string): Answer => ({
80+
status, body: { error: { code, message } },
81+
});
82+
const listed = (suggestions: SuggestedBinding[]): Answer =>
83+
ok({ suggestions, synced: { created: 0, confirmedObserved: 0, pruned: 0, reopened: 0, refused: 0 } });
84+
85+
/**
86+
* A `fetch` that answers each route from a queue (the last answer repeats),
87+
* and records every call as `METHOD path`.
88+
*/
89+
function serve(routes: { list: Answer[]; confirm?: Answer[]; dismiss?: Answer[] }) {
90+
const calls: string[] = [];
91+
const next = (q: Answer[] | undefined): Answer => {
92+
if (!q || q.length === 0) throw new Error('unexpected route');
93+
return q.length > 1 ? q.shift()! : q[0];
94+
};
95+
const fetchStub = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
96+
const url = new URL(String(input), 'http://localhost');
97+
const method = (init?.method ?? 'GET').toUpperCase();
98+
calls.push(`${method} ${url.pathname}${url.search}`);
99+
const answer = url.pathname.endsWith('/confirm')
100+
? next(routes.confirm)
101+
: url.pathname.endsWith('/dismiss')
102+
? next(routes.dismiss)
103+
: next(routes.list);
104+
return new Response(JSON.stringify(answer.body), {
105+
status: answer.status,
106+
headers: { 'Content-Type': 'application/json' },
107+
});
108+
});
109+
vi.stubGlobal('fetch', fetchStub);
110+
return calls;
111+
}
112+
113+
const panel = () => render(<SuggestedBindingsPanel packageId={PKG} strings={STRINGS} />);
114+
const rowEl = (set: string) => screen.findByTestId(`suggested-binding-${set}`);
115+
116+
beforeEach(() => { vi.clearAllMocks(); });
117+
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
118+
119+
describe('SuggestedBindingsPanel — the state of each row (objectui#7611)', () => {
120+
it('lists every status for the package, not pending only', async () => {
121+
const calls = serve({ list: [listed([row({ permission_set_name: 'crm_reader' })])] });
122+
panel();
123+
await rowEl('crm_reader');
124+
expect(calls).toEqual([`GET /api/v1/security/suggested-bindings?packageId=${encodeURIComponent(PKG)}`]);
125+
});
126+
127+
it('a pending row offers Accept and Dismiss', async () => {
128+
serve({ list: [listed([row({ permission_set_name: 'crm_reader' })])] });
129+
panel();
130+
const el = await rowEl('crm_reader');
131+
expect(el.dataset.state).toBe('pending');
132+
expect(el.textContent).toContain('PROMPT everyone crm_reader');
133+
expect(screen.getByTestId('suggested-binding-confirm-crm_reader')).toBeTruthy();
134+
expect(screen.getByTestId('suggested-binding-dismiss-crm_reader')).toBeTruthy();
135+
expect(screen.queryByTestId('suggested-binding-revoke-crm_reader')).toBeNull();
136+
});
137+
138+
it('an accepted row (confirmed by a deployment decision) offers Revoke and no Accept', async () => {
139+
serve({ list: [listed([row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' })])] });
140+
panel();
141+
const el = await rowEl('crm_reader');
142+
expect(el.dataset.state).toBe('accepted');
143+
expect(el.textContent).toContain('ACCEPTED crm_reader');
144+
expect(screen.getByTestId('suggested-binding-revoke-crm_reader')).toBeTruthy();
145+
expect(screen.queryByTestId('suggested-binding-confirm-crm_reader')).toBeNull();
146+
expect(screen.queryByTestId('suggested-binding-dismiss-crm_reader')).toBeNull();
147+
});
148+
149+
it('a dismissed or revoked row says it is not granted and offers no action', async () => {
150+
serve({ list: [listed([row({ permission_set_name: 'crm_reader', status: 'dismissed', deployment_decision: true, resolved_by: 'u_admin' })])] });
151+
panel();
152+
const el = await rowEl('crm_reader');
153+
expect(el.dataset.state).toBe('dismissed');
154+
expect(el.textContent).toContain('NOT GRANTED crm_reader');
155+
expect(el.querySelectorAll('button')).toHaveLength(0);
156+
});
157+
158+
it('a baseline row (confirmed, no deployment decision) is not drawn — this surface does not decide the baseline', async () => {
159+
serve({
160+
list: [listed([
161+
row({ permission_set_name: 'baseline_set', status: 'confirmed', deployment_decision: false }),
162+
row({ permission_set_name: 'crm_reader' }),
163+
])],
164+
});
165+
panel();
166+
await rowEl('crm_reader');
167+
expect(screen.queryByTestId('suggested-binding-baseline_set')).toBeNull();
168+
});
169+
170+
it('CONTROL for the above: a panel holding only a baseline row renders nothing', async () => {
171+
const calls = serve({ list: [listed([row({ permission_set_name: 'baseline_set', status: 'confirmed', deployment_decision: false })])] });
172+
const { container } = panel();
173+
await waitFor(() => expect(calls).toHaveLength(1));
174+
await Promise.resolve();
175+
expect(container.innerHTML).toBe('');
176+
});
177+
178+
it('a guest row offers no Accept, says why, and keeps Dismiss', async () => {
179+
serve({ list: [listed([row({ permission_set_name: 'site_visitor', anchor: 'guest' })])] });
180+
panel();
181+
const el = await rowEl('site_visitor');
182+
expect(el.dataset.state).toBe('guest');
183+
expect(el.textContent).toContain('PROMPT guest site_visitor');
184+
expect(el.textContent).toContain('GUEST CANNOT BE ACCEPTED');
185+
expect(screen.queryByTestId('suggested-binding-confirm-site_visitor')).toBeNull();
186+
expect(screen.getByTestId('suggested-binding-dismiss-site_visitor')).toBeTruthy();
187+
});
188+
189+
it('a 403 on the list renders nothing (a caller without manage_metadata)', async () => {
190+
const calls = serve({ list: [refused(403, 'PERMISSION_DENIED', '[Security] Access denied: listing audience-binding suggestions requires the `manage_metadata` capability.')] });
191+
const { container } = panel();
192+
await waitFor(() => expect(calls).toHaveLength(1));
193+
await Promise.resolve();
194+
expect(container.innerHTML).toBe('');
195+
});
196+
});
197+
198+
describe('SuggestedBindingsPanel — the actions (objectui#7611)', () => {
199+
it('Accept answered bindingCreated: true → the row is accepted, with Revoke, and the success toast', async () => {
200+
const pending = row({ permission_set_name: 'crm_reader' });
201+
const accepted = { ...pending, status: 'confirmed' as const, deployment_decision: true, resolved_by: 'u_admin' };
202+
const calls = serve({ list: [listed([pending])], confirm: [ok({ suggestion: accepted, bindingCreated: true })] });
203+
panel();
204+
fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_reader'));
205+
await screen.findByTestId('suggested-binding-revoke-crm_reader');
206+
expect((await rowEl('crm_reader')).dataset.state).toBe('accepted');
207+
expect(calls).toContain('POST /api/v1/security/suggested-bindings/sug_crm_reader/confirm');
208+
expect(toast.success).toHaveBeenCalledWith('GRANTED crm_reader');
209+
expect(toast.warning).not.toHaveBeenCalled();
210+
});
211+
212+
it('Accept answered 200 with bindingCreated: false → accepted but NOT in force, a warning and no success toast', async () => {
213+
const pending = row({ permission_set_name: 'crm_reader' });
214+
const accepted = { ...pending, status: 'confirmed' as const, deployment_decision: true, resolved_by: 'u_admin' };
215+
serve({ list: [listed([pending])], confirm: [ok({ suggestion: accepted, bindingCreated: false })] });
216+
panel();
217+
fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_reader'));
218+
await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('notInForce'));
219+
expect((await rowEl('crm_reader')).textContent).toContain('NOT IN FORCE crm_reader');
220+
expect(toast.warning).toHaveBeenCalledWith('NOT IN FORCE crm_reader');
221+
expect(toast.success).not.toHaveBeenCalled();
222+
// The acceptance is recorded, so it can be revoked.
223+
expect(screen.getByTestId('suggested-binding-revoke-crm_reader')).toBeTruthy();
224+
});
225+
226+
it('Dismiss on a pending row calls dismiss, and the row says it is not granted', async () => {
227+
const pending = row({ permission_set_name: 'crm_reader' });
228+
const dismissed = { ...pending, status: 'dismissed' as const, deployment_decision: true, resolved_by: 'u_admin' };
229+
const calls = serve({ list: [listed([pending])], dismiss: [ok({ suggestion: dismissed })] });
230+
panel();
231+
fireEvent.click(await screen.findByTestId('suggested-binding-dismiss-crm_reader'));
232+
await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed'));
233+
expect(calls).toContain('POST /api/v1/security/suggested-bindings/sug_crm_reader/dismiss');
234+
expect(toast.success).toHaveBeenCalledWith('DISMISSED crm_reader');
235+
});
236+
237+
it('Revoke on an accepted row is the dismiss call; the row says it is not granted and the revoke toast shows', async () => {
238+
const accepted = row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' });
239+
const revoked = { ...accepted, status: 'dismissed' as const };
240+
const calls = serve({ list: [listed([accepted])], dismiss: [ok({ suggestion: revoked })] });
241+
panel();
242+
fireEvent.click(await screen.findByTestId('suggested-binding-revoke-crm_reader'));
243+
await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed'));
244+
expect(calls).toContain('POST /api/v1/security/suggested-bindings/sug_crm_reader/dismiss');
245+
expect(calls.some((c) => c.endsWith('/confirm'))).toBe(false);
246+
expect(toast.success).toHaveBeenCalledWith('REVOKED crm_reader');
247+
});
248+
249+
it('Revoke refused 409 → the server message is shown, and the panel re-reads and shows what the server holds', async () => {
250+
const accepted = row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' });
251+
const elsewhere = { ...accepted, status: 'dismissed' as const };
252+
const message = "Audience-binding suggestion 'sug_crm_reader' is already dismissed — only a pending or accepted suggestion can be dismissed.";
253+
const calls = serve({
254+
list: [listed([accepted]), listed([elsewhere])],
255+
dismiss: [refused(409, 'SUGGESTION_STATE', message)],
256+
});
257+
panel();
258+
fireEvent.click(await screen.findByTestId('suggested-binding-revoke-crm_reader'));
259+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
260+
await waitFor(async () => expect((await rowEl('crm_reader')).dataset.state).toBe('dismissed'));
261+
expect(calls.filter((c) => c.startsWith('GET '))).toHaveLength(2);
262+
expect(toast.success).not.toHaveBeenCalled();
263+
});
264+
265+
it('Accept refused 403 (a bit the everyone anchor forbids) → the server message is shown and the row stays pending', async () => {
266+
const pending = row({ permission_set_name: 'crm_risky' });
267+
const message = "[Security] Access denied: permission set 'crm_risky' cannot be given to the 'everyone' audience anchor — it carries viewAllRecords on crm_account (ADR-0090 D5/D7).";
268+
serve({ list: [listed([pending])], confirm: [refused(403, 'PERMISSION_DENIED', message)] });
269+
panel();
270+
fireEvent.click(await screen.findByTestId('suggested-binding-confirm-crm_risky'));
271+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
272+
expect((await rowEl('crm_risky')).dataset.state).toBe('pending');
273+
expect(toast.success).not.toHaveBeenCalled();
274+
});
275+
276+
it('Revoke refused 403 and the re-read refused 403 → the message is shown, then the panel renders nothing', async () => {
277+
const accepted = row({ permission_set_name: 'crm_reader', status: 'confirmed', deployment_decision: true, resolved_by: 'u_admin' });
278+
const message = '[Security] Access denied: dismissing an audience-binding suggestion requires the `manage_metadata` capability.';
279+
serve({
280+
list: [listed([accepted]), refused(403, 'PERMISSION_DENIED', message)],
281+
dismiss: [refused(403, 'PERMISSION_DENIED', message)],
282+
});
283+
const { container } = panel();
284+
fireEvent.click(await screen.findByTestId('suggested-binding-revoke-crm_reader'));
285+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
286+
await waitFor(() => expect(container.innerHTML).toBe(''));
287+
});
288+
});

0 commit comments

Comments
 (0)