@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.
Files changed (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +7 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. package/tests/server/submission-settings.test.ts +241 -0
@@ -0,0 +1,208 @@
1
+ import {
2
+ authorizationToken,
3
+ grantBacked,
4
+ type AuthorizationEnv,
5
+ } from '@nocobase/app-plugin-authorization';
6
+ import type { Application } from '@nocobase/app-server/application';
7
+ import {
8
+ authenticationToken,
9
+ type AuthEnv,
10
+ } from '@nocobase/app-plugin-authentication';
11
+ import {
12
+ ApiError,
13
+ defineApiRoutes,
14
+ defineRepositoryApiRoutes,
15
+ executeIdempotentMutation,
16
+ describeRoute,
17
+ apiValidator,
18
+ dataResponse,
19
+ apiErrorResponses,
20
+ } from '@nocobase/app-server/router';
21
+ import { databaseManagerToken } from '@nocobase/db';
22
+ import {
23
+ createRepositoryLifecycleStore,
24
+ defineLifecycle,
25
+ LifecycleRuntime,
26
+ type LifecycleRecord,
27
+ } from '@nocobase/lifecycle';
28
+ import { Hono } from 'hono';
29
+ import { z } from 'zod';
30
+ import { randomUUID } from 'node:crypto';
31
+
32
+ interface Borrowing extends LifecycleRecord {
33
+ status: 'draft' | 'approved';
34
+ name: string;
35
+ assetTag: string;
36
+ }
37
+ type BorrowingTypes = {
38
+ record: Borrowing;
39
+ state: Borrowing['status'];
40
+ parameters: Record<string, never>;
41
+ services: Record<string, never>;
42
+ };
43
+ /** Isolated synthetic business fixture. Authentication uses this application's native session. */
44
+ export default defineApiRoutes(async (app: Application) => {
45
+ const database = app.container.resolve(databaseManagerToken);
46
+ const connection = database.connection();
47
+ const auth = app.container.resolve(authenticationToken);
48
+ const authorization = app.container.resolve(authorizationToken);
49
+ authorization.resourceTypes.add({
50
+ type: 'submissionBorrowing',
51
+ actions: ['read', 'create', 'update', 'delete', 'approve'],
52
+ authorize: grantBacked(),
53
+ });
54
+ const runtime = new LifecycleRuntime({
55
+ store: createRepositoryLifecycleStore(database),
56
+ });
57
+ runtime.register(
58
+ defineLifecycle<BorrowingTypes>({
59
+ name: 'borrowings',
60
+ initial: 'draft',
61
+ states: ['draft', { name: 'approved', final: true }],
62
+ transitions: {
63
+ approve: {
64
+ from: 'draft',
65
+ to: 'approved',
66
+ onTransition: async ({ record, transactionHandle }) => {
67
+ const tx = transactionHandle as typeof connection;
68
+ await tx.repository('inventoryEntries').createOne({
69
+ values: { id: randomUUID(), borrowingId: record.id },
70
+ });
71
+ },
72
+ },
73
+ },
74
+ }),
75
+ );
76
+ const router = new Hono<AuthEnv & AuthorizationEnv>();
77
+ router.use('*', auth.required(), authorization.middleware());
78
+ router.use('*', async (c, next) => {
79
+ const path = c.req.path;
80
+ const action = path.endsWith('/createOne')
81
+ ? 'create'
82
+ : path.endsWith('/updateOne')
83
+ ? 'update'
84
+ : path.endsWith('/deleteOne')
85
+ ? 'delete'
86
+ : path.endsWith('/approve')
87
+ ? 'approve'
88
+ : 'read';
89
+ await c.get('authz').require(
90
+ path.endsWith('/fixtureAccess')
91
+ ? {
92
+ resource: {
93
+ type: 'settings',
94
+ id: 'authorization.permission-sets',
95
+ },
96
+ action: 'update',
97
+ }
98
+ : { resource: { type: 'submissionBorrowing', id: '*' }, action },
99
+ );
100
+ await next();
101
+ });
102
+ let revoked = false;
103
+ router.use('*', async (c, next) => {
104
+ if (revoked && !c.req.path.endsWith('/fixtureAccess'))
105
+ throw new ApiError({
106
+ status: 'PERMISSION_DENIED',
107
+ reason: 'FIXTURE_REVOKED',
108
+ domain: 'submissionFixture',
109
+ message: 'The business permission was revoked.',
110
+ });
111
+ await next();
112
+ });
113
+ const routes = defineRepositoryApiRoutes({
114
+ repositories: [
115
+ {
116
+ name: 'borrowings',
117
+ policy: {
118
+ read: true,
119
+ create: {
120
+ scope: true,
121
+ fields: ['id', 'name', 'assetTag', 'statusChangedAt'],
122
+ },
123
+ update: { scope: true, fields: ['name', 'assetTag'] },
124
+ delete: true,
125
+ },
126
+ actions: { findMany: {}, createOne: {}, updateOne: {}, deleteOne: {} },
127
+ },
128
+ ],
129
+ });
130
+ router.route('/', await routes.createRouter(app));
131
+ const info = (operationId: string, summary: string) =>
132
+ describeRoute({
133
+ tags: ['SubmissionFixture'],
134
+ summary,
135
+ operationId,
136
+ responses: { 200: dataResponse(z.unknown()), ...apiErrorResponses },
137
+ });
138
+ router.get(
139
+ '/submissionFixture/evidence',
140
+ info('submissionFixtureEvidence', 'Inspect synthetic business data'),
141
+ async (c) =>
142
+ c.json({
143
+ data: {
144
+ rows: await connection.repository('borrowings').findMany(),
145
+ entries: await connection.repository('inventoryEntries').findMany(),
146
+ history: await connection
147
+ .repository('lifecycleTransitions')
148
+ .findMany(),
149
+ },
150
+ }),
151
+ );
152
+ router.post(
153
+ '/submissionFixture/fixtureAccess',
154
+ info('submissionFixtureAccess', 'Change the synthetic business permission'),
155
+ apiValidator('json', z.strictObject({ revoked: z.boolean() })),
156
+ (c) => {
157
+ revoked = c.req.valid('json').revoked;
158
+ return c.json({ data: { revoked } });
159
+ },
160
+ );
161
+ router.post(
162
+ '/submissionFixture/:id/approve',
163
+ info('submissionFixtureApprove', 'Approve a synthetic borrowing'),
164
+ apiValidator('param', z.object({ id: z.string() })),
165
+ apiValidator('header', z.object({ 'idempotency-key': z.string() })),
166
+ apiValidator('json', z.strictObject({ note: z.string() })),
167
+ async (c) => {
168
+ const user = c.get('auth') as { user: { id: string } };
169
+ const id = c.req.valid('param').id;
170
+ const result = await executeIdempotentMutation({
171
+ connection,
172
+ key: c.req.valid('header')['idempotency-key'],
173
+ principal: user.user.id,
174
+ operation: `borrowings/${id}/approve`,
175
+ input: c.req.valid('json'),
176
+ authorize: async () => {
177
+ if (revoked)
178
+ throw new ApiError({
179
+ status: 'PERMISSION_DENIED',
180
+ reason: 'FIXTURE_REVOKED',
181
+ domain: 'submissionFixture',
182
+ message: 'Permission revoked.',
183
+ });
184
+ },
185
+ authorizeReplay: async () => {
186
+ if (
187
+ !(await connection
188
+ .repository('borrowings')
189
+ .findOne({ filter: { id } }))
190
+ )
191
+ throw new Error('Borrowing unavailable.');
192
+ },
193
+ execute: async (transaction) => {
194
+ await runtime.fire('borrowings', id, 'approve', {
195
+ actor: { id: user.user.id },
196
+ requestId: c.req.valid('header')['idempotency-key'],
197
+ input: c.req.valid('json'),
198
+ transaction,
199
+ });
200
+ return { approved: true, id };
201
+ },
202
+ });
203
+ c.header('Idempotency-Replayed', String(result.replayed));
204
+ return c.json({ data: result.data });
205
+ },
206
+ );
207
+ return new Hono().route('/', router);
208
+ });
@@ -0,0 +1,31 @@
1
+ import type { ReactElement } from 'react';
2
+ import { beforeEach } from 'vitest';
3
+ import { renderWithApp } from '@nocobase/app-testing/client';
4
+ import {
5
+ AuthenticationProvider,
6
+ authenticationClientToken,
7
+ type AuthClient,
8
+ } from '@nocobase/app-plugin-authentication/client';
9
+
10
+ // Presentation tests keep their existing API doubles; native authentication is exercised separately against the server.
11
+ beforeEach(() => window.sessionStorage.clear());
12
+ export function renderSettingsClient(
13
+ ui: ReactElement,
14
+ options: Parameters<typeof renderWithApp>[1] = {},
15
+ ): ReturnType<typeof renderWithApp> {
16
+ return renderWithApp(<AuthenticationProvider>{ui}</AuthenticationProvider>, {
17
+ ...options,
18
+ services: (app) => {
19
+ options.services?.(app);
20
+ app.container.instance(authenticationClientToken, {
21
+ getSession: async () => ({
22
+ data: {
23
+ user: { id: 'settings-test-user' },
24
+ session: { id: 'settings-test-session' },
25
+ },
26
+ error: null,
27
+ }),
28
+ } as unknown as AuthClient);
29
+ },
30
+ });
31
+ }
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const createOperationKey = () => `${Date.now()}.${randomUUID()}`;
1
3
  // @vitest-environment node
2
4
  import { expect } from 'vitest';
3
5
  import { createAppTest } from '@nocobase/app-testing/server';
@@ -22,7 +24,11 @@ const test = createAppTest({
22
24
  });
23
25
  const json = (body: unknown, method = 'POST') => ({
24
26
  method,
25
- headers: { 'content-type': 'application/json', origin: 'http://localhost' },
27
+ headers: {
28
+ 'content-type': 'application/json',
29
+ origin: 'http://localhost',
30
+ 'Idempotency-Key': createOperationKey(),
31
+ },
26
32
  body: JSON.stringify(body),
27
33
  });
28
34
 
@@ -0,0 +1,241 @@
1
+ import { expect } from 'vitest';
2
+ import { createAppTest } from '@nocobase/app-testing/server';
3
+ import {
4
+ signIn,
5
+ DEFAULT_ADMIN_CREDENTIALS,
6
+ } from '@nocobase/app-plugin-authentication/testing';
7
+ import { randomUUID } from 'node:crypto';
8
+ const createOperationKey = () => `${Date.now()}.${randomUUID()}`;
9
+ import { authorizationToken } from '@nocobase/app-plugin-authorization';
10
+ import { createStandaloneServer } from '../../server/standalone.js';
11
+
12
+ const test = createAppTest({
13
+ createServer: createStandaloneServer,
14
+ config: {
15
+ app: { publicOrigin: 'http://localhost' },
16
+ auth: { secret: 'test-auth-secret-at-least-32-characters' },
17
+ },
18
+ });
19
+ const request = (
20
+ input: unknown,
21
+ key = createOperationKey(),
22
+ method = 'POST',
23
+ ): RequestInit => ({
24
+ method,
25
+ headers: {
26
+ 'content-type': 'application/json',
27
+ origin: 'http://localhost',
28
+ 'Idempotency-Key': key,
29
+ },
30
+ body: JSON.stringify(input),
31
+ });
32
+
33
+ test('native standard settings recover committed writes and enforce current permissions', async ({
34
+ testApp,
35
+ app,
36
+ connection,
37
+ }) => {
38
+ const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
39
+ const role = {
40
+ key: 'submission-reader',
41
+ title: 'Submission reader',
42
+ grants: [
43
+ { resource: { type: 'user', id: '*' }, actions: [{ action: 'read' }] },
44
+ ],
45
+ };
46
+ const roleRequest = request(role);
47
+ const firstRole = await admin.fetch(
48
+ '/authorization/permissionSets',
49
+ roleRequest,
50
+ );
51
+ expect(firstRole.status).toBe(201);
52
+ const savedRole = await firstRole.json();
53
+ const replayRole = await admin.fetch(
54
+ '/authorization/permissionSets',
55
+ roleRequest,
56
+ );
57
+ expect(replayRole.status).toBe(201);
58
+ expect(await replayRole.json()).toEqual(savedRole);
59
+ expect(replayRole.headers.get('Idempotency-Replayed')).toBe('true');
60
+ const account = {
61
+ name: 'Synthetic reader',
62
+ username: 'submissionreader',
63
+ email: 'submission-reader@example.test',
64
+ password: 'Submission-test-123!',
65
+ roleScopes: { app: [role.key] },
66
+ };
67
+ const accountRequest = request(account);
68
+ const firstUser = await admin.fetch('/users', accountRequest);
69
+ expect(firstUser.status).toBe(201);
70
+ const savedUser = await firstUser.json();
71
+ const replayUser = await admin.fetch('/users', accountRequest);
72
+ expect(replayUser.status).toBe(201);
73
+ expect(await replayUser.json()).toEqual(savedUser);
74
+ expect(replayUser.headers.get('Idempotency-Replayed')).toBe('true');
75
+ expect(
76
+ await connection
77
+ .repository('user')
78
+ .count({ filter: { username: account.username } }),
79
+ ).toBe(1);
80
+ const reader = await signIn(testApp, {
81
+ username: account.username,
82
+ password: account.password,
83
+ });
84
+ expect((await reader.fetch('/users')).status).toBe(200);
85
+ expect(
86
+ (
87
+ await reader.fetch(
88
+ '/users',
89
+ request({
90
+ ...account,
91
+ username: 'denied',
92
+ email: 'denied@example.test',
93
+ }),
94
+ )
95
+ ).status,
96
+ ).toBe(403);
97
+ const updateRequest = request(
98
+ {
99
+ title: 'Renamed reader',
100
+ grants: role.grants,
101
+ expectedRevision: savedRole.data.revision,
102
+ },
103
+ createOperationKey(),
104
+ 'PATCH',
105
+ );
106
+ const firstUpdate = await admin.fetch(
107
+ `/authorization/permissionSets/${role.key}`,
108
+ updateRequest,
109
+ );
110
+ expect(firstUpdate.status).toBe(200);
111
+ const updateResult = await firstUpdate.json();
112
+ const replayUpdate = await admin.fetch(
113
+ `/authorization/permissionSets/${role.key}`,
114
+ updateRequest,
115
+ );
116
+ expect(replayUpdate.status).toBe(200);
117
+ expect(await replayUpdate.json()).toEqual(updateResult);
118
+ const userUpdate = request(
119
+ { name: 'Updated synthetic reader', roleScopes: { app: [role.key] } },
120
+ createOperationKey(),
121
+ 'PATCH',
122
+ );
123
+ const firstEdit = await admin.fetch(
124
+ `/users/${savedUser.data.id}`,
125
+ userUpdate,
126
+ );
127
+ expect(firstEdit.status).toBe(200);
128
+ const replayEdit = await admin.fetch(
129
+ `/users/${savedUser.data.id}`,
130
+ userUpdate,
131
+ );
132
+ expect(replayEdit.status).toBe(200);
133
+ expect(await replayEdit.json()).toEqual(await firstEdit.json());
134
+ const missingKey = request({
135
+ ...account,
136
+ username: 'missing-key',
137
+ email: 'missing-key@example.test',
138
+ });
139
+ delete (missingKey.headers as Record<string, string>)['Idempotency-Key'];
140
+ expect((await admin.fetch('/users', missingKey)).status).toBe(400);
141
+ // A settings editor loses its grant after committing: possession of the old operation key cannot restore it.
142
+ const authz = app.container.resolve(authorizationToken);
143
+ await authz.permissionSets.create({
144
+ key: 'submission-editor',
145
+ grants: [
146
+ {
147
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
148
+ actions: [{ action: 'read' }, { action: 'create' }],
149
+ },
150
+ ],
151
+ });
152
+ await authz.permissionSets.replaceSubjectAssignments({
153
+ subject: { type: 'user', id: savedUser.data.id },
154
+ managedPermissionSets: ['submission-editor'],
155
+ permissionSets: ['submission-editor'],
156
+ });
157
+ const editorRequest = request({ key: 'created-by-editor', grants: [] });
158
+ expect(
159
+ (await reader.fetch('/authorization/permissionSets', editorRequest)).status,
160
+ ).toBe(201);
161
+ await authz.permissionSets.replaceSubjectAssignments({
162
+ subject: { type: 'user', id: savedUser.data.id },
163
+ managedPermissionSets: ['submission-editor'],
164
+ permissionSets: [],
165
+ });
166
+ expect(
167
+ (await reader.fetch('/authorization/permissionSets', editorRequest)).status,
168
+ ).toBe(403);
169
+ for (const [action, body, expected] of [
170
+ ['resetPassword', { password: 'Changed-test-123!' }, 204],
171
+ ['disable', {}, 200],
172
+ ['enable', {}, 200],
173
+ ['revokeSessions', {}, 204],
174
+ ] as const) {
175
+ const init = request(body);
176
+ const path = `/users/${savedUser.data.id}/${action}`;
177
+ const first = await admin.fetch(path, init);
178
+ expect(first.status).toBe(expected);
179
+ const second = await admin.fetch(path, init);
180
+ expect(second.status).toBe(expected);
181
+ expect(await second.text()).toEqual(await first.text());
182
+ expect(second.headers.get('Idempotency-Replayed')).toBe('true');
183
+ }
184
+ const deleteRole = {
185
+ ...request(null, createOperationKey(), 'DELETE'),
186
+ body: undefined,
187
+ };
188
+ expect(
189
+ (
190
+ await admin.fetch(
191
+ '/authorization/permissionSets/created-by-editor',
192
+ deleteRole,
193
+ )
194
+ ).status,
195
+ ).toBe(204);
196
+ const deletedRoleReplay = await admin.fetch(
197
+ '/authorization/permissionSets/created-by-editor',
198
+ deleteRole,
199
+ );
200
+ expect(deletedRoleReplay.status).toBe(204);
201
+ expect(deletedRoleReplay.headers.get('Idempotency-Replayed')).toBe('true');
202
+ });
203
+
204
+ test('default-access create update and delete replay without repeating preconditions', async ({
205
+ testApp,
206
+ app,
207
+ connection,
208
+ }) => {
209
+ const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
210
+ await connection.builder.createCollection('receiptExamples', (c) => {
211
+ c.string('id').primary();
212
+ });
213
+ app.container
214
+ .resolve(authorizationToken)
215
+ .database.collections.add({ name: 'receiptExamples' });
216
+ const rule = {
217
+ key: 'receipt-default',
218
+ resource: { type: 'database.collection', id: 'receiptExamples' },
219
+ actions: [{ action: 'read', selection: { type: 'all' } }],
220
+ };
221
+ for (const [path, init, expected] of [
222
+ ['/authorization/defaultAccess', request(rule), 201],
223
+ [
224
+ '/authorization/defaultAccess/receipt-default',
225
+ request({ actions: [] }, createOperationKey(), 'PATCH'),
226
+ 200,
227
+ ],
228
+ [
229
+ '/authorization/defaultAccess/receipt-default',
230
+ { ...request(null, createOperationKey(), 'DELETE'), body: undefined },
231
+ 204,
232
+ ],
233
+ ] as const) {
234
+ const first = await admin.fetch(path, init);
235
+ expect(first.status).toBe(expected);
236
+ const replay = await admin.fetch(path, init);
237
+ expect(replay.status).toBe(expected);
238
+ expect(await replay.text()).toEqual(await first.text());
239
+ expect(replay.headers.get('Idempotency-Replayed')).toBe('true');
240
+ }
241
+ });