@nocobase/app-template-default 1.0.0-beta.79 → 1.0.0-beta.81

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 (89) hide show
  1. package/AGENTS.md +4 -4
  2. package/README.MD +4 -2
  3. package/client/locales/system/settings-en-US.ts +3 -0
  4. package/client/locales/system/settings-zh-CN.ts +2 -0
  5. package/client/pages/settings/user-form.tsx +13 -1
  6. package/client/pages/settings/users.tsx +1 -1
  7. package/package.json +8 -7
  8. package/tests/client/account-permissions.test.tsx +174 -0
  9. package/tests/{components → client}/app-layout-routes.test.tsx +25 -42
  10. package/tests/{logic → client}/app-locale-coverage.test.ts +1 -4
  11. package/tests/{logic → client}/client-plugin-registry.test.ts +1 -4
  12. package/tests/{logic → client}/client-route-recovery.test.tsx +27 -7
  13. package/tests/{components → client}/header-hover.test.tsx +4 -14
  14. package/tests/{components → client}/home.test.tsx +27 -41
  15. package/tests/client/inbox.test.tsx +94 -0
  16. package/tests/{components → client}/permission-management-tabs.test.tsx +23 -44
  17. package/tests/client/sidebar-permissions.test.tsx +157 -0
  18. package/tests/{logic → client}/theme-tokens.test.ts +6 -8
  19. package/tests/{components → client}/user-drawer.test.tsx +48 -0
  20. package/tests/client/user-menu-sign-out.test.tsx +78 -0
  21. package/tests/{logic → database}/device-code-migration.test.ts +0 -1
  22. package/tests/helpers/authenticated-client.tsx +148 -0
  23. package/tests/{logic → project}/agents-md.test.ts +0 -2
  24. package/tests/{logic → project}/asset-url-build.test.ts +0 -1
  25. package/tests/{logic → project}/config-example.test.ts +0 -2
  26. package/tests/{logic → project}/dev-client-watch.test.ts +0 -2
  27. package/tests/{logic → project}/plugin-commands.test.ts +0 -2
  28. package/tests/{logic → project}/server-imports.test.ts +0 -1
  29. package/tests/{logic → project}/tailwind-sources.test.ts +0 -1
  30. package/tests/{logic → project}/template-package-name.test.ts +0 -2
  31. package/tests/{logic → server}/app-server.test.ts +0 -1
  32. package/tests/{logic → server}/authorization-assignment-safety.test.ts +10 -11
  33. package/tests/{logic → server}/config.test.ts +0 -2
  34. package/tests/server/member-role.test.ts +299 -0
  35. package/tests/{logic → server}/session-isolation.test.ts +0 -2
  36. package/tests/setup/application-environment.ts +8 -0
  37. package/tests/setup/client-config.ts +0 -2
  38. package/vitest.config.ts +38 -23
  39. package/tests/components/inbox.test.tsx +0 -124
  40. package/tests/components/sidebar-permissions.test.tsx +0 -108
  41. package/tests/logic/account-permissions.test.tsx +0 -204
  42. package/tests/logic/user-menu-sign-out.test.tsx +0 -107
  43. /package/tests/{components → client}/app-title.test.tsx +0 -0
  44. /package/tests/{components → client}/auth-i18n.test.tsx +0 -0
  45. /package/tests/{components → client}/back-button.test.tsx +0 -0
  46. /package/tests/{logic → client}/client-auth.test.tsx +0 -0
  47. /package/tests/{logic → client}/client-routes.test.ts +0 -0
  48. /package/tests/{logic → client}/client-runtime.test.ts +0 -0
  49. /package/tests/{logic → client}/client-shell.test.tsx +0 -0
  50. /package/tests/{logic → client}/client-theme.test.tsx +0 -0
  51. /package/tests/{components → client}/data-table.test.tsx +0 -0
  52. /package/tests/{components → client}/default-access-editor.test.tsx +0 -0
  53. /package/tests/{logic → client}/default-access-model.test.ts +0 -0
  54. /package/tests/{components → client}/default-access.test.tsx +0 -0
  55. /package/tests/{components → client}/file-preview.test.tsx +0 -0
  56. /package/tests/{logic → client}/foundation-integration.test.ts +0 -0
  57. /package/tests/{components → client}/language-switcher.test.tsx +0 -0
  58. /package/tests/{components → client}/layout-containers.test.tsx +0 -0
  59. /package/tests/{components → client}/layout-i18n.test.tsx +0 -0
  60. /package/tests/{components → client}/loading.test.tsx +0 -0
  61. /package/tests/{components → client}/navigation-menu.test.tsx +0 -0
  62. /package/tests/{components → client}/page-harness.test.tsx +0 -0
  63. /package/tests/{logic → client}/page-permission-navigation.test.ts +0 -0
  64. /package/tests/{logic → client}/page-permission-titles.test.ts +0 -0
  65. /package/tests/{components → client}/permission-editor.test.tsx +0 -0
  66. /package/tests/{components → client}/permission-filter-binding.test.tsx +0 -0
  67. /package/tests/{components → client}/permission-filter.test.tsx +0 -0
  68. /package/tests/{logic → client}/permission-management-routes.test.ts +0 -0
  69. /package/tests/{logic → client}/permission-workspace.test.ts +0 -0
  70. /package/tests/{components → client}/primitive-labels.test.tsx +0 -0
  71. /package/tests/{logic → client}/return-to.test.ts +0 -0
  72. /package/tests/{components → client}/role-list.test.tsx +0 -0
  73. /package/tests/{components → client}/roles.test.tsx +0 -0
  74. /package/tests/{logic → client}/route-navigation.test.ts +0 -0
  75. /package/tests/{logic → client}/route-overlay.test.tsx +0 -0
  76. /package/tests/{logic → client}/scrollbar.test.ts +0 -0
  77. /package/tests/{logic → client}/session-preparation.test.tsx +0 -0
  78. /package/tests/{components → client}/settings-continuity.test.tsx +0 -0
  79. /package/tests/{components → client}/settings-feedback.test.tsx +0 -0
  80. /package/tests/{logic → client}/settings-permissions.test.ts +0 -0
  81. /package/tests/{components → client}/settings.test.tsx +0 -0
  82. /package/tests/{components → client}/setup-readiness.test.tsx +0 -0
  83. /package/tests/{components → client}/sidebar-preference.test.tsx +0 -0
  84. /package/tests/{components → client}/status-badge.test.tsx +0 -0
  85. /package/tests/{components → client}/table-url-state.test.tsx +0 -0
  86. /package/tests/{logic → client}/theme-preferences.test.ts +0 -0
  87. /package/tests/{logic → client}/toaster.test.tsx +0 -0
  88. /package/tests/{components → client}/user-permissions.test.tsx +0 -0
  89. /package/tests/{logic → server}/lifecycle.test.ts +0 -0
@@ -0,0 +1,148 @@
1
+ import type { ReactElement } from 'react';
2
+ import { CookieJar } from 'jsdom';
3
+ import { onTestFinished, vi } from 'vitest';
4
+ import { act, waitFor } from '@testing-library/react';
5
+ import { createTestApp, type TestApp } from '@nocobase/app-testing/server';
6
+ import { renderWithApp } from '@nocobase/app-testing/client';
7
+ import authorization from '@nocobase/app-plugin-authorization/client/plugin';
8
+ import { authorizationToken } from '@nocobase/app-plugin-authorization/server';
9
+ import type { PermissionGrant } from '@nocobase/authorization/core';
10
+ import authentication from '@nocobase/app-plugin-authentication/client/plugin';
11
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
12
+ import {
13
+ signIn,
14
+ DEFAULT_ADMIN_CREDENTIALS,
15
+ type TestSession,
16
+ type SignInCredentials,
17
+ } from '@nocobase/app-plugin-authentication/testing';
18
+ import { createStandaloneServer } from '../../server/standalone.js';
19
+ import packageMetadata from '../../package.json' with { type: 'json' };
20
+ import enUS from '../../client/locales/en-US.js';
21
+ import zhCN from '../../client/locales/zh-CN.js';
22
+
23
+ /** Real application, authentication and cookies; only deliberate transport failures are injected by the caller. */
24
+ export async function renderAuthenticatedClient(
25
+ ui: ReactElement,
26
+ options: {
27
+ member?: boolean;
28
+ grants?: readonly PermissionGrant[];
29
+ route?: string;
30
+ locale?: string;
31
+ prepare?: (server: TestApp, session: TestSession) => Promise<void>;
32
+ namespaces?: NonNullable<Parameters<typeof renderWithApp>[1]>['namespaces'];
33
+ beforeRequest?: (request: Request) => void | Promise<void>;
34
+ afterResponse?: (
35
+ request: Request,
36
+ response: Response,
37
+ ) => void | Promise<void>;
38
+ } = {},
39
+ ) {
40
+ const server = await createTestApp({
41
+ createServer: createStandaloneServer,
42
+ connections: ['main'],
43
+ config: {
44
+ logging: { enabled: false },
45
+ app: { publicOrigin: 'http://localhost' },
46
+ auth: {
47
+ secret: 'test-auth-secret-at-least-32-characters',
48
+ emailAndPassword: { disableSignUp: false },
49
+ advanced: { disableOriginCheck: false, disableCSRFCheck: false },
50
+ },
51
+ },
52
+ });
53
+ onTestFinished(() => server.close());
54
+ const member = {
55
+ name: 'Test member',
56
+ email: 'member@example.test',
57
+ password: 'test-member-password',
58
+ };
59
+ if (options.member) {
60
+ const response = await server.request('/auth/sign-up/email', {
61
+ method: 'POST',
62
+ headers: {
63
+ 'content-type': 'application/json',
64
+ origin: 'http://localhost',
65
+ },
66
+ body: JSON.stringify(member),
67
+ });
68
+ if (!response.ok)
69
+ throw new Error(`Member registration failed: ${await response.text()}`);
70
+ }
71
+ const session = await signIn(
72
+ server,
73
+ options.member ? member : DEFAULT_ADMIN_CREDENTIALS,
74
+ );
75
+ if (options.grants) {
76
+ const authz = server.application.container.resolve(authorizationToken);
77
+ await authz.permissionSets.create({
78
+ key: 'test-access',
79
+ grants: options.grants,
80
+ });
81
+ await authz.permissionSets.assign({
82
+ id: 'test-assignment',
83
+ permissionSet: 'test-access',
84
+ subject: { type: 'user', id: session.user.id },
85
+ });
86
+ }
87
+ await options.prepare?.(server, session);
88
+ const jar = new CookieJar();
89
+ const root = `http://localhost${server.publicBasePath.replace(/\/$/u, '')}`;
90
+ for (const cookie of session.cookie.split('; ')) {
91
+ await jar.setCookie(`${cookie}; Path=/`, root);
92
+ }
93
+ const fetch: typeof globalThis.fetch = async (input, init) => {
94
+ const request = new Request(input, init);
95
+ request.headers.set('cookie', await jar.getCookieString(request.url));
96
+ request.headers.set('origin', 'http://localhost');
97
+ await options.beforeRequest?.(request);
98
+ const response = await server.fetch(request);
99
+ for (const cookie of response.headers.getSetCookie()) {
100
+ await jar.setCookie(cookie, request.url);
101
+ }
102
+ await options.afterResponse?.(request, response);
103
+ return response;
104
+ };
105
+ // Better Auth uses fetch directly; route it to the real in-process server with browser cookie handling.
106
+ vi.stubGlobal('fetch', fetch);
107
+ onTestFinished(() => {
108
+ vi.unstubAllGlobals();
109
+ });
110
+ let identity: ReturnType<typeof useAuthentication> | undefined;
111
+ function SessionObserver() {
112
+ identity = useAuthentication();
113
+ return ui;
114
+ }
115
+ const view = await renderWithApp(<SessionObserver />, {
116
+ plugins: [authentication(), authorization()],
117
+ namespace: packageMetadata.name,
118
+ namespaces: {
119
+ ...options.namespaces,
120
+ [packageMetadata.name]: {
121
+ 'en-US': async () => ({ default: enUS }),
122
+ 'zh-CN': async () => ({ default: zhCN }),
123
+ },
124
+ },
125
+ ...(options.route ? { route: options.route } : {}),
126
+ ...(options.locale ? { locale: options.locale } : {}),
127
+ server: { publicBasePath: server.publicBasePath, fetch },
128
+ });
129
+ await waitFor(() => {
130
+ if (identity?.isPending || identity?.session?.user.id !== session.user.id) {
131
+ throw new Error('The signed-in session has not reached the client.');
132
+ }
133
+ });
134
+ return {
135
+ ...view,
136
+ server,
137
+ session,
138
+ identity: () => identity!,
139
+ async signInAs(credentials: SignInCredentials) {
140
+ const next = await signIn(server, credentials);
141
+ await jar.removeAllCookies();
142
+ for (const cookie of next.cookie.split('; '))
143
+ await jar.setCookie(`${cookie}; Path=/`, root);
144
+ await act(() => identity!.refresh());
145
+ return next;
146
+ },
147
+ };
148
+ }
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
2
  import path from 'node:path';
5
3
  import { fileURLToPath } from 'node:url';
@@ -1,4 +1,3 @@
1
- // @vitest-environment node
2
1
  import {
3
2
  copyFile,
4
3
  mkdtemp,
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
2
 
5
3
  import os from 'node:os';
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import {
4
2
  copyFile,
5
3
  mkdir,
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  // Users develop, build and start their app through these scripts. Nothing at runtime depends on them, so dropping or
4
2
  // renaming one — a bad merge resolution did exactly that once — breaks the documented development steps silently: the app still
5
3
  // builds, starts, and passes every other test. These assertions are the alarm.
@@ -1,4 +1,3 @@
1
- // @vitest-environment node
2
1
  import { execFile } from 'node:child_process';
3
2
  import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
3
  import os from 'node:os';
@@ -1,5 +1,4 @@
1
1
  import clientPlugins from '../../client/plugins.js';
2
- // @vitest-environment node
3
2
 
4
3
  import {
5
4
  existsSync,
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { readFileSync, readdirSync, statSync } from 'node:fs';
4
2
  import path from 'node:path';
5
3
  import { fileURLToPath } from 'node:url';
@@ -1,6 +1,5 @@
1
1
  import { createApp } from '../../server/app.js';
2
2
  import authConfig from '../../server/config/auth.js';
3
- // @vitest-environment node
4
3
 
5
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
6
5
  import {
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { expect, it } from 'vitest';
4
2
  import { createAppTest } from '@nocobase/app-testing/server';
5
3
  import { createStandaloneServer } from '../../server/standalone.js';
@@ -13,14 +11,7 @@ type Assignment = {
13
11
  function unexpectedGlobalAssignments(
14
12
  assignments: readonly Assignment[],
15
13
  ): Assignment[] {
16
- return assignments.filter(
17
- (assignment) =>
18
- assignment.subjectId === '*' &&
19
- !(
20
- assignment.subjectType === 'authenticated' &&
21
- assignment.permissionSetKey === 'member'
22
- ),
23
- );
14
+ return assignments.filter((assignment) => assignment.subjectId === '*');
24
15
  }
25
16
 
26
17
  const test = createAppTest({
@@ -78,5 +69,13 @@ it('detects wildcard recipients regardless of the role name, while preserving ex
78
69
  global,
79
70
  { ...global, subjectType: 'everyone' },
80
71
  ]),
81
- ).toEqual([global, { ...global, subjectType: 'everyone' }]);
72
+ ).toEqual([
73
+ {
74
+ subjectType: 'authenticated',
75
+ subjectId: '*',
76
+ permissionSetKey: 'member',
77
+ },
78
+ global,
79
+ { ...global, subjectType: 'everyone' },
80
+ ]);
82
81
  });
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { fileURLToPath } from 'node:url';
4
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
3
  import os from 'node:os';
@@ -0,0 +1,299 @@
1
+ // @vitest-environment node
2
+ import { expect } from 'vitest';
3
+ import { createAppTest } from '@nocobase/app-testing/server';
4
+ import {
5
+ signIn,
6
+ DEFAULT_ADMIN_CREDENTIALS,
7
+ } from '@nocobase/app-plugin-authentication/testing';
8
+ import { userManagementServiceToken } from '@nocobase/app-plugin-users/server/tokens';
9
+ import {
10
+ authenticationToken,
11
+ userAdministrationServiceToken,
12
+ } from '@nocobase/app-plugin-authentication';
13
+ import { authorizationToken } from '@nocobase/app-plugin-authorization';
14
+ import { createStandaloneServer } from '../../server/standalone.js';
15
+
16
+ const test = createAppTest({
17
+ createServer: createStandaloneServer,
18
+ config: {
19
+ app: { publicOrigin: 'http://localhost' },
20
+ auth: { secret: 'test-auth-secret-at-least-32-characters' },
21
+ },
22
+ });
23
+ const json = (body: unknown, method = 'POST') => ({
24
+ method,
25
+ headers: { 'content-type': 'application/json', origin: 'http://localhost' },
26
+ body: JSON.stringify(body),
27
+ });
28
+
29
+ test('saved roles control direct API access while account basics remain usable', async ({
30
+ testApp,
31
+ app,
32
+ }) => {
33
+ const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
34
+ const sets = app.container.resolve(authorizationToken).permissionSets;
35
+ const grants = [
36
+ { resource: { type: 'user', id: '*' }, actions: [{ action: 'read' }] },
37
+ ];
38
+ await sets.update('member', { key: 'member', grants });
39
+ await sets.create({ key: 'reader', grants });
40
+ const create = async (
41
+ name: string,
42
+ roleScopes?: Record<string, string[]>,
43
+ ) => {
44
+ const response = await admin.fetch(
45
+ '/users',
46
+ json({
47
+ name,
48
+ username: name,
49
+ email: `${name}@example.test`,
50
+ password: 'Member-test-123!',
51
+ ...(roleScopes === undefined ? {} : { roleScopes }),
52
+ }),
53
+ );
54
+ expect(response.status).toBe(201);
55
+ return (await response.json()).data;
56
+ };
57
+ const member = await create('membercase');
58
+ expect(member.roleScopes.app).toEqual(['member']);
59
+ expect((await create('emptycase', { app: [] })).roleScopes.app).toEqual([]);
60
+ expect(
61
+ (await create('replacecase', { app: ['reader'] })).roleScopes.app,
62
+ ).toEqual(['reader']);
63
+ expect(
64
+ (
65
+ await create('appendcase', { app: ['member', 'reader'] })
66
+ ).roleScopes.app.sort(),
67
+ ).toEqual(['member', 'reader']);
68
+ expect((await create('omittedscope', {})).roleScopes.app).toEqual(['member']);
69
+ let session = await signIn(testApp, {
70
+ username: 'membercase',
71
+ password: 'Member-test-123!',
72
+ });
73
+ expect((await session.fetch('/users')).status).toBe(200);
74
+ expect(
75
+ (
76
+ await admin.fetch(
77
+ `/users/${member.id}`,
78
+ json({ roleScopes: { app: [] } }, 'PATCH'),
79
+ )
80
+ ).status,
81
+ ).toBe(200);
82
+ expect((await session.fetch('/users')).status).toBe(403);
83
+ expect(
84
+ (
85
+ await admin.fetch(
86
+ `/users/${member.id}`,
87
+ json({ name: 'Updated member' }, 'PATCH'),
88
+ )
89
+ ).status,
90
+ ).toBe(200);
91
+ expect(
92
+ (await (await admin.fetch(`/users/${member.id}`)).json()).data.roleScopes
93
+ .app,
94
+ ).toEqual([]);
95
+ session = await signIn(testApp, {
96
+ username: 'membercase',
97
+ password: 'Member-test-123!',
98
+ });
99
+ expect((await session.fetch('/users')).status).toBe(403);
100
+ expect((await session.fetch('/users/me/preferences')).status).toBe(200);
101
+ expect(
102
+ (
103
+ await session.fetch(
104
+ `/users/${member.id}`,
105
+ json({ roleScopes: { app: ['root'] } }, 'PATCH'),
106
+ )
107
+ ).status,
108
+ ).toBe(403);
109
+ expect(
110
+ (
111
+ await session.fetch(
112
+ `/users/${admin.user.id}`,
113
+ json({ name: 'Forbidden' }, 'PATCH'),
114
+ )
115
+ ).status,
116
+ ).toBe(403);
117
+ expect(
118
+ (
119
+ await session.fetch(
120
+ '/auth/change-password',
121
+ json({
122
+ currentPassword: 'Member-test-123!',
123
+ newPassword: 'Changed-test-123!',
124
+ }),
125
+ )
126
+ ).status,
127
+ ).toBe(200);
128
+ expect((await session.fetch('/auth/sign-out', json({}))).status).toBe(200);
129
+ session = await signIn(testApp, {
130
+ username: 'membercase',
131
+ password: 'Changed-test-123!',
132
+ });
133
+ expect((await session.fetch('/users')).status).toBe(403);
134
+ expect(
135
+ (
136
+ await admin.fetch(
137
+ `/users/${member.id}`,
138
+ json({ roleScopes: { app: ['reader'] } }, 'PATCH'),
139
+ )
140
+ ).status,
141
+ ).toBe(200);
142
+ expect((await session.fetch('/users')).status).toBe(200);
143
+ const protectedRole = await admin.fetch(
144
+ `/users/${member.id}`,
145
+ json({ roleScopes: { app: ['root'] } }, 'PATCH'),
146
+ );
147
+ expect(protectedRole.status).toBe(400);
148
+ expect((await protectedRole.json()).error.reason).toBe(
149
+ 'PROTECTED_ROLE_ASSIGNMENT',
150
+ );
151
+ await expect(
152
+ sets.assign({
153
+ subject: { type: 'authenticated', id: '*' },
154
+ permissionSet: 'member',
155
+ }),
156
+ ).rejects.toThrow();
157
+ });
158
+
159
+ test('registration and trusted external identity creation receive defaults atomically', async ({
160
+ testApp,
161
+ app,
162
+ connection,
163
+ }) => {
164
+ const response = await testApp.request(
165
+ '/auth/sign-up/email',
166
+ json({
167
+ name: 'Signup',
168
+ email: 'signup@example.test',
169
+ password: 'Signup-test-123!',
170
+ roleScopes: { app: ['root'] },
171
+ }),
172
+ );
173
+ expect(response.status).toBe(200);
174
+ const registered = (await response.json()).user;
175
+ const roles = async (id: string) =>
176
+ (
177
+ await connection.query
178
+ .selectFrom('authorizationPermissionSetAssignments')
179
+ .select('permissionSetKey')
180
+ .where('subjectType', '=', 'user')
181
+ .where('subjectId', '=', id)
182
+ .execute()
183
+ ).map((r) => r.permissionSetKey);
184
+ expect(await roles(registered.id)).toEqual(['member']);
185
+ const auth = app.container.resolve(authenticationToken);
186
+ const context = await auth.administrationContext();
187
+ const external = await context.internalAdapter.createUser({
188
+ name: 'External',
189
+ email: 'external@example.test',
190
+ emailVerified: true,
191
+ });
192
+ expect(await roles(external.id)).toEqual(['member']);
193
+ const service = await app.container
194
+ .resolve(userAdministrationServiceToken)
195
+ .createServiceAccount({ name: 'Service verification' });
196
+ expect(await roles(service.id)).toEqual([]);
197
+ const remove = auth.addUserInitializer(async () => {
198
+ throw new Error('Synthetic initialization failure');
199
+ });
200
+ try {
201
+ await expect(
202
+ context.internalAdapter.createUser({
203
+ name: 'Failed',
204
+ email: 'failed@example.test',
205
+ emailVerified: true,
206
+ }),
207
+ ).rejects.toThrow('Synthetic initialization failure');
208
+ expect(
209
+ await connection.query
210
+ .selectFrom('user')
211
+ .select('id')
212
+ .where('email', '=', 'failed@example.test')
213
+ .execute(),
214
+ ).toEqual([]);
215
+ } finally {
216
+ remove();
217
+ }
218
+ });
219
+
220
+ test('invitations preserve explicit empty roles and existing account decisions', async ({
221
+ testApp,
222
+ app,
223
+ }) => {
224
+ const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
225
+ const users = app.container.resolve(userManagementServiceToken);
226
+ await app.container.resolve(authorizationToken).permissionSets.create({
227
+ key: 'invitation-reader',
228
+ grants: [],
229
+ });
230
+ for (const [name, scopes, expected] of [
231
+ ['invite-default', undefined, ['member']],
232
+ ['invite-empty', { app: [] }, []],
233
+ ['invite-replace', { app: ['invitation-reader'] }, ['invitation-reader']],
234
+ ] as const) {
235
+ const [invitation] = await users.invite({
236
+ emails: [`${name}@example.test`],
237
+ invitedBy: admin.user.id,
238
+ origin: 'http://localhost',
239
+ sendEmail: false,
240
+ ...(scopes ? { roleScopes: scopes } : {}),
241
+ });
242
+ const token = invitation.inviteUrl!.split('/').at(-1)!;
243
+ const accepted = await users.acceptInvitation({
244
+ token,
245
+ name,
246
+ password: 'Invite-test-123!',
247
+ });
248
+ expect((await users.get(accepted.userId)).roleScopes.app).toEqual(expected);
249
+ await expect(
250
+ users.acceptInvitation({ token, name, password: 'Invite-test-123!' }),
251
+ ).rejects.toThrow();
252
+ await users.update(accepted.userId, { roleScopes: { app: [] } });
253
+ const [again] = await users.invite({
254
+ emails: [`${name}@example.test`],
255
+ invitedBy: admin.user.id,
256
+ origin: 'http://localhost',
257
+ sendEmail: false,
258
+ });
259
+ expect(again.outcome).toBe('existingUser');
260
+ expect((await users.get(accepted.userId)).roleScopes.app).toEqual([]);
261
+ }
262
+ const [pending] = await users.invite({
263
+ emails: ['preaccepted@example.test'],
264
+ invitedBy: admin.user.id,
265
+ origin: 'http://localhost',
266
+ sendEmail: false,
267
+ });
268
+ const existing = await users.create({
269
+ name: 'Existing',
270
+ email: 'preaccepted@example.test',
271
+ password: 'Existing-test-123!',
272
+ roleScopes: { app: [] },
273
+ });
274
+ await users.acceptInvitation(
275
+ {
276
+ token: pending.inviteUrl!.split('/').at(-1)!,
277
+ name: 'Existing',
278
+ password: 'Existing-test-123!',
279
+ },
280
+ existing.id,
281
+ );
282
+ expect((await users.get(existing.id)).roleScopes.app).toEqual([]);
283
+ const created = await Promise.all(
284
+ Array.from({ length: 4 }, (_, index) =>
285
+ users.create({
286
+ name: `Concurrent ${index}`,
287
+ email: `concurrent-${index}@example.test`,
288
+ password: 'Concurrent-test-123!',
289
+ ...(index % 2 ? { roleScopes: { app: [] } } : {}),
290
+ }),
291
+ ),
292
+ );
293
+ expect(created.map((user) => user.roleScopes.app)).toEqual([
294
+ ['member'],
295
+ [],
296
+ ['member'],
297
+ [],
298
+ ]);
299
+ });
@@ -1,5 +1,3 @@
1
- // @vitest-environment node
2
-
3
1
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
2
  import { tmpdir } from 'node:os';
5
3
  import path from 'node:path';
@@ -0,0 +1,8 @@
1
+ import { builtinEnvironments, type Environment } from 'vitest/runtime';
2
+
3
+ // Render in jsdom while loading the in-process application's Node workers as server modules.
4
+ export default {
5
+ ...builtinEnvironments.jsdom,
6
+ name: 'application',
7
+ viteEnvironment: 'ssr',
8
+ } satisfies Environment;
@@ -8,8 +8,6 @@ const RUNTIME_CONFIG_ELEMENT_ID = 'nocobase-runtime-config';
8
8
  * at `/main`. A test that needs other values removes it and renders its own.
9
9
  */
10
10
  function renderClientConfig(): void {
11
- // Server-side tests run under `@vitest-environment node`, where there is no page to render into.
12
- if (typeof document === 'undefined') return;
13
11
  if (document.getElementById(RUNTIME_CONFIG_ELEMENT_ID)) return;
14
12
  const element = document.createElement('script');
15
13
  element.id = RUNTIME_CONFIG_ELEMENT_ID;
package/vitest.config.ts CHANGED
@@ -1,34 +1,49 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
 
3
+ import { createNodeVitestConfig } from '@nocobase/dev-config/vitest/node';
3
4
  import { createReactVitestConfig } from '@nocobase/dev-config/vitest/react';
5
+ import { defineConfig } from 'vitest/config';
4
6
 
5
7
  const root = fileURLToPath(new URL('.', import.meta.url));
6
8
 
7
- export default createReactVitestConfig({
9
+ export default defineConfig({
8
10
  test: {
9
11
  root,
10
- server: {
11
- deps: {
12
- // Transform the public Client surfaces so vi.mock applies, without duplicating the Server packages' identity tokens.
13
- inline: [
14
- // The published fixture mounts MemoryRouter; transform it with the page to share the router context.
15
- /@nocobase\/app-testing\/(?:dist\/)?src\/client\//u,
16
- // AppClientProviders includes the navigation guard and must use that same router context.
17
- /@nocobase\/app-client\//u,
18
- // Client plugins in every scope, including @acme/app-plugin-*, must share the transformed runtime.
19
- // Keep server/shared modules external so their service identities remain unchanged.
20
- /@[^/]+\/app-plugin-[^/]+\/(?:dist\/)?client\//u,
21
- ],
22
- },
23
- },
24
- // A glob rather than a list of filenames. The list had to be edited by hand for every test added or removed and
25
- // silently drifted: it named a file that no longer existed while several real test files were absent from it, so
26
- // those tests were never run at all.
27
- include: ['tests/**/*.test.{ts,tsx}'],
28
- // Playwright runs tests/playwright/ against a built application; its files are named *.test.ts too.
29
- exclude: ['tests/playwright/**'],
30
- setupFiles: ['./tests/setup/client-config.ts'],
31
- // Keep the command usable if an application intentionally removes all scaffold tests before adding its own.
12
+ projects: [
13
+ createNodeVitestConfig({
14
+ test: {
15
+ root,
16
+ name: 'node',
17
+ include: ['tests/{server,database,cli,project}/**/*.test.{ts,tsx}'],
18
+ // Database fixtures and command output capture run one file at a time.
19
+ fileParallelism: false,
20
+ },
21
+ }),
22
+ createReactVitestConfig({
23
+ test: {
24
+ root,
25
+ name: 'client',
26
+ environment: './tests/setup/application-environment.ts',
27
+ // Client integration tests also start isolated application databases.
28
+ fileParallelism: false,
29
+ include: ['tests/client/**/*.test.{ts,tsx}'],
30
+ setupFiles: ['./tests/setup/client-config.ts'],
31
+ server: {
32
+ deps: {
33
+ // Keep the fixture, application and plugin clients in the same React/router context.
34
+ // Server and shared modules stay external to preserve their service identities.
35
+ inline: [
36
+ /@nocobase\/app-testing\/(?:dist\/)?src\/client\//u,
37
+ /@nocobase\/app-client\//u,
38
+ /@[^/]+\/app-plugin-[^/]+\/(?:dist\/)?client\//u,
39
+ ],
40
+ },
41
+ },
42
+ },
43
+ }),
44
+ ],
45
+ // Only the directories above contain Vitest suites. Playwright has its own runner.
46
+ // Keep the command usable if an application intentionally removes all scaffold tests.
32
47
  passWithNoTests: true,
33
48
  coverage: {
34
49
  provider: 'v8',