Skip to content

Commit 77e993e

Browse files
fix(app-shell): a member Setup refuses gets the app's no-access page on Setup's settings and marketplace routes (#27590)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgHGZA9M9n8sPRHZcTX4oAiQ)_ Fixes #27507 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:没有 Setup 权限的普通成员打开 Setup 的设置页(`system/settings`、`system/settings/NAMESPACE`)和应用市场(`system/marketplace`、`system/marketplace/PACKAGE`)时,不再落进默认应用(如 Showcase)的外壳里看「No settings registered」或原始权限报错,而是和打开 Setup 根路由一样,看到「You don't have access to this app … Back to home」。 - **为什么改**:这些路由带 `system` 段,属于伪路由,过去不等待 Setup 的访问裁决,直接回落到默认应用;三处页面各自解读服务端的拒绝,出了三种说法。分诊裁定:一律走应用根已有的无权限页,不新增文案。 - **风险与回滚**:只改前端外壳的路由判断(一个谓词),服务端拒绝逻辑不变;管理员和能打开该应用的用户行为不变;审批收件箱及其记录页(objectui#12706 留下的开放路由)不受影响。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/console/AppContent.tsx` 中 `isSettingsOrMarketplaceRoute` 与 `appSurfaceAwaitsVerdict` 两行;然后是新测试 `AppContent.settingsMarketplaceVerdict-27507.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/console/__tests__/AppContent.settingsMarketplaceVerdict-27507.test.tsx`,预期 11 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes `AppContent` (app-shell) already waits on the access verdict for the app the URL names on two pseudo-route families, Setup's root and the metadata-admin routes (objectui#12706), and draws `renderAppAccessDenied` on `denied`. This adds a third family to `appSurfaceAwaitsVerdict`: a route whose sub-path is `system/settings...` or `system/marketplace...` (`isSettingsOrMarketplaceRoute`). Nothing else moves: - The gate still applies only when the URL names an app the loaded list does not carry (`!matchedApp`), so an admin, and any caller under an app they may open, render the pages exactly as before, unprobed. - Every verdict other than `denied` (`not_found`, `unreachable`, `unknown`, `granted`) falls through to the pseudo-route rendering, unchanged. - The routes objectui#12706 deliberately left open stay open: the approvals inbox (`system/approvals`, where the server's approval links forward) and the record pages it opens (`OBJECT/record/ID`). Their pins in `AppContent.setupItemRoutesVerdict-12706.test.tsx` are untouched and green. - The bare `system` landing is not in the set: it forwards onto `system/settings` (the console host's `SystemLandingRedirect`), which is refused. A pin holds that chain. - No inner component changed. The settings hub's empty state, `SettingsView`'s error text and `MarketplaceAccessDenied` are unreachable for a caller Setup refuses, and stay as they were for a caller Setup admits. - No server change. The server already refuses each read; this is presentation only. Changeset: `@object-ui/app-shell` patch. ## Tests New `ui/packages/app-shell/src/console/__tests__/AppContent.settingsMarketplaceVerdict-27507.test.tsx`, on the 12706 file's harness (a real `ObjectStackAdapter` over a stubbed `fetch`, so the verdict comes from the real probe reading a real 403 envelope; page stubs count their mounts): - A refused member on the three settings routes the card names (the hub, `settings/auth`, `settings/localization`), on the marketplace it names, and on a marketplace package page: `app-access-denied`, no `ConsoleLayout`, zero page mounts, exactly one probe and it is Setup's, URL unchanged, the Back-to-home button present. - Setup's package-id address (`/apps/com.objectstack.setup/system/settings/auth`): same verdict, probed by that address. - `/apps/setup/system`: forwards to the hub and is refused there. - Controls: an admitted caller gets the settings page and the marketplace under Setup, unprobed; a server with no such app (404) keeps the old fallback rendering; the settings route under the caller's own app renders the page, unprobed. Ablation (the predicate disabled via `scripts/ablation-replace.mjs`, anchor hit 1 to 0, restored to the HEAD blob with `git diff HEAD` empty): 7 of 11 cases red, the 4 controls green. ## Acceptance notes - Not filed, observation only: a caller Setup admits but who lacks `manage_platform_settings` can still type `/apps/setup/system/settings/auth` and see `SettingsView` print the server's error message verbatim. The hub does not list that namespace for them, so it is reachable only by URL; not reproduced in a browser here. - Not in scope and left as they were: the other Setup `system/*` routes the card does not name (`system/apps`, `system/audit-log`, `system/ai-approvals`, `system/profile`). Whether they wait on Setup's verdict is the same product question objectui#12706 left open for the approvals routes. - Home's marketplace shortcuts already show only to admins, so a member reaches the marketplace route only by URL or an old link. ## Verification <details><summary>Targeted tests and typecheck (head after the main merge)</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/console/__tests__/ apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx`: Test Files 58 passed (58), Tests 408 passed (408). - `pnpm --filter @object-ui/app-shell run type-check`: exit 0; the new test file is in `tsconfig.test.json`'s program (`--listFilesOnly` count 1). - `eslint` on the two touched source files: 0 errors; no warning on a touched line. </details> <details><summary>Derived gates</summary> At head `244ec6aa`, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 derived; each run and recorded, reconciled with `--ran`: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". Plus `check:new-line-citations` (0 new citations) and `check-nul-bytes` (exit 0). ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZ9nhZe8YouxkXECUs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4e7014 commit 77e993e

3 files changed

Lines changed: 352 additions & 1 deletion

File tree

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(app-shell): a member the Setup app refuses gets the app's no-access page on Setup's settings and marketplace routes, not the default app's chrome around a misleading empty state or a raw capability error
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Opening `/apps/setup/system/settings`, `/apps/setup/system/settings/NAMESPACE`, `/apps/setup/system/marketplace` or `/apps/setup/system/marketplace/PACKAGE` without access to Setup used to fall back to the default app: the settings hub read the server's filtered, empty manifest list as "No settings registered", a settings page printed the server's capability error verbatim, and the marketplace drew its own admin-only notice. These routes now wait on Setup's access verdict, as Setup's root and the metadata routes already do, and draw the same "You don't have access to this app" page with its way back home. The server's refusals are unchanged; an administrator, and a caller under any app they may open, see the pages as before. The approvals inbox and the record pages it links to stay reachable under the Setup segment.
11+
12+
What to do: nothing.

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,8 +505,21 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
505505
// `recordHref`). A member reaches both under the Setup segment, so whether
506506
// they wait on Setup's verdict is a product decision, left open on
507507
// objectui#12706.
508+
//
509+
// objectstack#27507 — two more wait on it: the settings pages
510+
// (`system/settings`, `system/settings/:namespace`, the console host's) and
511+
// the marketplace (`system/marketplace[/:packageId]`, this file's). Without
512+
// the verdict a member opening them got the default app's chrome around the
513+
// page's own reading of the server's refusal: an empty settings hub ("No
514+
// settings registered"), the raw capability error on a settings page, the
515+
// marketplace's admin-only notice. Each now reads as the app root does; the
516+
// pages' inner states stay for a caller the app lets in.
508517
const isSetupRootRoute = location.pathname === '/apps/setup' || location.pathname === '/apps/setup/';
509-
const appSurfaceAwaitsVerdict = !!appName && !matchedApp && (isMetadataRoute || isSetupRootRoute);
518+
const [appSubRoot, appSubPage] = appSubPath.split('/');
519+
const isSettingsOrMarketplaceRoute =
520+
appSubRoot === 'system' && (appSubPage === 'settings' || appSubPage === 'marketplace');
521+
const appSurfaceAwaitsVerdict =
522+
!!appName && !matchedApp && (isMetadataRoute || isSetupRootRoute || isSettingsOrMarketplaceRoute);
510523

511524
// Post-publish readiness: when a requested app isn't in the loaded metadata,
512525
// re-check ONCE (the registry can lag a beat behind a publish) before
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A caller the Setup app refuses gets the app's access verdict on Setup's
5+
* settings and marketplace pages too (objectstack#27507), the screen Setup's
6+
* root already draws (objectui#12706).
7+
*
8+
* ## The measured defect
9+
*
10+
* A plain member opening `/apps/setup/system/settings` got the default app's
11+
* chrome around the settings hub, which read the server's empty, filtered
12+
* manifest list as "No settings registered"; `/apps/setup/system/settings/auth`
13+
* printed the server's raw capability error under the same chrome; the
14+
* marketplace drew its own admin-only notice. `/apps/com.objectstack.setup`
15+
* already drew `app-access-denied`. The server refused every read, so it was
16+
* three wrong screens, not an access hole.
17+
*
18+
* The cause: these routes carry a `system` segment, so they are pseudo-routes
19+
* (`isSpecialRoute`) and skip the verdict the way the rest of Setup's subtree
20+
* did before objectui#12706, which gated only the metadata routes and the root.
21+
*
22+
* ## What is pinned
23+
*
24+
* The settings and marketplace routes wait on the verdict for the app the URL
25+
* names and draw `app-access-denied` on `denied`, nothing of the page mounted.
26+
* An admitted caller, an app the server does not have, and an ordinary app's
27+
* own settings route render as before. The routes objectui#12706 left open
28+
* (the approvals inbox and the records it links to) stay pinned in
29+
* `AppContent.setupItemRoutesVerdict-12706.test.tsx`, which this change keeps
30+
* green unchanged.
31+
*
32+
* ## Why the route is stubbed at the TRANSPORT
33+
*
34+
* As in `AppContent.setupItemRoutesVerdict-12706.test.tsx`: a real
35+
* `ObjectStackAdapter` over a stubbed `fetch`, so the verdict comes from the
36+
* real probe reading the real envelope.
37+
*
38+
* NOTE ON SCOPE: this file measures WHICH SURFACE renders. `ConsoleLayout`,
39+
* the host's settings pages and the marketplace pages are stubbed; each stub
40+
* counts its mounts, so "nothing of the page mounted" is a count.
41+
*/
42+
43+
import '@testing-library/jest-dom/vitest';
44+
import { describe, it, expect, vi, beforeEach } from 'vitest';
45+
import React from 'react';
46+
import { render, screen } from '@testing-library/react';
47+
import { MemoryRouter, Navigate, Routes, Route, useLocation, useParams } from 'react-router-dom';
48+
import { ObjectStackAdapter } from '@object-ui/data-objectstack';
49+
50+
// ---------------------------------------------------------------------------
51+
// Mocks — everything that takes part in the DECISION stays real.
52+
// ---------------------------------------------------------------------------
53+
54+
/** How many times each stubbed page mounted, by test id. */
55+
const mounts: Record<string, number> = {};
56+
const counted = (testId: string, text?: string) => {
57+
mounts[testId] = (mounts[testId] ?? 0) + 1;
58+
return <div data-testid={testId}>{text}</div>;
59+
};
60+
61+
// AGENTS.md §测试纪律 — the lazy page modules are stubbed so no unbounded
62+
// `import()` races a bounded `findBy` window.
63+
vi.mock('../marketplace/MarketplacePage', () => ({
64+
MarketplacePage: () => counted('marketplace-page'),
65+
}));
66+
vi.mock('../marketplace/MarketplacePackagePage', () => ({
67+
MarketplacePackagePage: () => {
68+
const { packageId } = useParams<{ packageId?: string }>();
69+
return counted('marketplace-package-page', packageId);
70+
},
71+
}));
72+
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
73+
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
74+
CreateAppPage: () => <div data-testid="create-app-page" />,
75+
EditAppPage: () => <div data-testid="edit-app-page" />,
76+
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
77+
}));
78+
79+
vi.mock('../../layout/ConsoleLayout', () => ({
80+
ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => (
81+
<div data-testid="console-layout" data-active-app={activeAppName}>
82+
<header>chrome</header>
83+
{children}
84+
</div>
85+
),
86+
}));
87+
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
88+
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
89+
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
90+
91+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
92+
...(await importOriginal<Record<string, unknown>>()),
93+
useObjectTranslation: () => ({
94+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
95+
}),
96+
useObjectLabel: () => ({
97+
objectLabel: ({ label }: { label?: string }) => label,
98+
objectPluralLabel: ({ label }: { label?: string }) => label,
99+
appLabel: ({ name, label }: { name: string; label?: string }) => label ?? name,
100+
}),
101+
}));
102+
103+
vi.mock('@object-ui/auth', async (importOriginal) => ({
104+
...(await importOriginal<Record<string, unknown>>()),
105+
useAuth: () => ({
106+
user: { id: 'u_member', name: 'Member', email: 'member@example.com', role: 'member' },
107+
getAuthConfig: async () => ({ features: {} }),
108+
activeOrganization: { id: 'org_1', name: 'Org' },
109+
}),
110+
// The least-privileged value, so nothing here is an admin-only result.
111+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
112+
}));
113+
114+
const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) };
115+
vi.mock('@object-ui/react', async (importOriginal) => ({
116+
...(await importOriginal<Record<string, unknown>>()),
117+
useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }),
118+
useGlobalUndo: () => {},
119+
useMutationInvalidationBridge: () => {},
120+
}));
121+
122+
// ---------------------------------------------------------------------------
123+
// The transport. One stubbed `fetch` behind a real adapter + real client.
124+
// ---------------------------------------------------------------------------
125+
126+
const json = (status: number, body: unknown) =>
127+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
128+
129+
/** `GET /api/v1/meta/app/:name` for an app that exists and refuses this session. */
130+
const deniedBody = (app: string) => ({
131+
success: false,
132+
error: { code: 'PERMISSION_DENIED', message: `You do not have permission to open the '${app}' app.` },
133+
});
134+
/** …and for a name with nothing behind it. */
135+
const ABSENT_BODY = {
136+
error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' },
137+
};
138+
139+
/** URLs the by-name meta route was asked for, in order. */
140+
let metaItemRequests: string[] = [];
141+
/** How the by-name route answers, per app name; unlisted names are absent. */
142+
let byName: Record<string, () => Response> = {};
143+
144+
function makeAdapter() {
145+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
146+
const url = String(input);
147+
if (url.includes('/api/v1/discovery')) {
148+
return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } });
149+
}
150+
const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url);
151+
if (item) {
152+
metaItemRequests.push(url);
153+
const answer = byName[decodeURIComponent(item[1])];
154+
return answer ? answer() : json(404, ABSENT_BODY);
155+
}
156+
// Nothing else is part of this question; a loud 500 keeps an unexpected
157+
// call from passing as a silent success.
158+
return json(500, { error: { code: 'UNEXPECTED_REQUEST', message: url } });
159+
});
160+
return new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
161+
}
162+
163+
let adapter: unknown = null;
164+
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
165+
...(await importOriginal<Record<string, unknown>>()),
166+
useAdapter: () => adapter,
167+
}));
168+
169+
/**
170+
* The list as the SERVER hands it to this session: an app the session may not
171+
* open is simply ABSENT (`filterAppForUser`). The member's one app is
172+
* Showcase, the launcher's default, which a pseudo-route falls back to.
173+
*/
174+
const SHOWCASE_APP = {
175+
name: 'showcase_app',
176+
label: 'Showcase',
177+
isDefault: true,
178+
navigation: [{ id: 'nav_accounts', type: 'object', objectName: 'showcase_account', label: 'Accounts' }],
179+
};
180+
const SETUP_APP = { name: 'setup', label: 'Setup', navigation: [] };
181+
let metadataApps: unknown[] = [];
182+
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
183+
...(await importOriginal<Record<string, unknown>>()),
184+
useMetadata: () => ({
185+
apps: metadataApps,
186+
objects: [],
187+
loading: false,
188+
ensureType: undefined,
189+
error: null,
190+
refresh: vi.fn(async () => {}),
191+
}),
192+
}));
193+
194+
import { AppContent } from '../AppContent';
195+
196+
function LocationProbe() {
197+
const location = useLocation();
198+
return <div data-testid="pathname">{location.pathname}</div>;
199+
}
200+
201+
/** The host's settings routes, as `apps/console`'s `systemRoutes` mounts them. */
202+
function SettingsHubStub() {
203+
return counted('settings-hub-page');
204+
}
205+
function SettingsViewStub() {
206+
const { namespace } = useParams<{ namespace?: string }>();
207+
return counted('settings-view-page', namespace);
208+
}
209+
const hostSystemRoutes = (
210+
<>
211+
<Route path="system" element={<Navigate to="settings" replace />} />
212+
<Route path="system/settings" element={<SettingsHubStub />} />
213+
<Route path="system/settings/:namespace" element={<SettingsViewStub />} />
214+
</>
215+
);
216+
217+
function renderConsoleAt(initialUrl: string) {
218+
return render(
219+
<MemoryRouter initialEntries={[initialUrl]}>
220+
<LocationProbe />
221+
<Routes>
222+
<Route
223+
path="/apps/:appName/*"
224+
element={<AppContent extraRoutes={hostSystemRoutes} extraRoutesNoApp={hostSystemRoutes} />}
225+
/>
226+
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
227+
</Routes>
228+
</MemoryRouter>,
229+
);
230+
}
231+
232+
const pathname = () => screen.getByTestId('pathname').textContent;
233+
234+
/** Every page stub this file counts; a refused caller mounts none of them. */
235+
const PAGE_STUBS = ['settings-hub-page', 'settings-view-page', 'marketplace-page', 'marketplace-package-page'];
236+
const pagesMounted = () => PAGE_STUBS.filter((id) => (mounts[id] ?? 0) > 0);
237+
238+
describe('AppContent — Setup\'s settings and marketplace routes wait on the app\'s access verdict (objectstack#27507)', () => {
239+
beforeEach(() => {
240+
vi.clearAllMocks();
241+
for (const k of Object.keys(mounts)) delete mounts[k];
242+
metadataApps = [SHOWCASE_APP];
243+
metaItemRequests = [];
244+
byName = {
245+
setup: () => json(403, deniedBody('setup')),
246+
'com.objectstack.setup': () => json(403, deniedBody('com.objectstack.setup')),
247+
};
248+
adapter = makeAdapter();
249+
});
250+
251+
it.each([
252+
['the settings hub', '/apps/setup/system/settings'],
253+
['a settings page the capability gate refuses', '/apps/setup/system/settings/auth'],
254+
['a settings page the setup gate refuses', '/apps/setup/system/settings/localization'],
255+
['the marketplace', '/apps/setup/system/marketplace'],
256+
['a marketplace package page', '/apps/setup/system/marketplace/com.acme.crm'],
257+
])('a refused caller gets app-access-denied on %s, with nothing of the page mounted', async (_label, url) => {
258+
renderConsoleAt(url);
259+
260+
expect(await screen.findByTestId('app-access-denied')).toBeInTheDocument();
261+
expect(screen.queryByTestId('console-layout')).not.toBeInTheDocument();
262+
expect(pagesMounted()).toEqual([]);
263+
// The verdict asked is Setup's, the app the URL names, never the fallback's.
264+
expect(metaItemRequests).toHaveLength(1);
265+
expect(metaItemRequests[0]).toContain('/api/v1/meta/app/setup');
266+
// The URL is left as the caller typed it, and the screen owns its way back.
267+
expect(pathname()).toBe(url);
268+
expect(screen.getByTestId('app-access-denied-home')).toBeInTheDocument();
269+
});
270+
271+
it('the same verdict on Setup\'s package-id address', async () => {
272+
renderConsoleAt('/apps/com.objectstack.setup/system/settings/auth');
273+
274+
expect(await screen.findByTestId('app-access-denied')).toBeInTheDocument();
275+
expect(pagesMounted()).toEqual([]);
276+
expect(metaItemRequests[0]).toContain('/api/v1/meta/app/com.objectstack.setup');
277+
});
278+
279+
it('the bare system landing forwards onto the settings hub, which is refused', async () => {
280+
renderConsoleAt('/apps/setup/system');
281+
282+
expect(await screen.findByTestId('app-access-denied')).toBeInTheDocument();
283+
expect(pagesMounted()).toEqual([]);
284+
expect(pathname()).toBe('/apps/setup/system/settings');
285+
});
286+
287+
it('CONTROL — an admitted caller gets the settings page under Setup, and nothing is probed', async () => {
288+
metadataApps = [SHOWCASE_APP, SETUP_APP];
289+
290+
renderConsoleAt('/apps/setup/system/settings/auth');
291+
292+
expect(await screen.findByTestId('settings-view-page')).toHaveTextContent('auth');
293+
expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'setup');
294+
expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument();
295+
expect(metaItemRequests).toEqual([]);
296+
});
297+
298+
it('CONTROL — an admitted caller gets the marketplace, and nothing is probed', async () => {
299+
metadataApps = [SHOWCASE_APP, SETUP_APP];
300+
301+
renderConsoleAt('/apps/setup/system/marketplace');
302+
303+
expect(await screen.findByTestId('marketplace-page')).toBeInTheDocument();
304+
expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument();
305+
expect(metaItemRequests).toEqual([]);
306+
});
307+
308+
it('CONTROL — a server that has no such app changes nothing: the page renders as before', async () => {
309+
// A 404 is not a refusal; the pseudo-route keeps its default-app fallback.
310+
byName = {};
311+
312+
renderConsoleAt('/apps/setup/system/settings');
313+
314+
expect(await screen.findByTestId('settings-hub-page')).toBeInTheDocument();
315+
expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'showcase_app');
316+
expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument();
317+
});
318+
319+
it('CONTROL — the settings route under an app the caller may open renders the page, unprobed', async () => {
320+
renderConsoleAt('/apps/showcase_app/system/settings');
321+
322+
expect(await screen.findByTestId('settings-hub-page')).toBeInTheDocument();
323+
expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'showcase_app');
324+
expect(metaItemRequests).toEqual([]);
325+
});
326+
});

0 commit comments

Comments
 (0)