@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.84
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 +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +7 -1
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/permission-editor.tsx +396 -210
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/hooks/use-list-data.ts +77 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +8 -0
- package/client/locales/system/settings-zh-CN.ts +8 -0
- package/client/locales/system/zh-CN.ts +8 -0
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +56 -27
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -58
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +11 -3
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +39 -27
- package/client/routing/session-preparation.tsx +10 -3
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +13 -12
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/default-access.test.tsx +60 -4
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +5 -4
- package/tests/client/settings-feedback.test.tsx +25 -0
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
// Run against disposable generated applications prepared with tests/fixtures/submission.
|
|
5
|
+
const applications = JSON.parse(
|
|
6
|
+
process.env.SUBMISSION_APPLICATIONS ?? '[]',
|
|
7
|
+
) as {
|
|
8
|
+
name: string;
|
|
9
|
+
url: string;
|
|
10
|
+
beforeData?: string;
|
|
11
|
+
beforePermissions?: string;
|
|
12
|
+
}[];
|
|
13
|
+
if (!applications.length)
|
|
14
|
+
throw new Error(
|
|
15
|
+
'Set SUBMISSION_APPLICATIONS to the generated and upgraded fixture application entry URLs.',
|
|
16
|
+
);
|
|
17
|
+
type Evidence = {
|
|
18
|
+
rows: { id: string; name: string; assetTag: string; status: string }[];
|
|
19
|
+
entries: { borrowingId: string }[];
|
|
20
|
+
history: { recordId: string }[];
|
|
21
|
+
};
|
|
22
|
+
function gate() {
|
|
23
|
+
let release!: () => void;
|
|
24
|
+
const promise = new Promise<void>((resolve) => {
|
|
25
|
+
release = resolve;
|
|
26
|
+
});
|
|
27
|
+
return { promise, release };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const app of applications) {
|
|
31
|
+
test(`${app.name}: shared submission and retained business customizations`, async ({
|
|
32
|
+
page,
|
|
33
|
+
}, testInfo) => {
|
|
34
|
+
await page.goto(`${app.url}/submission-check`);
|
|
35
|
+
await page
|
|
36
|
+
.getByRole('textbox', { name: 'Username or email' })
|
|
37
|
+
.fill('nocobase');
|
|
38
|
+
await page.getByLabel('Password', { exact: true }).fill('admin123');
|
|
39
|
+
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
40
|
+
await page.waitForURL((url) => !url.pathname.endsWith('/login'));
|
|
41
|
+
await page.goto(`${app.url}/submission-check`);
|
|
42
|
+
await page.getByRole('heading', { name: 'Equipment borrowing' }).waitFor();
|
|
43
|
+
const form = page
|
|
44
|
+
.getByRole('heading', { name: 'Equipment borrowing' })
|
|
45
|
+
.locator('..');
|
|
46
|
+
const status = form.getByRole('status');
|
|
47
|
+
const evidence = async (): Promise<Evidence> => {
|
|
48
|
+
const response = await page.request.get(
|
|
49
|
+
`${app.url}/api/submissionFixture/evidence`,
|
|
50
|
+
);
|
|
51
|
+
expect(response.ok()).toBe(true);
|
|
52
|
+
return (await response.json()).data as Evidence;
|
|
53
|
+
};
|
|
54
|
+
const original = await evidence();
|
|
55
|
+
if (app.beforeData) {
|
|
56
|
+
const before = JSON.parse(
|
|
57
|
+
await readFile(app.beforeData, 'utf8'),
|
|
58
|
+
) as Evidence;
|
|
59
|
+
for (const row of before.rows)
|
|
60
|
+
expect(original.rows.find((item) => item.id === row.id)).toEqual(row);
|
|
61
|
+
}
|
|
62
|
+
await expect(form).toHaveCSS('border-top-color', 'rgb(37, 99, 235)');
|
|
63
|
+
await page.getByLabel('Custom asset tag').fill('invalid');
|
|
64
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
65
|
+
await expect(status).toHaveText('Name and CUSTOM- asset tag are required');
|
|
66
|
+
expect((await evidence()).rows).toHaveLength(original.rows.length);
|
|
67
|
+
await page.getByLabel('Custom asset tag').fill('CUSTOM-001');
|
|
68
|
+
|
|
69
|
+
// Hold a committed response: double click, Enter and programmatic submit share one immediate lock.
|
|
70
|
+
let creates = 0;
|
|
71
|
+
const held = gate();
|
|
72
|
+
const committed = gate();
|
|
73
|
+
await page.route('**/api/borrowings/createOne', async (route) => {
|
|
74
|
+
creates++;
|
|
75
|
+
const response = await route.fetch();
|
|
76
|
+
expect(response.status()).toBe(200);
|
|
77
|
+
committed.release();
|
|
78
|
+
await held.promise;
|
|
79
|
+
await route.fulfill({ response });
|
|
80
|
+
});
|
|
81
|
+
await form.getByRole('button', { name: 'Save', exact: true }).dblclick();
|
|
82
|
+
await committed.promise;
|
|
83
|
+
await page.getByLabel('Equipment name').press('Enter');
|
|
84
|
+
await form
|
|
85
|
+
.locator('form')
|
|
86
|
+
.evaluate((element) =>
|
|
87
|
+
(element as { requestSubmit(): void }).requestSubmit(),
|
|
88
|
+
);
|
|
89
|
+
await expect(
|
|
90
|
+
form.getByRole('button', { name: 'Submitting…' }),
|
|
91
|
+
).toBeDisabled();
|
|
92
|
+
expect(creates).toBe(1);
|
|
93
|
+
await form.screenshot({ path: testInfo.outputPath('submitting.png') });
|
|
94
|
+
await testInfo.attach('Submitting (English)', {
|
|
95
|
+
path: testInfo.outputPath('submitting.png'),
|
|
96
|
+
contentType: 'image/png',
|
|
97
|
+
});
|
|
98
|
+
held.release();
|
|
99
|
+
await expect(status).toHaveText('Saved successfully');
|
|
100
|
+
await page.unroute('**/api/borrowings/createOne');
|
|
101
|
+
const firstId = await page.getByTestId('record-id').innerText();
|
|
102
|
+
expect((await evidence()).rows).toHaveLength(original.rows.length + 1);
|
|
103
|
+
|
|
104
|
+
// A deliberate new operation with identical fields creates another record.
|
|
105
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
106
|
+
await expect
|
|
107
|
+
.poll(async () => (await evidence()).rows.length)
|
|
108
|
+
.toBe(original.rows.length + 2);
|
|
109
|
+
const secondId = await page.getByTestId('record-id').innerText();
|
|
110
|
+
expect(secondId).not.toBe(firstId);
|
|
111
|
+
await page.getByLabel('Operation').selectOption('update');
|
|
112
|
+
await page.getByLabel('Equipment name').fill('Camera updated');
|
|
113
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
114
|
+
await expect
|
|
115
|
+
.poll(
|
|
116
|
+
async () =>
|
|
117
|
+
(await evidence()).rows.find((row) => row.id === secondId)?.name,
|
|
118
|
+
)
|
|
119
|
+
.toBe('Camera updated');
|
|
120
|
+
|
|
121
|
+
// A definite server refusal retains the fields and allows correction/retry.
|
|
122
|
+
await page.getByLabel('Operation').selectOption('create');
|
|
123
|
+
await page.route('**/api/borrowings/createOne', (route) =>
|
|
124
|
+
route.fulfill({
|
|
125
|
+
status: 400,
|
|
126
|
+
contentType: 'application/json',
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
error: {
|
|
129
|
+
code: 'INVALID_ARGUMENT',
|
|
130
|
+
status: 400,
|
|
131
|
+
reason: 'INVALID_INPUT',
|
|
132
|
+
domain: 'app',
|
|
133
|
+
message: 'Synthetic rejection',
|
|
134
|
+
requestId: 'synthetic',
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
140
|
+
await expect(status).toHaveText(
|
|
141
|
+
'Request failed; input retained. Retry to confirm the outcome.',
|
|
142
|
+
);
|
|
143
|
+
await expect(page.getByLabel('Equipment name')).toHaveValue(
|
|
144
|
+
'Camera updated',
|
|
145
|
+
);
|
|
146
|
+
await form.screenshot({ path: testInfo.outputPath('failed.png') });
|
|
147
|
+
await testInfo.attach('Failure with retained input (English)', {
|
|
148
|
+
path: testInfo.outputPath('failed.png'),
|
|
149
|
+
contentType: 'image/png',
|
|
150
|
+
});
|
|
151
|
+
await page.unroute('**/api/borrowings/createOne');
|
|
152
|
+
|
|
153
|
+
// Commit on the server, then drop the response; retry uses exactly the original key and body.
|
|
154
|
+
let lostKey = '';
|
|
155
|
+
let lostBody = '';
|
|
156
|
+
let replayed = false;
|
|
157
|
+
await page.route('**/api/borrowings/createOne', async (route) => {
|
|
158
|
+
lostKey = route.request().headers()['idempotency-key'];
|
|
159
|
+
lostBody = route.request().postData() ?? '';
|
|
160
|
+
const response = await route.fetch();
|
|
161
|
+
expect(response.status()).toBe(200);
|
|
162
|
+
await route.abort('failed');
|
|
163
|
+
});
|
|
164
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
165
|
+
await expect(status).toHaveText(
|
|
166
|
+
'Request failed; input retained. Retry to confirm the outcome.',
|
|
167
|
+
);
|
|
168
|
+
expect((await evidence()).rows).toHaveLength(original.rows.length + 3);
|
|
169
|
+
await page.unroute('**/api/borrowings/createOne');
|
|
170
|
+
await page.getByLabel('Equipment name').fill('Changed while uncertain');
|
|
171
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
172
|
+
await expect(status).toHaveText(
|
|
173
|
+
'Retry the original operation before changing input',
|
|
174
|
+
);
|
|
175
|
+
await page.getByLabel('Equipment name').fill('Camera updated');
|
|
176
|
+
await page.route('**/api/borrowings/createOne', async (route) => {
|
|
177
|
+
expect(route.request().headers()['idempotency-key']).toBe(lostKey);
|
|
178
|
+
expect(route.request().postData()).toBe(lostBody);
|
|
179
|
+
const response = await route.fetch();
|
|
180
|
+
expect(response.status()).toBe(200);
|
|
181
|
+
replayed = response.headers()['idempotency-replayed'] === 'true';
|
|
182
|
+
await route.fulfill({ response });
|
|
183
|
+
});
|
|
184
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
185
|
+
await expect(status).toHaveText('Saved successfully');
|
|
186
|
+
expect(replayed).toBe(true);
|
|
187
|
+
expect((await evidence()).rows).toHaveLength(original.rows.length + 3);
|
|
188
|
+
await page.unroute('**/api/borrowings/createOne');
|
|
189
|
+
const approvalId = await page.getByTestId('record-id').innerText();
|
|
190
|
+
|
|
191
|
+
// Lifecycle state, history and inventory effect join the same receipt transaction.
|
|
192
|
+
await page.getByLabel('Operation').selectOption('approve');
|
|
193
|
+
let approvalKey = '';
|
|
194
|
+
let approvalBody = '';
|
|
195
|
+
await page.route('**/api/submissionFixture/*/approve', async (route) => {
|
|
196
|
+
approvalKey = route.request().headers()['idempotency-key'];
|
|
197
|
+
approvalBody = route.request().postData() ?? '';
|
|
198
|
+
const response = await route.fetch();
|
|
199
|
+
expect(response.status()).toBe(200);
|
|
200
|
+
await route.abort('failed');
|
|
201
|
+
});
|
|
202
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
203
|
+
await expect(status).toHaveText(
|
|
204
|
+
'Request failed; input retained. Retry to confirm the outcome.',
|
|
205
|
+
);
|
|
206
|
+
await page.unroute('**/api/submissionFixture/*/approve');
|
|
207
|
+
const accessChange = await page.request.post(
|
|
208
|
+
`${app.url}/api/submissionFixture/fixtureAccess`,
|
|
209
|
+
{
|
|
210
|
+
headers: { Origin: new URL(app.url).origin },
|
|
211
|
+
data: { revoked: true },
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
expect(accessChange.status()).toBe(200);
|
|
215
|
+
const denied = await page.request.post(
|
|
216
|
+
`${app.url}/api/submissionFixture/${approvalId}/approve`,
|
|
217
|
+
{
|
|
218
|
+
headers: {
|
|
219
|
+
'Idempotency-Key': approvalKey,
|
|
220
|
+
Origin: new URL(app.url).origin,
|
|
221
|
+
},
|
|
222
|
+
data: JSON.parse(approvalBody),
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
expect(denied.status()).toBe(403);
|
|
226
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
227
|
+
await expect(status).toHaveText(
|
|
228
|
+
'Request failed; input retained. Retry to confirm the outcome.',
|
|
229
|
+
);
|
|
230
|
+
await page.request.post(`${app.url}/api/submissionFixture/fixtureAccess`, {
|
|
231
|
+
headers: { Origin: new URL(app.url).origin },
|
|
232
|
+
data: { revoked: false },
|
|
233
|
+
});
|
|
234
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
235
|
+
await expect(status).toHaveText('Approved once');
|
|
236
|
+
const after = await evidence();
|
|
237
|
+
expect(after.rows.find((row) => row.id === approvalId)?.status).toBe(
|
|
238
|
+
'approved',
|
|
239
|
+
);
|
|
240
|
+
expect(
|
|
241
|
+
after.entries.filter((entry) => entry.borrowingId === approvalId),
|
|
242
|
+
).toHaveLength(1);
|
|
243
|
+
expect(
|
|
244
|
+
after.history.filter((entry) => entry.recordId === approvalId),
|
|
245
|
+
).toHaveLength(1);
|
|
246
|
+
for (const row of original.rows)
|
|
247
|
+
expect(after.rows.find((item) => item.id === row.id)).toEqual(row);
|
|
248
|
+
await form.screenshot({ path: testInfo.outputPath('success.png') });
|
|
249
|
+
await testInfo.attach('Successful approval (English)', {
|
|
250
|
+
path: testInfo.outputPath('success.png'),
|
|
251
|
+
contentType: 'image/png',
|
|
252
|
+
});
|
|
253
|
+
await testInfo.attach('Business invariants', {
|
|
254
|
+
body: Buffer.from(
|
|
255
|
+
JSON.stringify(
|
|
256
|
+
{
|
|
257
|
+
retainedRows: original.rows.length,
|
|
258
|
+
addedRows: 3,
|
|
259
|
+
effectsForApproval: 1,
|
|
260
|
+
historyForApproval: 1,
|
|
261
|
+
replayedCreate: replayed,
|
|
262
|
+
},
|
|
263
|
+
null,
|
|
264
|
+
2,
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
contentType: 'application/json',
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const app of applications) {
|
|
273
|
+
test(`${app.name}: persisted ordinary-user permissions survive source upgrade`, async ({
|
|
274
|
+
page,
|
|
275
|
+
browser,
|
|
276
|
+
}, testInfo) => {
|
|
277
|
+
await page.goto(`${app.url}/login`);
|
|
278
|
+
await page
|
|
279
|
+
.getByRole('textbox', { name: 'Username or email' })
|
|
280
|
+
.fill('nocobase');
|
|
281
|
+
await page.getByLabel('Password', { exact: true }).fill('admin123');
|
|
282
|
+
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
283
|
+
await page.waitForURL((url) => !url.pathname.endsWith('/login'));
|
|
284
|
+
const key = () => `${Date.now()}.${crypto.randomUUID()}`;
|
|
285
|
+
const api = async (path: string, data?: unknown, method = 'GET') =>
|
|
286
|
+
page.request.fetch(`${app.url}/api/${path}`, {
|
|
287
|
+
method,
|
|
288
|
+
headers: { Origin: new URL(app.url).origin, 'Idempotency-Key': key() },
|
|
289
|
+
...(data === undefined ? {} : { data }),
|
|
290
|
+
});
|
|
291
|
+
type Baseline = {
|
|
292
|
+
role: unknown;
|
|
293
|
+
userId: string;
|
|
294
|
+
roleScopes: unknown;
|
|
295
|
+
assignments: unknown;
|
|
296
|
+
allowed: unknown;
|
|
297
|
+
denied: unknown;
|
|
298
|
+
};
|
|
299
|
+
let baseline: Baseline;
|
|
300
|
+
if (app.beforePermissions)
|
|
301
|
+
baseline = JSON.parse(
|
|
302
|
+
await readFile(app.beforePermissions, 'utf8'),
|
|
303
|
+
) as Baseline;
|
|
304
|
+
else {
|
|
305
|
+
const role = {
|
|
306
|
+
key: 'borrowing-clerk',
|
|
307
|
+
title: 'Borrowing clerk',
|
|
308
|
+
grants: [
|
|
309
|
+
{
|
|
310
|
+
resource: { type: 'submissionBorrowing', id: '*' },
|
|
311
|
+
actions: [
|
|
312
|
+
{ action: 'read' },
|
|
313
|
+
{ action: 'create' },
|
|
314
|
+
{ action: 'update' },
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
const roleResponse = await api(
|
|
320
|
+
'authorization/permissionSets',
|
|
321
|
+
role,
|
|
322
|
+
'POST',
|
|
323
|
+
);
|
|
324
|
+
expect(roleResponse.status()).toBe(201);
|
|
325
|
+
const userResponse = await api(
|
|
326
|
+
'users',
|
|
327
|
+
{
|
|
328
|
+
name: 'Synthetic clerk',
|
|
329
|
+
username: 'borrowclerk',
|
|
330
|
+
email: 'clerk@example.test',
|
|
331
|
+
password: 'Borrow-test-123!',
|
|
332
|
+
roleScopes: { app: ['borrowing-clerk'] },
|
|
333
|
+
},
|
|
334
|
+
'POST',
|
|
335
|
+
);
|
|
336
|
+
expect(userResponse.status()).toBe(201);
|
|
337
|
+
const user = (await userResponse.json()).data;
|
|
338
|
+
baseline = {
|
|
339
|
+
role: (await roleResponse.json()).data,
|
|
340
|
+
userId: user.id,
|
|
341
|
+
roleScopes: user.roleScopes,
|
|
342
|
+
assignments: (
|
|
343
|
+
await (
|
|
344
|
+
await api(
|
|
345
|
+
'authorization/permissionSets/borrowing-clerk/assignments',
|
|
346
|
+
)
|
|
347
|
+
).json()
|
|
348
|
+
).data,
|
|
349
|
+
allowed: { read: 200, update: 200 },
|
|
350
|
+
denied: { delete: 403, approve: 403 },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const storedRole = (
|
|
354
|
+
await (await api('authorization/permissionSets/borrowing-clerk')).json()
|
|
355
|
+
).data;
|
|
356
|
+
const assignments = (
|
|
357
|
+
await (
|
|
358
|
+
await api('authorization/permissionSets/borrowing-clerk/assignments')
|
|
359
|
+
).json()
|
|
360
|
+
).data;
|
|
361
|
+
const user = (await (await api(`users/${baseline.userId}`)).json()).data;
|
|
362
|
+
expect(storedRole).toEqual(baseline.role);
|
|
363
|
+
expect(assignments).toEqual(baseline.assignments);
|
|
364
|
+
expect(user.roleScopes).toEqual(baseline.roleScopes);
|
|
365
|
+
const ordinary = await browser.newPage();
|
|
366
|
+
try {
|
|
367
|
+
await ordinary.goto(`${app.url}/login`);
|
|
368
|
+
await ordinary
|
|
369
|
+
.getByRole('textbox', { name: 'Username or email' })
|
|
370
|
+
.fill('borrowclerk');
|
|
371
|
+
await ordinary
|
|
372
|
+
.getByLabel('Password', { exact: true })
|
|
373
|
+
.fill('Borrow-test-123!');
|
|
374
|
+
await ordinary
|
|
375
|
+
.getByRole('button', { name: 'Sign in', exact: true })
|
|
376
|
+
.click();
|
|
377
|
+
await ordinary.waitForURL((url) => !url.pathname.endsWith('/login'));
|
|
378
|
+
await ordinary.goto(`${app.url}/submission-check`);
|
|
379
|
+
const form = ordinary
|
|
380
|
+
.getByRole('heading', { name: 'Equipment borrowing' })
|
|
381
|
+
.locator('..');
|
|
382
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
383
|
+
await expect(form.getByRole('status')).toHaveText('Saved successfully');
|
|
384
|
+
const id = await ordinary.getByTestId('record-id').innerText();
|
|
385
|
+
const send = (path: string, data: unknown, operation = key()) =>
|
|
386
|
+
ordinary.request.post(`${app.url}/api/${path}`, {
|
|
387
|
+
headers: {
|
|
388
|
+
Origin: new URL(app.url).origin,
|
|
389
|
+
'Idempotency-Key': operation,
|
|
390
|
+
},
|
|
391
|
+
data,
|
|
392
|
+
});
|
|
393
|
+
const read = await send('borrowings/findMany', {});
|
|
394
|
+
const edit = await send('borrowings/updateOne', {
|
|
395
|
+
filter: { id },
|
|
396
|
+
values: { name: 'Preserved clerk customization' },
|
|
397
|
+
});
|
|
398
|
+
const deletion = await send('borrowings/deleteOne', { filter: { id } });
|
|
399
|
+
const approval = await send(`submissionFixture/${id}/approve`, {
|
|
400
|
+
note: 'Denied synthetic approval',
|
|
401
|
+
});
|
|
402
|
+
expect({ read: read.status(), update: edit.status() }).toEqual(
|
|
403
|
+
baseline.allowed,
|
|
404
|
+
);
|
|
405
|
+
expect({ delete: deletion.status(), approve: approval.status() }).toEqual(
|
|
406
|
+
baseline.denied,
|
|
407
|
+
);
|
|
408
|
+
await ordinary.getByLabel('Operation').selectOption('approve');
|
|
409
|
+
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
|
410
|
+
await expect(form.getByRole('status')).toHaveText(
|
|
411
|
+
'Request failed; input retained. Retry to confirm the outcome.',
|
|
412
|
+
);
|
|
413
|
+
await form.screenshot({
|
|
414
|
+
path: testInfo.outputPath('ordinary-user-denied.png'),
|
|
415
|
+
});
|
|
416
|
+
const operation = key();
|
|
417
|
+
const input = {
|
|
418
|
+
values: {
|
|
419
|
+
id: operation.slice(14),
|
|
420
|
+
name: 'Native role replay',
|
|
421
|
+
assetTag: 'CUSTOM-ROLE',
|
|
422
|
+
statusChangedAt: new Date().toISOString(),
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
expect(
|
|
426
|
+
(await send('borrowings/createOne', input, operation)).status(),
|
|
427
|
+
).toBe(200);
|
|
428
|
+
expect(
|
|
429
|
+
(
|
|
430
|
+
await api(
|
|
431
|
+
`users/${baseline.userId}`,
|
|
432
|
+
{ roleScopes: { app: [] } },
|
|
433
|
+
'PATCH',
|
|
434
|
+
)
|
|
435
|
+
).status(),
|
|
436
|
+
).toBe(200);
|
|
437
|
+
expect(
|
|
438
|
+
(await send('borrowings/createOne', input, operation)).status(),
|
|
439
|
+
).toBe(403);
|
|
440
|
+
expect(
|
|
441
|
+
(
|
|
442
|
+
await api(
|
|
443
|
+
`users/${baseline.userId}`,
|
|
444
|
+
{ roleScopes: baseline.roleScopes },
|
|
445
|
+
'PATCH',
|
|
446
|
+
)
|
|
447
|
+
).status(),
|
|
448
|
+
).toBe(200);
|
|
449
|
+
const replay = await send('borrowings/createOne', input, operation);
|
|
450
|
+
expect(replay.status()).toBe(200);
|
|
451
|
+
expect(replay.headers()['idempotency-replayed']).toBe('true');
|
|
452
|
+
expect(
|
|
453
|
+
(
|
|
454
|
+
await (
|
|
455
|
+
await api(
|
|
456
|
+
'authorization/permissionSets/borrowing-clerk/assignments',
|
|
457
|
+
)
|
|
458
|
+
).json()
|
|
459
|
+
).data,
|
|
460
|
+
).toEqual(baseline.assignments);
|
|
461
|
+
await testInfo.attach('Persistent business permissions', {
|
|
462
|
+
body: Buffer.from(
|
|
463
|
+
JSON.stringify({
|
|
464
|
+
rolePreserved: true,
|
|
465
|
+
assignmentsPreserved: true,
|
|
466
|
+
allowed: { read: read.status(), update: edit.status() },
|
|
467
|
+
denied: { delete: deletion.status(), approve: approval.status() },
|
|
468
|
+
revokedReplay: 403,
|
|
469
|
+
restoredReplay: 200,
|
|
470
|
+
}),
|
|
471
|
+
),
|
|
472
|
+
contentType: 'application/json',
|
|
473
|
+
});
|
|
474
|
+
} finally {
|
|
475
|
+
await ordinary.close();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineMigration, type MigrationDefinition } from '@nocobase/db';
|
|
2
|
+
const migration: MigrationDefinition = defineMigration({
|
|
3
|
+
name: '202610260099_submission_fixture',
|
|
4
|
+
async up({ builder }) {
|
|
5
|
+
await builder.createCollection('borrowings', (c) => {
|
|
6
|
+
c.string('id', { length: 64 }).primary().notNull();
|
|
7
|
+
c.string('name').notNull();
|
|
8
|
+
c.string('assetTag').notNull();
|
|
9
|
+
c.string('status').notNull().defaultTo('draft');
|
|
10
|
+
c.datetimeTz('statusChangedAt').notNull();
|
|
11
|
+
c.integer('lifecycleVersion').notNull().defaultTo(0);
|
|
12
|
+
});
|
|
13
|
+
await builder.createCollection('inventoryEntries', (c) => {
|
|
14
|
+
c.string('id', { length: 64 }).primary().notNull();
|
|
15
|
+
c.string('borrowingId', { length: 64 }).notNull();
|
|
16
|
+
});
|
|
17
|
+
await builder.createCollection('lifecycleTransitions', (c) => {
|
|
18
|
+
c.bigInt('id').primary().autoIncrement().notNull();
|
|
19
|
+
c.string('lifecycle').notNull();
|
|
20
|
+
c.string('recordId').notNull();
|
|
21
|
+
c.string('transition').notNull();
|
|
22
|
+
c.string('from').nullable();
|
|
23
|
+
c.string('to').notNull();
|
|
24
|
+
c.string('actorId').notNull();
|
|
25
|
+
c.json('input').notNull();
|
|
26
|
+
c.datetimeTz('at').notNull();
|
|
27
|
+
c.integer('version').notNull();
|
|
28
|
+
c.string('requestId').nullable();
|
|
29
|
+
c.string('requestKey').notNull();
|
|
30
|
+
c.unique(['lifecycle', 'recordId', 'version']);
|
|
31
|
+
c.unique(['lifecycle', 'recordId', 'requestKey']);
|
|
32
|
+
});
|
|
33
|
+
await builder.createCollection('lifecycleEffectRuns', (c) => {
|
|
34
|
+
c.bigInt('id').primary().autoIncrement().notNull();
|
|
35
|
+
c.bigInt('transitionId').notNull();
|
|
36
|
+
c.string('lifecycle').notNull();
|
|
37
|
+
c.string('recordId').notNull();
|
|
38
|
+
c.string('effect').notNull();
|
|
39
|
+
c.boolean('stayBound').notNull();
|
|
40
|
+
c.string('status').notNull();
|
|
41
|
+
c.integer('attempts').notNull();
|
|
42
|
+
c.integer('maxAttempts').notNull();
|
|
43
|
+
c.json('result').nullable();
|
|
44
|
+
c.text('error').nullable();
|
|
45
|
+
c.datetimeTz('createdAt').notNull();
|
|
46
|
+
c.datetimeTz('updatedAt').notNull();
|
|
47
|
+
c.datetimeTz('claimedAt').nullable();
|
|
48
|
+
c.datetimeTz('runAfter').nullable();
|
|
49
|
+
c.json('continuation').nullable();
|
|
50
|
+
c.datetimeTz('continuationDueAt').nullable();
|
|
51
|
+
c.datetimeTz('continuationAbandonedAt').nullable();
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
async down({ builder }) {
|
|
55
|
+
await builder.dropCollection('lifecycleEffectRuns');
|
|
56
|
+
await builder.dropCollection('lifecycleTransitions');
|
|
57
|
+
await builder.dropCollection('inventoryEntries');
|
|
58
|
+
await builder.dropCollection('borrowings');
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
export default migration;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState, type ReactElement } from 'react';
|
|
2
|
+
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
3
|
+
import {
|
|
4
|
+
useApiClient,
|
|
5
|
+
useSubmission,
|
|
6
|
+
PendingSubmissionError,
|
|
7
|
+
} from '@nocobase/app-client';
|
|
8
|
+
import { Button } from '#components/ui/button';
|
|
9
|
+
import { Input } from '#components/ui/input';
|
|
10
|
+
|
|
11
|
+
/** A synthetic customized business form, used against generated and upgraded applications. */
|
|
12
|
+
export default function SubmissionFixture(): ReactElement {
|
|
13
|
+
const api = useApiClient();
|
|
14
|
+
const { session } = useAuthentication();
|
|
15
|
+
const submission = useSubmission({
|
|
16
|
+
scope: `${session?.user.id}:synthetic-borrowing-form`,
|
|
17
|
+
});
|
|
18
|
+
const [name, setName] = useState('Camera');
|
|
19
|
+
const [assetTag, setAssetTag] = useState('CUSTOM-001');
|
|
20
|
+
const [id, setId] = useState<string>();
|
|
21
|
+
const [message, setMessage] = useState('Ready');
|
|
22
|
+
const [mode, setMode] = useState<'create' | 'update' | 'approve'>('create');
|
|
23
|
+
const submit = async (): Promise<void> => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await submission.run(
|
|
26
|
+
{ name, assetTag, mode, id },
|
|
27
|
+
async (key, signal) => {
|
|
28
|
+
if (!name.trim() || !assetTag.startsWith('CUSTOM-')) {
|
|
29
|
+
setMessage('Name and CUSTOM- asset tag are required');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (mode === 'approve')
|
|
33
|
+
return api.request({
|
|
34
|
+
path: `submissionFixture/${id}/approve`,
|
|
35
|
+
method: 'POST',
|
|
36
|
+
signal,
|
|
37
|
+
headers: { 'Idempotency-Key': key },
|
|
38
|
+
json: { note: 'Approved synthetic request' },
|
|
39
|
+
});
|
|
40
|
+
return mode === 'update'
|
|
41
|
+
? api
|
|
42
|
+
.repository<{
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
assetTag: string;
|
|
46
|
+
statusChangedAt: string;
|
|
47
|
+
}>('borrowings')
|
|
48
|
+
.updateOne({
|
|
49
|
+
idempotencyKey: key,
|
|
50
|
+
signal,
|
|
51
|
+
filter: { id },
|
|
52
|
+
values: { name, assetTag },
|
|
53
|
+
})
|
|
54
|
+
: api
|
|
55
|
+
.repository<{
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
assetTag: string;
|
|
59
|
+
statusChangedAt: string;
|
|
60
|
+
}>('borrowings')
|
|
61
|
+
.createOne({
|
|
62
|
+
idempotencyKey: key,
|
|
63
|
+
signal,
|
|
64
|
+
values: {
|
|
65
|
+
id: key.slice(14),
|
|
66
|
+
name,
|
|
67
|
+
assetTag,
|
|
68
|
+
statusChangedAt: new Date(
|
|
69
|
+
Number(key.slice(0, 13)),
|
|
70
|
+
).toISOString(),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
if (result)
|
|
76
|
+
setMessage(mode === 'approve' ? 'Approved once' : 'Saved successfully');
|
|
77
|
+
if (result && typeof result === 'object' && 'record' in result)
|
|
78
|
+
setId((result.record as { id: string }).id);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
setMessage(
|
|
81
|
+
error instanceof PendingSubmissionError
|
|
82
|
+
? 'Retry the original operation before changing input'
|
|
83
|
+
: 'Request failed; input retained. Retry to confirm the outcome.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return (
|
|
88
|
+
<main
|
|
89
|
+
className='mx-auto max-w-xl space-y-5 p-8'
|
|
90
|
+
style={{ borderTop: '6px solid #2563eb' }}
|
|
91
|
+
>
|
|
92
|
+
<h1 className='text-2xl font-semibold'>Equipment borrowing</h1>
|
|
93
|
+
<p>
|
|
94
|
+
Custom business form • Asset tag validation • Existing data retained
|
|
95
|
+
</p>
|
|
96
|
+
<form
|
|
97
|
+
id='borrowing-form'
|
|
98
|
+
onSubmit={(event) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
void submit();
|
|
101
|
+
}}
|
|
102
|
+
className='space-y-3'
|
|
103
|
+
>
|
|
104
|
+
<label htmlFor='name'>Equipment name</label>
|
|
105
|
+
<Input
|
|
106
|
+
id='name'
|
|
107
|
+
value={name}
|
|
108
|
+
onChange={(event) => setName(event.target.value)}
|
|
109
|
+
/>
|
|
110
|
+
<label htmlFor='assetTag'>Custom asset tag</label>
|
|
111
|
+
<Input
|
|
112
|
+
id='assetTag'
|
|
113
|
+
value={assetTag}
|
|
114
|
+
onChange={(event) => setAssetTag(event.target.value)}
|
|
115
|
+
/>
|
|
116
|
+
<label htmlFor='mode'>Operation</label>
|
|
117
|
+
<select
|
|
118
|
+
id='mode'
|
|
119
|
+
value={mode}
|
|
120
|
+
onChange={(event) => setMode(event.target.value as typeof mode)}
|
|
121
|
+
>
|
|
122
|
+
<option value='create'>Create</option>
|
|
123
|
+
<option value='update'>Update</option>
|
|
124
|
+
<option value='approve'>Approve</option>
|
|
125
|
+
</select>
|
|
126
|
+
</form>
|
|
127
|
+
<Button
|
|
128
|
+
type='submit'
|
|
129
|
+
form='borrowing-form'
|
|
130
|
+
disabled={submission.submitting}
|
|
131
|
+
>
|
|
132
|
+
{submission.submitting ? 'Submitting…' : 'Save'}
|
|
133
|
+
</Button>
|
|
134
|
+
<p role='status'>{submission.submitting ? 'Submitting…' : message}</p>
|
|
135
|
+
<p data-testid='record-id'>{id}</p>
|
|
136
|
+
</main>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
export default defineConfig({
|
|
3
|
+
testDir: '.',
|
|
4
|
+
testMatch: 'browser.test.ts',
|
|
5
|
+
timeout: 120_000,
|
|
6
|
+
workers: 1,
|
|
7
|
+
retries: 0,
|
|
8
|
+
outputDir: process.env.SUBMISSION_EVIDENCE_DIR ?? './evidence',
|
|
9
|
+
reporter: 'list',
|
|
10
|
+
use: { viewport: { width: 1280, height: 900 }, trace: 'off' },
|
|
11
|
+
});
|