@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85

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.
Files changed (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -0,0 +1,35 @@
1
+ # Submission acceptance fixture
2
+
3
+ This is a synthetic equipment-borrowing module for disposable applications, using native application authentication. It adds an asset tag, `CUSTOM-` validation, a business permission gate, custom blue border, and a lifecycle approval transition whose transaction inserts an inventory entry. Never install these test-only routes into an application with real data; the permission switch exists only to exercise revocation. The real equipment-borrowing application is not required or accessed.
4
+
5
+ ## Fresh application
6
+
7
+ Create the application with `pnpm create @nocobase/app submission-new --template=<target-template-directory-or-package> --no-install --json`, from a scratch working directory. Verify its `nocobase.templatePackage`, read its `AGENTS.md` and synchronized application Skills, and install the target's coordinated dependencies. For an unreleased source-workspace verification, link the generated application's `node_modules` to the target template's installed workspace modules; this exercises the pending source and is not a published-version certification. Do not run package-manager mutations through that shared link.
8
+
9
+ Copy `migration.ts` to `database/main/migrations/202610260099_submission_fixture.ts`, `routes.ts` to `server/routes/submission-fixture.ts`, and `page.tsx` to `client/pages/submission-fixture.tsx`. Register the server contribution in `server/routes/index.ts`, preserving the template's submission provider. Add an authenticated application route at `/submission-check`, with `authz: 'skip'` and a component loader for the fixture page. The fixture's business endpoints own their authentication and synthetic permission gate independently of that page setting.
10
+
11
+ Run `pnpm nocobase config init --dialect sqlite --json`, then start with `APP_PUBLIC_ORIGIN=http://127.0.0.1:<chosen-port> APP_SERVER_PORT=<chosen-port> I18N_DEFAULT_LOCALE=en-US pnpm dev`. Use the actual printed application entry URL, including its base path. Configuration initialization generates local secrets; do not publish its file or native session cookies.
12
+
13
+ ## Version upgrade acceptance
14
+
15
+ For release acceptance, run this fixture through the shared [versioned template-upgrade procedure](../../../../../app/app-skills/skills/nocobase-app-upgrade/references/versioned-upgrade-verification.md). Start Default and Examples separately from published BASE tarballs and their original application Skills. Resolve an explicitly newer TARGET from the isolated candidate registry or the official registry, discover its instructions through the normal template diff, and coordinate dependencies and copied source within that same upgrade. Do not install target Skills or patch individual pages before starting the upgrade. Generate a fresh comparison application from each identical TARGET package.
16
+
17
+ Record exact BASE/TARGET versions, registry and tarball provenance, integrity, lockfile hashes, BASE-to-TARGET and BASE-to-PROJECT differences, resolved custom conflicts and the validated final baseline. Missing targets and unresolved conflicts must leave the baseline unchanged. Preserve the original business fields, validation, native roles and user assignments, lifecycle history, styling and rows. Candidate-package results and official-package results are separate acceptance records; repeat the matrix from real target packages after publication. Source-workspace checks below are useful specialist checks, but do not satisfy version upgrade acceptance.
18
+
19
+ The shared upgrade Skill, template entry documentation and versioned packaging procedure supply the common entry. This fixture supplies only the submission-specific business module, browser matrix and before/after evidence inputs. Integrate those inputs with the shared fixture rather than creating another packaging or upgrade mechanism.
20
+
21
+ ## Customized source verification
22
+
23
+ Prepare a separate application from the BASE template, before this feature. Add the same custom business schema, validation, permission gate, style and lifecycle hook. For the pre-upgrade page, replace only the shared controller with a direct submit function and omit operation headers on Repository mutations. For its custom action call `runtime.fire` without the new outer receipt adapter. Start it on its own database, sign in through its own native login and reproduce two records from a double click while holding the first response. Preserve these synthetic record ids and complete JSON rows as the data baseline.
24
+
25
+ Stop the old application and commit its source baseline on a local branch. Back up its closed database and migration history. Reconcile BASE → TARGET and BASE → PROJECT through the [submission source-upgrade checklist](../../../../../app/app-skills/skills/nocobase-app-upgrade/references/submission-upgrade.md). Merge the provider/registration, receipt migration, three settings forms and client peer dependencies. For the customized page change only the controller and key forwarding; preserve the fields, validation and styling. For its custom server action add the receipt adapter around the existing lifecycle transition and join its transaction, preserving the permission check and inventory hook. Keep all business tables and records. Inspect `db apply --dry-run` and `db repair --dry-run`; this additive feature should add only the receipt migration with no old checksum changes. Apply it, restart the same application and retain its original database.
26
+
27
+ For an unreleased source target, report its commit and keep the existing release marker instead of inventing a published target version. For a published target, advance `defaultTemplateVersion` only after source reconciliation and validation. Restore the paired old source and database backup for recovery.
28
+
29
+ ## Run the browser matrix
30
+
31
+ Set `SUBMISSION_APPLICATIONS` to a JSON array of `{ "name": "new-template", "url": "http://127.0.0.1:<port>/main" }` and `{ "name": "upgraded-customized", "url": "http://127.0.0.1:<other-port>/main", "beforeData": "<path-to-pre-upgrade-evidence-JSON>", "beforePermissions": "<path-to-pre-upgrade-native-permissions-JSON>" }`. `beforeData` contains the fixture's `{ rows, entries, history }`, captured from its authenticated evidence endpoint before upgrading. `beforePermissions` contains the persisted `borrowing-clerk` permission set, explicit user assignment and native role scopes captured before the upgrade; the second browser case compares these and signs in as that ordinary user to exercise allowed reads/updates and forbidden deletion/approval. Fresh applications create equivalent synthetic native permissions in that case. Write the environment setup to a local file when it spans multiple lines. Use the synthetic initial administrator from the disposable template; never reuse a management-instance identity or credential.
32
+
33
+ Run the application's local `pnpm exec playwright test -c tests/fixtures/submission/playwright.config.ts`. `PLAYWRIGHT_BROWSERS_PATH` must name an allowed scratch directory when installing a browser; use that same directory to run it. `SUBMISSION_EVIDENCE_DIR` selects a scratch output directory. The dedicated configuration keeps this opt-in fixture out of ordinary template browser tests. The test neither sleeps nor automatically retries failures.
34
+
35
+ Each application checks validation and custom styling, double click, Enter plus programmatic submit while a committed response is held, normal identical new records, update, a definite server rejection with retained input, response loss after a server-confirmed commit, unchanged keys and bodies on retry, refusal of changed input during an uncertain operation, permission revocation, and lifecycle approval with exactly one history entry and inventory effect. Every original row must remain identical. English submitting/error/success screenshots and JSON invariant summaries are attached to the Playwright result. The service-level suites separately cover parallel HTTP attempts, rollback, changed-content conflicts, expiration after cleanup, strict/compatibility modes and query costs on SQLite, PostgreSQL and MySQL.
@@ -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;