@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.
- package/AGENTS.md +4 -4
- package/README.MD +4 -2
- package/client/locales/system/settings-en-US.ts +3 -0
- package/client/locales/system/settings-zh-CN.ts +2 -0
- package/client/pages/settings/user-form.tsx +13 -1
- package/client/pages/settings/users.tsx +1 -1
- package/package.json +8 -7
- package/tests/client/account-permissions.test.tsx +174 -0
- package/tests/{components → client}/app-layout-routes.test.tsx +25 -42
- package/tests/{logic → client}/app-locale-coverage.test.ts +1 -4
- package/tests/{logic → client}/client-plugin-registry.test.ts +1 -4
- package/tests/{logic → client}/client-route-recovery.test.tsx +27 -7
- package/tests/{components → client}/header-hover.test.tsx +4 -14
- package/tests/{components → client}/home.test.tsx +27 -41
- package/tests/client/inbox.test.tsx +94 -0
- package/tests/{components → client}/permission-management-tabs.test.tsx +23 -44
- package/tests/client/sidebar-permissions.test.tsx +157 -0
- package/tests/{logic → client}/theme-tokens.test.ts +6 -8
- package/tests/{components → client}/user-drawer.test.tsx +48 -0
- package/tests/client/user-menu-sign-out.test.tsx +78 -0
- package/tests/{logic → database}/device-code-migration.test.ts +0 -1
- package/tests/helpers/authenticated-client.tsx +148 -0
- package/tests/{logic → project}/agents-md.test.ts +0 -2
- package/tests/{logic → project}/asset-url-build.test.ts +0 -1
- package/tests/{logic → project}/config-example.test.ts +0 -2
- package/tests/{logic → project}/dev-client-watch.test.ts +0 -2
- package/tests/{logic → project}/plugin-commands.test.ts +0 -2
- package/tests/{logic → project}/server-imports.test.ts +0 -1
- package/tests/{logic → project}/tailwind-sources.test.ts +0 -1
- package/tests/{logic → project}/template-package-name.test.ts +0 -2
- package/tests/{logic → server}/app-server.test.ts +0 -1
- package/tests/{logic → server}/authorization-assignment-safety.test.ts +10 -11
- package/tests/{logic → server}/config.test.ts +0 -2
- package/tests/server/member-role.test.ts +299 -0
- package/tests/{logic → server}/session-isolation.test.ts +0 -2
- package/tests/setup/application-environment.ts +8 -0
- package/tests/setup/client-config.ts +0 -2
- package/vitest.config.ts +38 -23
- package/tests/components/inbox.test.tsx +0 -124
- package/tests/components/sidebar-permissions.test.tsx +0 -108
- package/tests/logic/account-permissions.test.tsx +0 -204
- package/tests/logic/user-menu-sign-out.test.tsx +0 -107
- /package/tests/{components → client}/app-title.test.tsx +0 -0
- /package/tests/{components → client}/auth-i18n.test.tsx +0 -0
- /package/tests/{components → client}/back-button.test.tsx +0 -0
- /package/tests/{logic → client}/client-auth.test.tsx +0 -0
- /package/tests/{logic → client}/client-routes.test.ts +0 -0
- /package/tests/{logic → client}/client-runtime.test.ts +0 -0
- /package/tests/{logic → client}/client-shell.test.tsx +0 -0
- /package/tests/{logic → client}/client-theme.test.tsx +0 -0
- /package/tests/{components → client}/data-table.test.tsx +0 -0
- /package/tests/{components → client}/default-access-editor.test.tsx +0 -0
- /package/tests/{logic → client}/default-access-model.test.ts +0 -0
- /package/tests/{components → client}/default-access.test.tsx +0 -0
- /package/tests/{components → client}/file-preview.test.tsx +0 -0
- /package/tests/{logic → client}/foundation-integration.test.ts +0 -0
- /package/tests/{components → client}/language-switcher.test.tsx +0 -0
- /package/tests/{components → client}/layout-containers.test.tsx +0 -0
- /package/tests/{components → client}/layout-i18n.test.tsx +0 -0
- /package/tests/{components → client}/loading.test.tsx +0 -0
- /package/tests/{components → client}/navigation-menu.test.tsx +0 -0
- /package/tests/{components → client}/page-harness.test.tsx +0 -0
- /package/tests/{logic → client}/page-permission-navigation.test.ts +0 -0
- /package/tests/{logic → client}/page-permission-titles.test.ts +0 -0
- /package/tests/{components → client}/permission-editor.test.tsx +0 -0
- /package/tests/{components → client}/permission-filter-binding.test.tsx +0 -0
- /package/tests/{components → client}/permission-filter.test.tsx +0 -0
- /package/tests/{logic → client}/permission-management-routes.test.ts +0 -0
- /package/tests/{logic → client}/permission-workspace.test.ts +0 -0
- /package/tests/{components → client}/primitive-labels.test.tsx +0 -0
- /package/tests/{logic → client}/return-to.test.ts +0 -0
- /package/tests/{components → client}/role-list.test.tsx +0 -0
- /package/tests/{components → client}/roles.test.tsx +0 -0
- /package/tests/{logic → client}/route-navigation.test.ts +0 -0
- /package/tests/{logic → client}/route-overlay.test.tsx +0 -0
- /package/tests/{logic → client}/scrollbar.test.ts +0 -0
- /package/tests/{logic → client}/session-preparation.test.tsx +0 -0
- /package/tests/{components → client}/settings-continuity.test.tsx +0 -0
- /package/tests/{components → client}/settings-feedback.test.tsx +0 -0
- /package/tests/{logic → client}/settings-permissions.test.ts +0 -0
- /package/tests/{components → client}/settings.test.tsx +0 -0
- /package/tests/{components → client}/setup-readiness.test.tsx +0 -0
- /package/tests/{components → client}/sidebar-preference.test.tsx +0 -0
- /package/tests/{components → client}/status-badge.test.tsx +0 -0
- /package/tests/{components → client}/table-url-state.test.tsx +0 -0
- /package/tests/{logic → client}/theme-preferences.test.ts +0 -0
- /package/tests/{logic → client}/toaster.test.tsx +0 -0
- /package/tests/{components → client}/user-permissions.test.tsx +0 -0
- /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
|
// 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,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([
|
|
72
|
+
).toEqual([
|
|
73
|
+
{
|
|
74
|
+
subjectType: 'authenticated',
|
|
75
|
+
subjectId: '*',
|
|
76
|
+
permissionSetKey: 'member',
|
|
77
|
+
},
|
|
78
|
+
global,
|
|
79
|
+
{ ...global, subjectType: 'everyone' },
|
|
80
|
+
]);
|
|
82
81
|
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
9
|
+
export default defineConfig({
|
|
8
10
|
test: {
|
|
9
11
|
root,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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',
|