@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/build-and-deploy.md +4 -0
- package/.agents/rules/components.md +3 -1
- package/README.MD +2 -2
- package/client/components/data-table/data-table.tsx +2 -0
- package/client/components/data-table/pagination.tsx +6 -6
- package/client/components/flow-diagram.tsx +645 -83
- package/client/components/flow-layout.ts +117 -7
- package/client/components/lifecycle-diagram.tsx +175 -37
- package/client/components/record-link.tsx +20 -0
- package/client/components/required-role-setup.tsx +93 -0
- package/client/components/role-list.tsx +21 -5
- package/client/components/setup-readiness-state.ts +135 -0
- package/client/components/setup-readiness.tsx +91 -0
- package/client/components/ui/combobox.tsx +300 -0
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
- package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
- package/client/hooks/use-table-url-state.ts +93 -0
- package/client/lib/required-role-setup.ts +98 -0
- package/client/lib/return-to.ts +19 -0
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +37 -1
- package/client/locales/system/settings-zh-CN.ts +33 -2
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +4 -1
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/role-form.tsx +1 -1
- package/client/pages/settings/roles.tsx +44 -6
- package/client/pages/settings/shared.tsx +15 -13
- package/client/pages/settings/user-detail.tsx +15 -259
- package/client/pages/settings/user-form.tsx +299 -147
- package/client/pages/settings/users.tsx +211 -123
- package/config.example.yml +6 -5
- package/package.json +11 -11
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +1 -1
- package/tests/components/setup-readiness.test.tsx +370 -0
- package/tests/components/table-url-state.test.tsx +95 -0
- package/tests/components/user-drawer.test.tsx +526 -0
- package/tests/logic/return-to.test.ts +19 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
3
|
+
import {
|
|
4
|
+
AuthorizationClient,
|
|
5
|
+
authorizationClientToken,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { Routes, Route } from 'react-router';
|
|
11
|
+
import UsersPage from '../../client/pages/settings/users.js';
|
|
12
|
+
import RolesPage from '../../client/pages/settings/roles.js';
|
|
13
|
+
import { SettingsError } from '../../client/pages/settings/shared.js';
|
|
14
|
+
import enUS from '../../client/locales/en-US.js';
|
|
15
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
16
|
+
|
|
17
|
+
const namespace = '@nocobase/settings-feedback-test';
|
|
18
|
+
const namespaces = {
|
|
19
|
+
[namespace]: {
|
|
20
|
+
'en-US': async () => ({ default: enUS }),
|
|
21
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const services = (app: ClientApplication) =>
|
|
25
|
+
app.container.instance(
|
|
26
|
+
authorizationClientToken,
|
|
27
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
28
|
+
);
|
|
29
|
+
const userRow = {
|
|
30
|
+
id: 'alice',
|
|
31
|
+
name: 'Alice',
|
|
32
|
+
email: 'alice@example.test',
|
|
33
|
+
disabledAt: null as string | null,
|
|
34
|
+
roleScopes: {},
|
|
35
|
+
};
|
|
36
|
+
const options = { data: { roleScopes: [] } };
|
|
37
|
+
|
|
38
|
+
describe.each([
|
|
39
|
+
['en-US', enUS],
|
|
40
|
+
['zh-CN', zhCN],
|
|
41
|
+
] as const)('%s', (language, copy) => {
|
|
42
|
+
it.each(['users', 'roles'] as const)(
|
|
43
|
+
'explains the next step for a read-only empty %s list',
|
|
44
|
+
async (kind) => {
|
|
45
|
+
await renderWithApp(
|
|
46
|
+
<Routes>
|
|
47
|
+
<Route
|
|
48
|
+
path={`/settings/${kind}`}
|
|
49
|
+
element={kind === 'users' ? <UsersPage /> : <RolesPage />}
|
|
50
|
+
/>
|
|
51
|
+
</Routes>,
|
|
52
|
+
{
|
|
53
|
+
route: `/settings/${kind}`,
|
|
54
|
+
locale: language,
|
|
55
|
+
namespace,
|
|
56
|
+
namespaces,
|
|
57
|
+
services,
|
|
58
|
+
fetch: answerApi(({ path }) => {
|
|
59
|
+
if (path === 'authorization/permissions')
|
|
60
|
+
return { data: { unrestricted: false, permissions: [] } };
|
|
61
|
+
if (path === 'users/options') return options;
|
|
62
|
+
return { data: [], meta: { total: 0 } };
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
expect(
|
|
67
|
+
await screen.findByText(
|
|
68
|
+
kind === 'users'
|
|
69
|
+
? copy.settings.usersReadOnlyEmpty
|
|
70
|
+
: copy.settings.rolesReadOnlyEmpty,
|
|
71
|
+
),
|
|
72
|
+
).toBeVisible();
|
|
73
|
+
expect(
|
|
74
|
+
screen.queryByRole('button', {
|
|
75
|
+
name:
|
|
76
|
+
kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
|
|
77
|
+
}),
|
|
78
|
+
).not.toBeInTheDocument();
|
|
79
|
+
expect(
|
|
80
|
+
screen.queryByText(copy.settings.noResults),
|
|
81
|
+
).not.toBeInTheDocument();
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it.each(['users', 'roles'] as const)(
|
|
86
|
+
'keeps a single creation entry for an authorized empty %s list',
|
|
87
|
+
async (kind) => {
|
|
88
|
+
await renderWithApp(
|
|
89
|
+
<Routes>
|
|
90
|
+
<Route
|
|
91
|
+
path={`/settings/${kind}`}
|
|
92
|
+
element={kind === 'users' ? <UsersPage /> : <RolesPage />}
|
|
93
|
+
/>
|
|
94
|
+
</Routes>,
|
|
95
|
+
{
|
|
96
|
+
route: `/settings/${kind}`,
|
|
97
|
+
locale: language,
|
|
98
|
+
namespace,
|
|
99
|
+
namespaces,
|
|
100
|
+
services,
|
|
101
|
+
fetch: answerApi(({ path }) => {
|
|
102
|
+
if (path === 'authorization/permissions')
|
|
103
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
104
|
+
if (path === 'users/options') return options;
|
|
105
|
+
return { data: [], meta: { total: 0 } };
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
expect(
|
|
110
|
+
await screen.findByRole('button', {
|
|
111
|
+
name:
|
|
112
|
+
kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
|
|
113
|
+
}),
|
|
114
|
+
).toHaveAttribute('href', `/settings/${kind}/create`);
|
|
115
|
+
expect(
|
|
116
|
+
screen.getAllByRole('button', {
|
|
117
|
+
name:
|
|
118
|
+
kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
|
|
119
|
+
}),
|
|
120
|
+
).toHaveLength(1);
|
|
121
|
+
expect(
|
|
122
|
+
screen.queryByText(
|
|
123
|
+
kind === 'users'
|
|
124
|
+
? copy.settings.usersReadOnlyEmpty
|
|
125
|
+
: copy.settings.rolesReadOnlyEmpty,
|
|
126
|
+
),
|
|
127
|
+
).not.toBeInTheDocument();
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
it('guides correction of invalid input without replaying it or exposing raw errors', async () => {
|
|
132
|
+
const retry = vi.fn();
|
|
133
|
+
await renderWithApp(
|
|
134
|
+
<SettingsError
|
|
135
|
+
error={{ status: 400, message: 'secret database details' }}
|
|
136
|
+
retry={retry}
|
|
137
|
+
/>,
|
|
138
|
+
{
|
|
139
|
+
locale: language,
|
|
140
|
+
namespace,
|
|
141
|
+
namespaces,
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
expect(screen.getByText(copy.settings.invalidRequest)).toBeVisible();
|
|
145
|
+
expect(
|
|
146
|
+
screen.queryByRole('button', { name: copy.settings.retry }),
|
|
147
|
+
).not.toBeInTheDocument();
|
|
148
|
+
expect(
|
|
149
|
+
screen.queryByText('secret database details'),
|
|
150
|
+
).not.toBeInTheDocument();
|
|
151
|
+
expect(retry).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('keeps a retry for temporary failures and performs it only on demand', async () => {
|
|
155
|
+
const retry = vi.fn();
|
|
156
|
+
await renderWithApp(
|
|
157
|
+
<SettingsError error={{ status: 503 }} retry={retry} />,
|
|
158
|
+
{
|
|
159
|
+
locale: language,
|
|
160
|
+
namespace,
|
|
161
|
+
namespaces,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
expect(screen.getByText(copy.settings.failed)).toBeVisible();
|
|
165
|
+
expect(retry).not.toHaveBeenCalled();
|
|
166
|
+
await userEvent.click(
|
|
167
|
+
screen.getByRole('button', { name: copy.settings.retry }),
|
|
168
|
+
);
|
|
169
|
+
expect(retry).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does not describe every conflict as a role edit or offer a blind retry', async () => {
|
|
173
|
+
await renderWithApp(
|
|
174
|
+
<SettingsError error={{ status: 409 }} retry={vi.fn()} />,
|
|
175
|
+
{
|
|
176
|
+
locale: language,
|
|
177
|
+
namespace,
|
|
178
|
+
namespaces,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
expect(screen.getByText(copy.settings.conflict)).toBeVisible();
|
|
182
|
+
expect(
|
|
183
|
+
screen.queryByRole('button', { name: copy.settings.retry }),
|
|
184
|
+
).not.toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it.each(['disable', 'enable', 'revokeSessions'] as const)(
|
|
189
|
+
'reports a completed %s action only after the server succeeds',
|
|
190
|
+
async (action) => {
|
|
191
|
+
let complete!: (value: unknown) => void;
|
|
192
|
+
const response = new Promise((resolve) => {
|
|
193
|
+
complete = resolve;
|
|
194
|
+
});
|
|
195
|
+
const endpoint = action;
|
|
196
|
+
const calls = vi.fn(({ path }: { path: string }) => {
|
|
197
|
+
if (path === 'authorization/permissions')
|
|
198
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
199
|
+
if (path === 'users/options') return options;
|
|
200
|
+
if (path === `users/alice/${endpoint}`) return response;
|
|
201
|
+
return {
|
|
202
|
+
data: [
|
|
203
|
+
{
|
|
204
|
+
...userRow,
|
|
205
|
+
disabledAt: action === 'enable' ? '2026-10-21T00:00:00Z' : null,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
meta: { total: 1 },
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
const view = await renderWithApp(<UsersPage />, {
|
|
212
|
+
route: '/settings/users',
|
|
213
|
+
namespace,
|
|
214
|
+
namespaces,
|
|
215
|
+
services,
|
|
216
|
+
fetch: answerApi(calls),
|
|
217
|
+
});
|
|
218
|
+
await userEvent.click(
|
|
219
|
+
await screen.findByRole('button', {
|
|
220
|
+
name: enUS.settings[action],
|
|
221
|
+
exact: true,
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
const dialog = await screen.findByRole('alertdialog');
|
|
225
|
+
await userEvent.click(
|
|
226
|
+
within(dialog).getByRole('button', {
|
|
227
|
+
name: enUS.settings[action],
|
|
228
|
+
exact: true,
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
await waitFor(() =>
|
|
232
|
+
expect(calls).toHaveBeenCalledWith(
|
|
233
|
+
expect.objectContaining({ path: `users/alice/${endpoint}` }),
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
expect(view.toasts()).toHaveLength(0);
|
|
237
|
+
expect(
|
|
238
|
+
within(dialog).getByRole('button', { name: enUS.settings.saving }),
|
|
239
|
+
).toBeDisabled();
|
|
240
|
+
expect(
|
|
241
|
+
within(dialog).getByRole('button', { name: enUS.settings.cancel }),
|
|
242
|
+
).toBeDisabled();
|
|
243
|
+
complete(
|
|
244
|
+
action === 'revokeSessions'
|
|
245
|
+
? { data: null }
|
|
246
|
+
: {
|
|
247
|
+
data: {
|
|
248
|
+
...userRow,
|
|
249
|
+
disabledAt: action === 'disable' ? '2026-10-21T00:00:00Z' : null,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
await waitFor(() =>
|
|
254
|
+
expect(view.toasts()).toMatchObject([
|
|
255
|
+
{
|
|
256
|
+
type: 'success',
|
|
257
|
+
title: enUS.settings.userActionSaved[action].replace(
|
|
258
|
+
'{{name}}',
|
|
259
|
+
'Alice',
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
]),
|
|
263
|
+
);
|
|
264
|
+
await waitFor(() =>
|
|
265
|
+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(),
|
|
266
|
+
);
|
|
267
|
+
expect(
|
|
268
|
+
calls.mock.calls.filter(
|
|
269
|
+
([call]) => call.path === `users/alice/${endpoint}`,
|
|
270
|
+
),
|
|
271
|
+
).toHaveLength(1);
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
it('keeps a failed account action in its dialog with no success toast', async () => {
|
|
276
|
+
const view = await renderWithApp(<UsersPage />, {
|
|
277
|
+
route: '/settings/users',
|
|
278
|
+
namespace,
|
|
279
|
+
namespaces,
|
|
280
|
+
services,
|
|
281
|
+
fetch: answerApi(({ path }) => {
|
|
282
|
+
if (path === 'authorization/permissions')
|
|
283
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
284
|
+
if (path === 'users/options') return options;
|
|
285
|
+
if (path === 'users/alice/disable')
|
|
286
|
+
return Response.json(
|
|
287
|
+
{ error: { code: 'FORBIDDEN', message: 'internal details' } },
|
|
288
|
+
{ status: 403 },
|
|
289
|
+
);
|
|
290
|
+
return { data: [userRow], meta: { total: 1 } };
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
await userEvent.click(
|
|
294
|
+
await screen.findByRole('button', { name: 'Disable', exact: true }),
|
|
295
|
+
);
|
|
296
|
+
const dialog = await screen.findByRole('alertdialog');
|
|
297
|
+
await userEvent.click(
|
|
298
|
+
within(dialog).getByRole('button', { name: 'Disable', exact: true }),
|
|
299
|
+
);
|
|
300
|
+
expect(await within(dialog).findByText(enUS.settings.denied)).toBeVisible();
|
|
301
|
+
expect(view.toasts()).toHaveLength(0);
|
|
302
|
+
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
|
303
|
+
expect(screen.queryByText('internal details')).not.toBeInTheDocument();
|
|
304
|
+
});
|
|
@@ -48,7 +48,7 @@ describe('template system settings', () => {
|
|
|
48
48
|
services,
|
|
49
49
|
fetch: answerApi(calls),
|
|
50
50
|
});
|
|
51
|
-
expect(await screen.
|
|
51
|
+
expect(await screen.findByText('Alice', { exact: true })).toBeVisible();
|
|
52
52
|
expect(
|
|
53
53
|
screen.queryByRole('link', { name: 'New user' }),
|
|
54
54
|
).not.toBeInTheDocument();
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
3
|
+
import {
|
|
4
|
+
AuthorizationClient,
|
|
5
|
+
authorizationClientToken,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { it, expect, vi } from 'vitest';
|
|
10
|
+
import { Link, Route, Routes } from 'react-router';
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { RequiredRoleSetup } from '../../client/components/required-role-setup.js';
|
|
13
|
+
import {
|
|
14
|
+
inspectRequiredRoles,
|
|
15
|
+
requiredRoleSetupId,
|
|
16
|
+
requiredRoleSetupLink,
|
|
17
|
+
} from '../../client/lib/required-role-setup.js';
|
|
18
|
+
import enUS from '../../client/locales/en-US.js';
|
|
19
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
20
|
+
|
|
21
|
+
const namespace = '@nocobase/setup-test';
|
|
22
|
+
const namespaces = {
|
|
23
|
+
[namespace]: {
|
|
24
|
+
'en-US': async () => ({ default: enUS }),
|
|
25
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const services = (app: ClientApplication) =>
|
|
29
|
+
app.container.instance(
|
|
30
|
+
authorizationClientToken,
|
|
31
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
32
|
+
);
|
|
33
|
+
const role = {
|
|
34
|
+
scope: 'app',
|
|
35
|
+
role: 'finance',
|
|
36
|
+
title: 'Finance reviewer',
|
|
37
|
+
description: 'Assign an enabled reviewer before finance review can run.',
|
|
38
|
+
};
|
|
39
|
+
const roles = [role];
|
|
40
|
+
const registered = {
|
|
41
|
+
roleScopes: [
|
|
42
|
+
{
|
|
43
|
+
key: 'app',
|
|
44
|
+
label: 'App roles',
|
|
45
|
+
selection: 'multiple' as const,
|
|
46
|
+
hasAuthenticatedDefaultAccess: true,
|
|
47
|
+
requiredOnCreate: false,
|
|
48
|
+
options: [{ value: 'finance', label: 'Finance' }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
const id = requiredRoleSetupId(role);
|
|
53
|
+
|
|
54
|
+
it('reads only enabled members in the exact registered scope and deduplicates required roles', async () => {
|
|
55
|
+
const client = {
|
|
56
|
+
options: vi.fn().mockResolvedValue(registered),
|
|
57
|
+
list: vi.fn().mockResolvedValue({ total: 0 }),
|
|
58
|
+
};
|
|
59
|
+
expect(await inspectRequiredRoles(client, [role, role])).toEqual({
|
|
60
|
+
[id]: { status: 'missing', canConfigure: true },
|
|
61
|
+
});
|
|
62
|
+
expect(client.options).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(client.list).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(client.list).toHaveBeenCalledWith({
|
|
65
|
+
roleScope: 'app',
|
|
66
|
+
role: 'finance',
|
|
67
|
+
status: 'enabled',
|
|
68
|
+
page: 1,
|
|
69
|
+
pageSize: 1,
|
|
70
|
+
});
|
|
71
|
+
client.list.mockResolvedValue({ total: 1 });
|
|
72
|
+
expect((await inspectRequiredRoles(client, roles))[id]?.status).toBe('ready');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each(['unknown-role', 'unknown-scope', 'default-only-role'])(
|
|
76
|
+
'does not misreport %s as missing membership',
|
|
77
|
+
async (kind) => {
|
|
78
|
+
const requirement =
|
|
79
|
+
kind === 'unknown-role'
|
|
80
|
+
? { ...role, role: 'deleted' }
|
|
81
|
+
: kind === 'unknown-scope'
|
|
82
|
+
? { ...role, scope: 'deleted' }
|
|
83
|
+
: { ...role, role: 'authenticated-member' };
|
|
84
|
+
const client = {
|
|
85
|
+
options: vi.fn().mockResolvedValue({
|
|
86
|
+
roleScopes: registered.roleScopes.map((scope) => ({
|
|
87
|
+
...scope,
|
|
88
|
+
hasAuthenticatedDefaultAccess: true,
|
|
89
|
+
})),
|
|
90
|
+
}),
|
|
91
|
+
list: vi.fn(),
|
|
92
|
+
};
|
|
93
|
+
expect(
|
|
94
|
+
(await inspectRequiredRoles(client, [requirement]))[
|
|
95
|
+
requiredRoleSetupId(requirement)
|
|
96
|
+
],
|
|
97
|
+
).toEqual({ status: 'unknown', reason: 'invalid' });
|
|
98
|
+
expect(client.list).not.toHaveBeenCalled();
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
it.each([undefined, -1, Number.NaN])(
|
|
103
|
+
'treats malformed totals %s as unknown',
|
|
104
|
+
async (total) => {
|
|
105
|
+
const client = {
|
|
106
|
+
options: vi.fn().mockResolvedValue(registered),
|
|
107
|
+
list: vi.fn().mockResolvedValue({ total }),
|
|
108
|
+
};
|
|
109
|
+
expect((await inspectRequiredRoles(client, roles))[id]?.status).toBe(
|
|
110
|
+
'unknown',
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it('keeps partial inspection success and cannot configure a non-assignable missing role', async () => {
|
|
116
|
+
const other = { ...role, role: 'manager' };
|
|
117
|
+
const client = {
|
|
118
|
+
options: vi.fn().mockResolvedValue({
|
|
119
|
+
roleScopes: [
|
|
120
|
+
{
|
|
121
|
+
...registered.roleScopes[0],
|
|
122
|
+
options: [
|
|
123
|
+
{ value: 'finance', label: 'Finance', assignable: false },
|
|
124
|
+
{ value: 'manager', label: 'Manager' },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
}),
|
|
129
|
+
list: vi
|
|
130
|
+
.fn()
|
|
131
|
+
.mockImplementation(({ role: key }: { role: string }) =>
|
|
132
|
+
key === 'finance'
|
|
133
|
+
? Promise.resolve({ total: 0 })
|
|
134
|
+
: Promise.reject({ status: 403, message: 'private-server-detail' }),
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
expect(await inspectRequiredRoles(client, [role, other])).toEqual({
|
|
138
|
+
[id]: { status: 'missing', canConfigure: false },
|
|
139
|
+
[requiredRoleSetupId(other)]: { status: 'unknown', reason: 'forbidden' },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('does not read metadata for an empty declaration and validates the return destination', async () => {
|
|
144
|
+
const client = { options: vi.fn(), list: vi.fn() };
|
|
145
|
+
expect(await inspectRequiredRoles(client, [])).toEqual({});
|
|
146
|
+
expect(client.options).not.toHaveBeenCalled();
|
|
147
|
+
const url = new URL(
|
|
148
|
+
requiredRoleSetupLink(role, '/processes/expense?tab=definition#review'),
|
|
149
|
+
'https://app.test',
|
|
150
|
+
);
|
|
151
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({
|
|
152
|
+
roleScope: 'app',
|
|
153
|
+
role: 'finance',
|
|
154
|
+
status: 'enabled',
|
|
155
|
+
returnTo: '/processes/expense?tab=definition#review',
|
|
156
|
+
});
|
|
157
|
+
expect(requiredRoleSetupLink(role, '//outside.test')).not.toContain(
|
|
158
|
+
'returnTo',
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('checks real API-client results, links to scoped management and rechecks on return and explicit refresh', async () => {
|
|
163
|
+
let total = 0;
|
|
164
|
+
const calls = vi.fn(
|
|
165
|
+
({ path, query }: { path: string; query?: Record<string, string> }) => {
|
|
166
|
+
if (path === 'authorization/permissions')
|
|
167
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
168
|
+
if (path === 'users/options') return { data: registered };
|
|
169
|
+
if (path === 'users') {
|
|
170
|
+
expect(query).toMatchObject({
|
|
171
|
+
status: 'enabled',
|
|
172
|
+
roleScope: 'app',
|
|
173
|
+
role: 'finance',
|
|
174
|
+
pageSize: '1',
|
|
175
|
+
});
|
|
176
|
+
return { data: [], meta: { total, page: 1, pageSize: 1 } };
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Unexpected request: ${path}`);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
await renderWithApp(
|
|
182
|
+
<Routes>
|
|
183
|
+
<Route path='/process' element={<RequiredRoleSetup roles={roles} />} />
|
|
184
|
+
<Route
|
|
185
|
+
path='/settings/users'
|
|
186
|
+
element={
|
|
187
|
+
<Link to='/process?name=travel#review'>Return to workflow</Link>
|
|
188
|
+
}
|
|
189
|
+
/>
|
|
190
|
+
</Routes>,
|
|
191
|
+
{
|
|
192
|
+
route: '/process?name=travel#review',
|
|
193
|
+
namespace,
|
|
194
|
+
namespaces,
|
|
195
|
+
services,
|
|
196
|
+
fetch: answerApi(calls),
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
const action = await screen.findByRole('link', {
|
|
200
|
+
name: 'Manage Finance reviewer members',
|
|
201
|
+
});
|
|
202
|
+
expect(action).toHaveAttribute(
|
|
203
|
+
'href',
|
|
204
|
+
requiredRoleSetupLink(role, '/process?name=travel#review'),
|
|
205
|
+
);
|
|
206
|
+
await userEvent.click(action);
|
|
207
|
+
total = 1;
|
|
208
|
+
const beforeReturn = calls.mock.calls.filter(
|
|
209
|
+
([request]) => request.path === 'users',
|
|
210
|
+
).length;
|
|
211
|
+
await userEvent.click(
|
|
212
|
+
screen.getByRole('link', { name: 'Return to workflow' }),
|
|
213
|
+
);
|
|
214
|
+
await waitFor(() => {
|
|
215
|
+
expect(
|
|
216
|
+
calls.mock.calls.filter(([request]) => request.path === 'users').length,
|
|
217
|
+
).toBeGreaterThan(beforeReturn);
|
|
218
|
+
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
total = 0;
|
|
221
|
+
fireEvent(window, new Event('focus'));
|
|
222
|
+
await screen.findByRole('link', { name: 'Manage Finance reviewer members' });
|
|
223
|
+
total = 1;
|
|
224
|
+
const refresh = screen.getByRole('button', { name: 'Check again' });
|
|
225
|
+
await userEvent.click(refresh);
|
|
226
|
+
expect(
|
|
227
|
+
await screen.findByText('The checked prerequisites are ready.'),
|
|
228
|
+
).toBeVisible();
|
|
229
|
+
expect(screen.getByRole('button', { name: 'Check again' })).toBe(refresh);
|
|
230
|
+
expect(
|
|
231
|
+
calls.mock.calls.every(
|
|
232
|
+
([request]) => !('method' in request) || request.method === 'GET',
|
|
233
|
+
),
|
|
234
|
+
).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it.each([false, true])(
|
|
238
|
+
'never requests member data when access is %s or a declaration is absent',
|
|
239
|
+
async (empty) => {
|
|
240
|
+
const calls = vi.fn(({ path }: { path: string }) =>
|
|
241
|
+
path === 'authorization/permissions'
|
|
242
|
+
? { data: { unrestricted: false, permissions: [] } }
|
|
243
|
+
: (() => {
|
|
244
|
+
throw new Error('No member reads permitted');
|
|
245
|
+
})(),
|
|
246
|
+
);
|
|
247
|
+
const view = await renderWithApp(
|
|
248
|
+
<RequiredRoleSetup roles={empty ? [] : roles} />,
|
|
249
|
+
{ namespace, namespaces, services, fetch: answerApi(calls) },
|
|
250
|
+
);
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
252
|
+
expect(view.container).toBeEmptyDOMElement();
|
|
253
|
+
expect(
|
|
254
|
+
calls.mock.calls.some(([request]) => request.path.startsWith('users')),
|
|
255
|
+
).toBe(false);
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
it('allows read-only inspection without offering role assignment', async () => {
|
|
260
|
+
await renderWithApp(<RequiredRoleSetup roles={roles} />, {
|
|
261
|
+
namespace,
|
|
262
|
+
namespaces,
|
|
263
|
+
services,
|
|
264
|
+
fetch: answerApi(({ path }) =>
|
|
265
|
+
path === 'authorization/permissions'
|
|
266
|
+
? {
|
|
267
|
+
data: {
|
|
268
|
+
unrestricted: false,
|
|
269
|
+
permissions: [
|
|
270
|
+
{ resource: { type: 'user', id: '*' }, actions: ['read'] },
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
: path === 'users/options'
|
|
275
|
+
? { data: registered }
|
|
276
|
+
: { data: [], meta: { total: 0 } },
|
|
277
|
+
),
|
|
278
|
+
});
|
|
279
|
+
expect(await screen.findByText(role.description)).toBeVisible();
|
|
280
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('uses short action copy for long role labels and rechecks the mounted feature after a successful inline save', async () => {
|
|
284
|
+
let total = 0;
|
|
285
|
+
const longRole = {
|
|
286
|
+
...role,
|
|
287
|
+
title: 'Department finance reviewer '.repeat(12),
|
|
288
|
+
};
|
|
289
|
+
const requirements = [longRole];
|
|
290
|
+
function Feature() {
|
|
291
|
+
const [revision, setRevision] = useState(0);
|
|
292
|
+
return (
|
|
293
|
+
<>
|
|
294
|
+
<button
|
|
295
|
+
onClick={() => {
|
|
296
|
+
total = 1;
|
|
297
|
+
setRevision((value) => value + 1);
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
Save configuration fixture
|
|
301
|
+
</button>
|
|
302
|
+
<RequiredRoleSetup roles={requirements} refreshKey={String(revision)} />
|
|
303
|
+
</>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
await renderWithApp(<Feature />, {
|
|
307
|
+
namespace,
|
|
308
|
+
namespaces,
|
|
309
|
+
services,
|
|
310
|
+
fetch: answerApi(({ path }) =>
|
|
311
|
+
path === 'authorization/permissions'
|
|
312
|
+
? { data: { unrestricted: true, permissions: [] } }
|
|
313
|
+
: path === 'users/options'
|
|
314
|
+
? { data: registered }
|
|
315
|
+
: { data: [], meta: { total } },
|
|
316
|
+
),
|
|
317
|
+
});
|
|
318
|
+
const link = await screen.findByRole('link');
|
|
319
|
+
expect(link.textContent).toBe('Manage members');
|
|
320
|
+
expect(link).toHaveAttribute(
|
|
321
|
+
'aria-label',
|
|
322
|
+
`Manage ${longRole.title} members`,
|
|
323
|
+
);
|
|
324
|
+
await userEvent.click(
|
|
325
|
+
screen.getByRole('button', { name: 'Save configuration fixture' }),
|
|
326
|
+
);
|
|
327
|
+
await waitFor(() =>
|
|
328
|
+
expect(screen.queryByRole('region')).not.toBeInTheDocument(),
|
|
329
|
+
);
|
|
330
|
+
expect(
|
|
331
|
+
screen.queryByText('Checking current configuration…'),
|
|
332
|
+
).not.toBeInTheDocument();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it.each([
|
|
336
|
+
null,
|
|
337
|
+
{},
|
|
338
|
+
{ roleScopes: null },
|
|
339
|
+
{ roleScopes: [{ key: 'app', options: null }] },
|
|
340
|
+
])(
|
|
341
|
+
'treats incompatible role metadata as an invalid check without reading users',
|
|
342
|
+
async (metadata) => {
|
|
343
|
+
const client = {
|
|
344
|
+
options: vi.fn().mockResolvedValue(metadata),
|
|
345
|
+
list: vi.fn(),
|
|
346
|
+
};
|
|
347
|
+
expect((await inspectRequiredRoles(client, roles))[id]).toEqual({
|
|
348
|
+
status: 'unknown',
|
|
349
|
+
reason: 'invalid',
|
|
350
|
+
});
|
|
351
|
+
expect(client.list).not.toHaveBeenCalled();
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
it('ignores malformed unrelated metadata while checking a valid declared role', async () => {
|
|
356
|
+
const client = {
|
|
357
|
+
options: vi.fn().mockResolvedValue({
|
|
358
|
+
roleScopes: [
|
|
359
|
+
null,
|
|
360
|
+
{
|
|
361
|
+
...registered.roleScopes[0],
|
|
362
|
+
options: [null, ...registered.roleScopes[0]!.options],
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
}),
|
|
366
|
+
list: vi.fn().mockResolvedValue({ total: 1 }),
|
|
367
|
+
};
|
|
368
|
+
expect((await inspectRequiredRoles(client, roles))[id]?.status).toBe('ready');
|
|
369
|
+
expect(client.list).toHaveBeenCalledTimes(1);
|
|
370
|
+
});
|