@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
|
@@ -1,36 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
createTestI18nRuntime,
|
|
4
|
-
} from '@nocobase/i18n/testing';
|
|
5
|
-
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
1
|
+
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
6
3
|
import userEvent from '@testing-library/user-event';
|
|
7
|
-
import type { ReactElement, ReactNode } from 'react';
|
|
8
4
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
5
|
|
|
6
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
10
7
|
import enUS from '../../client/locales/en-US.js';
|
|
11
8
|
import zhCN from '../../client/locales/zh-CN.js';
|
|
12
9
|
import HomePage from '../../client/pages/home/index.js';
|
|
13
10
|
import { CAPABILITIES } from '../../client/pages/home/capabilities.js';
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
...(await original<typeof import('@nocobase/app-client')>()),
|
|
21
|
-
useToaster: () => toaster,
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
// Strict: a key the locale lacks fails the test instead of rendering as text.
|
|
25
|
-
const runtime = await createTestI18nRuntime({
|
|
26
|
-
application: {
|
|
27
|
-
namespace: '@nocobase/app-template-default',
|
|
28
|
-
resources: enUS,
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
function I18n({ children }: { readonly children: ReactNode }): ReactElement {
|
|
33
|
-
return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
|
|
12
|
+
function renderHome() {
|
|
13
|
+
return renderWithApp(<HomePage />, {
|
|
14
|
+
namespace: packageMetadata.name,
|
|
15
|
+
namespaces: { [packageMetadata.name]: enUS },
|
|
16
|
+
});
|
|
34
17
|
}
|
|
35
18
|
|
|
36
19
|
const writeText = vi.fn<(text: string) => Promise<void>>();
|
|
@@ -53,12 +36,11 @@ function setup(): ReturnType<typeof userEvent.setup> {
|
|
|
53
36
|
|
|
54
37
|
describe('home page', () => {
|
|
55
38
|
beforeEach(() => {
|
|
56
|
-
toaster.show.mockReset();
|
|
57
39
|
clipboard.fail = false;
|
|
58
40
|
});
|
|
59
41
|
|
|
60
|
-
it('tells people to build by asking their coding agent', () => {
|
|
61
|
-
|
|
42
|
+
it('tells people to build by asking their coding agent', async () => {
|
|
43
|
+
await renderHome();
|
|
62
44
|
|
|
63
45
|
expect(
|
|
64
46
|
screen.getByRole('heading', { level: 1, name: enUS.home.title }),
|
|
@@ -66,8 +48,8 @@ describe('home page', () => {
|
|
|
66
48
|
expect(screen.getByText(enUS.home.steps.describe.title)).toBeVisible();
|
|
67
49
|
});
|
|
68
50
|
|
|
69
|
-
it('shows one card per built-in capability', () => {
|
|
70
|
-
|
|
51
|
+
it('shows one card per built-in capability', async () => {
|
|
52
|
+
await renderHome();
|
|
71
53
|
|
|
72
54
|
for (const { id } of CAPABILITIES) {
|
|
73
55
|
const title =
|
|
@@ -80,7 +62,7 @@ describe('home page', () => {
|
|
|
80
62
|
|
|
81
63
|
it('opens a capability, switches prompts, and copies the edited text', async () => {
|
|
82
64
|
const user = setup();
|
|
83
|
-
|
|
65
|
+
const view = await renderHome();
|
|
84
66
|
const scheduler = enUS.home.capabilities.scheduler;
|
|
85
67
|
|
|
86
68
|
await user.click(
|
|
@@ -106,10 +88,12 @@ describe('home page', () => {
|
|
|
106
88
|
);
|
|
107
89
|
|
|
108
90
|
expect(writeText).toHaveBeenCalledWith('Every Friday');
|
|
109
|
-
expect(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
91
|
+
expect(view.toasts()).toEqual([
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
type: 'success',
|
|
94
|
+
title: enUS.home.prompt.copied,
|
|
95
|
+
}),
|
|
96
|
+
]);
|
|
113
97
|
|
|
114
98
|
await user.click(
|
|
115
99
|
within(dialog).getByRole('button', { name: enUS.home.prompt.reset }),
|
|
@@ -119,7 +103,7 @@ describe('home page', () => {
|
|
|
119
103
|
|
|
120
104
|
it('starts from the default prompt each time the dialog opens', async () => {
|
|
121
105
|
const user = setup();
|
|
122
|
-
|
|
106
|
+
await renderHome();
|
|
123
107
|
const theme = enUS.home.capabilities.theme;
|
|
124
108
|
const card = screen.getByRole('button', { name: new RegExp(theme.title) });
|
|
125
109
|
|
|
@@ -142,16 +126,18 @@ describe('home page', () => {
|
|
|
142
126
|
it('reports a copy the browser refuses', async () => {
|
|
143
127
|
clipboard.fail = true;
|
|
144
128
|
const user = setup();
|
|
145
|
-
|
|
129
|
+
const view = await renderHome();
|
|
146
130
|
|
|
147
131
|
await user.click(
|
|
148
132
|
screen.getAllByRole('button', { name: enUS.home.prompt.copy })[0],
|
|
149
133
|
);
|
|
150
134
|
|
|
151
|
-
expect(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
135
|
+
expect(view.toasts()).toEqual([
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
type: 'error',
|
|
138
|
+
title: enUS.home.prompt.copyFailed,
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
155
141
|
});
|
|
156
142
|
});
|
|
157
143
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { realtimeClientToken } from '@nocobase/app-client';
|
|
2
|
+
import {
|
|
3
|
+
AuthenticationProvider,
|
|
4
|
+
authenticationClientToken,
|
|
5
|
+
} from '@nocobase/app-plugin-authentication/client';
|
|
6
|
+
import {
|
|
7
|
+
answerApi,
|
|
8
|
+
renderWithApp,
|
|
9
|
+
type ApiCall,
|
|
10
|
+
} from '@nocobase/app-testing/client';
|
|
11
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import { expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
15
|
+
import locales from '../../client/locales/index.js';
|
|
16
|
+
import { TooltipProvider } from '../../client/components/ui/tooltip';
|
|
17
|
+
import InboxPage from '../../client/pages/inbox.tsx';
|
|
18
|
+
|
|
19
|
+
const messages = [
|
|
20
|
+
{
|
|
21
|
+
id: 'm1',
|
|
22
|
+
deliveryId: 'd1',
|
|
23
|
+
notificationId: 'n1',
|
|
24
|
+
title: 'Weekly report is ready',
|
|
25
|
+
body: 'The sales report for week 40 is ready.',
|
|
26
|
+
createdAt: '2026-10-01T08:00:00.000Z',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'm2',
|
|
30
|
+
deliveryId: 'd2',
|
|
31
|
+
notificationId: 'n2',
|
|
32
|
+
title: 'Backup finished',
|
|
33
|
+
body: 'All databases were backed up.',
|
|
34
|
+
readAt: '2026-10-01T09:00:00.000Z',
|
|
35
|
+
createdAt: '2026-10-01T07:00:00.000Z',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const api = vi.fn(({ path }: ApiCall) => {
|
|
40
|
+
if (path === 'notificationInApp/unreadCount') return { data: { count: 2 } };
|
|
41
|
+
if (path === 'notificationInApp/messages')
|
|
42
|
+
return { data: messages, meta: {} };
|
|
43
|
+
if (path === 'notificationInApp/messages/m1/markRead') {
|
|
44
|
+
return { data: { ...messages[0], readAt: '2026-10-01T10:00:00.000Z' } };
|
|
45
|
+
}
|
|
46
|
+
return new Response(null, { status: 404 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function renderInbox() {
|
|
50
|
+
return renderWithApp(
|
|
51
|
+
<AuthenticationProvider>
|
|
52
|
+
<TooltipProvider>
|
|
53
|
+
<InboxPage />
|
|
54
|
+
</TooltipProvider>
|
|
55
|
+
</AuthenticationProvider>,
|
|
56
|
+
{
|
|
57
|
+
route: '/inbox',
|
|
58
|
+
namespace: packageMetadata.name,
|
|
59
|
+
namespaces: { [packageMetadata.name]: locales },
|
|
60
|
+
fetch: answerApi(api),
|
|
61
|
+
services: (app) => {
|
|
62
|
+
app.container.instance(authenticationClientToken, {
|
|
63
|
+
getSession: async () => ({
|
|
64
|
+
data: { user: { id: 'user-1' }, session: { id: 'session-1' } },
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
// Keep realtime hooks real while preventing a WebSocket connection in this page test.
|
|
68
|
+
const realtime = app.container.resolve(realtimeClientToken);
|
|
69
|
+
vi.spyOn(realtime, 'watch').mockReturnValue({ close: vi.fn() });
|
|
70
|
+
vi.spyOn(realtime, 'subscribe').mockReturnValue(vi.fn());
|
|
71
|
+
vi.spyOn(realtime, 'onOpen').mockReturnValue(vi.fn());
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it("lists the plugin's messages as notifications and marks one read when it is opened", async () => {
|
|
78
|
+
await renderInbox();
|
|
79
|
+
expect(
|
|
80
|
+
await screen.findByRole('heading', { name: /Notifications/u }),
|
|
81
|
+
).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByText('Backup finished')).toBeInTheDocument();
|
|
83
|
+
// The first message is shown, and opening it marks it read.
|
|
84
|
+
expect(
|
|
85
|
+
screen.getByRole('heading', { name: 'Weekly report is ready', level: 2 }),
|
|
86
|
+
).toBeInTheDocument();
|
|
87
|
+
fireEvent.click(screen.getAllByText('Weekly report is ready')[0]!);
|
|
88
|
+
await waitFor(() =>
|
|
89
|
+
expect(api).toHaveBeenCalledWith({
|
|
90
|
+
path: 'notificationInApp/messages/m1/markRead',
|
|
91
|
+
method: 'POST',
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
@@ -1,34 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
2
2
|
import { screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import { Route, Routes, useLocation } from 'react-router';
|
|
5
|
-
import {
|
|
5
|
+
import { expect, it } from 'vitest';
|
|
6
6
|
import { PermissionManagementPage } from '../../client/pages/settings/permission-management-page.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
const namespaces = {
|
|
23
|
-
[namespace]: {
|
|
24
|
-
'en-US': async () => ({ default: enUS }),
|
|
25
|
-
'zh-CN': async () => ({ default: zhCN }),
|
|
26
|
-
},
|
|
27
|
-
};
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
access.roles = true;
|
|
30
|
-
access.defaults = true;
|
|
31
|
-
});
|
|
7
|
+
|
|
8
|
+
function mount(prefix: string, tab = 'roles', roles = true, locale = 'en-US') {
|
|
9
|
+
return renderAuthenticatedClient(tree(prefix), {
|
|
10
|
+
route: `${prefix}/${tab}`,
|
|
11
|
+
locale,
|
|
12
|
+
member: true,
|
|
13
|
+
grants: [
|
|
14
|
+
'authorization.default-access',
|
|
15
|
+
...(roles ? ['authorization.permission-sets'] : []),
|
|
16
|
+
].map((id) => ({
|
|
17
|
+
resource: { type: 'settings', id },
|
|
18
|
+
actions: [{ action: 'read' }],
|
|
19
|
+
})),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
32
22
|
function Content({ tab }: { tab: 'roles' | 'default-access' }) {
|
|
33
23
|
const location = useLocation();
|
|
34
24
|
return (
|
|
@@ -55,11 +45,8 @@ it.each(['', '/settings'])(
|
|
|
55
45
|
'uses two URL-backed tabs under one heading, preserves the existing %s route prefix and keyboard focus',
|
|
56
46
|
async (prefix) => {
|
|
57
47
|
const user = userEvent.setup();
|
|
58
|
-
await
|
|
59
|
-
|
|
60
|
-
namespace,
|
|
61
|
-
namespaces,
|
|
62
|
-
});
|
|
48
|
+
await mount(prefix);
|
|
49
|
+
await screen.findByRole('tab', { name: 'Roles and permissions' });
|
|
63
50
|
expect(
|
|
64
51
|
screen.getByRole('heading', { name: 'Permission management' }),
|
|
65
52
|
).toBeVisible();
|
|
@@ -99,12 +86,8 @@ it.each(['', '/settings'])(
|
|
|
99
86
|
},
|
|
100
87
|
);
|
|
101
88
|
it('hides an unreadable tab while keeping direct navigation on the readable default scope page', async () => {
|
|
102
|
-
access
|
|
103
|
-
await
|
|
104
|
-
route: '/default-access',
|
|
105
|
-
namespace,
|
|
106
|
-
namespaces,
|
|
107
|
-
});
|
|
89
|
+
await mount('', 'default-access', false);
|
|
90
|
+
await screen.findByRole('tab', { name: 'Default data scopes' });
|
|
108
91
|
expect(
|
|
109
92
|
screen.queryByRole('tab', { name: 'Roles and permissions' }),
|
|
110
93
|
).not.toBeInTheDocument();
|
|
@@ -116,12 +99,8 @@ it('hides an unreadable tab while keeping direct navigation on the readable defa
|
|
|
116
99
|
);
|
|
117
100
|
});
|
|
118
101
|
it('uses the agreed Chinese menu and tab names', async () => {
|
|
119
|
-
await
|
|
120
|
-
|
|
121
|
-
namespace,
|
|
122
|
-
namespaces,
|
|
123
|
-
locale: 'zh-CN',
|
|
124
|
-
});
|
|
102
|
+
await mount('', 'roles', true, 'zh-CN');
|
|
103
|
+
await screen.findByRole('tab', { name: '角色与权限' });
|
|
125
104
|
expect(screen.getByRole('heading', { name: '权限管理' })).toBeVisible();
|
|
126
105
|
expect(screen.getByRole('tab', { name: '角色与权限' })).toBeVisible();
|
|
127
106
|
expect(screen.getByRole('tab', { name: '默认数据范围' })).toBeVisible();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppSidebarPreferenceStore,
|
|
3
|
+
SidebarPreferenceProvider,
|
|
4
|
+
} from '../../client/layouts/use-sidebar-preference.js';
|
|
5
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
6
|
+
import { act, screen, waitFor } from '@testing-library/react';
|
|
7
|
+
import { apiClientToken } from '@nocobase/app-client';
|
|
8
|
+
import {
|
|
9
|
+
AuthorizationClient,
|
|
10
|
+
authorizationClientToken,
|
|
11
|
+
type AuthorizationSnapshot,
|
|
12
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
13
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
14
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
15
|
+
import enUS from '../../client/locales/en-US.js';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
import {
|
|
18
|
+
useRouteNavigation,
|
|
19
|
+
routeKey,
|
|
20
|
+
} from '../../client/routing/route-navigation';
|
|
21
|
+
import { NavigationMenu } from '../../client/layouts/components/navigation-menu';
|
|
22
|
+
import { AppSidebarProvider } from '../../client/layouts/components/app-sidebar';
|
|
23
|
+
|
|
24
|
+
const sidebarPreference = createAppSidebarPreferenceStore('/main');
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
localStorage.clear();
|
|
28
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
29
|
+
matches: false,
|
|
30
|
+
addEventListener: vi.fn(),
|
|
31
|
+
removeEventListener: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
35
|
+
function page(name: string): AppClientRegisteredRoute {
|
|
36
|
+
return {
|
|
37
|
+
id: name,
|
|
38
|
+
name,
|
|
39
|
+
path: `/${name}`,
|
|
40
|
+
packageName: 'test',
|
|
41
|
+
source: 'application',
|
|
42
|
+
auth: 'required',
|
|
43
|
+
authz: { resource: { type: 'page', id: name }, action: 'access' },
|
|
44
|
+
navigation: { title: name },
|
|
45
|
+
componentLoader: async () => ({ default: () => null }),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const visible = page('Allowed');
|
|
49
|
+
const hidden = page('Denied');
|
|
50
|
+
const routes: AppClientRegisteredRoute[] = [
|
|
51
|
+
{ ...page('Group'), componentLoader: undefined, children: [visible, hidden] },
|
|
52
|
+
{ ...page('Empty'), componentLoader: undefined, children: [hidden] },
|
|
53
|
+
];
|
|
54
|
+
function Menu() {
|
|
55
|
+
const { items, loading } = useRouteNavigation(routes);
|
|
56
|
+
return (
|
|
57
|
+
<section aria-label='Permission status' aria-busy={loading}>
|
|
58
|
+
<NavigationMenu
|
|
59
|
+
items={items}
|
|
60
|
+
label='Navigation'
|
|
61
|
+
selectedKey={routeKey(visible)}
|
|
62
|
+
/>
|
|
63
|
+
</section>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
describe.each(['expanded', 'collapsed', 'mobile'])(
|
|
67
|
+
'permissions in %s navigation',
|
|
68
|
+
(mode) => {
|
|
69
|
+
it('hides pending and denied entries, removes empty groups, and refreshes open menus', async () => {
|
|
70
|
+
if (mode === 'mobile') vi.stubGlobal('innerWidth', 390);
|
|
71
|
+
if (mode === 'collapsed')
|
|
72
|
+
localStorage.setItem('nocobase:sidebar:collapsed:app:/main', 'true');
|
|
73
|
+
const response = Promise.withResolvers<{ data: AuthorizationSnapshot }>();
|
|
74
|
+
let permissions: AuthorizationSnapshot = {
|
|
75
|
+
unrestricted: false,
|
|
76
|
+
permissions: [],
|
|
77
|
+
};
|
|
78
|
+
let pending = true;
|
|
79
|
+
const { app } = await renderWithApp(
|
|
80
|
+
<SidebarPreferenceProvider value={sidebarPreference}>
|
|
81
|
+
<AppSidebarProvider>
|
|
82
|
+
<Menu />
|
|
83
|
+
</AppSidebarProvider>
|
|
84
|
+
</SidebarPreferenceProvider>,
|
|
85
|
+
{
|
|
86
|
+
namespace: packageMetadata.name,
|
|
87
|
+
namespaces: {
|
|
88
|
+
[packageMetadata.name]: enUS,
|
|
89
|
+
test: {
|
|
90
|
+
Allowed: 'Allowed',
|
|
91
|
+
Denied: 'Denied',
|
|
92
|
+
Group: 'Group',
|
|
93
|
+
Empty: 'Empty',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
fetch: answerApi(({ path }) =>
|
|
97
|
+
path === 'authorization/permissions'
|
|
98
|
+
? pending
|
|
99
|
+
? response.promise
|
|
100
|
+
: { data: permissions }
|
|
101
|
+
: new Response(null, { status: 404 }),
|
|
102
|
+
),
|
|
103
|
+
services: (app) => {
|
|
104
|
+
app.container.singleton(
|
|
105
|
+
authorizationClientToken,
|
|
106
|
+
(resolver) =>
|
|
107
|
+
new AuthorizationClient(resolver.resolve(apiClientToken)),
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
expect(screen.queryByText('Group')).not.toBeInTheDocument();
|
|
113
|
+
await act(async () => {
|
|
114
|
+
permissions = {
|
|
115
|
+
unrestricted: false,
|
|
116
|
+
permissions: [
|
|
117
|
+
{ resource: { type: 'page', id: 'Allowed' }, actions: ['access'] },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
pending = false;
|
|
121
|
+
response.resolve({ data: permissions });
|
|
122
|
+
});
|
|
123
|
+
// Group is a section: its entries show in every mode, as icons in the collapsed sidebar.
|
|
124
|
+
expect(
|
|
125
|
+
await screen.findByRole('link', { name: 'Allowed' }),
|
|
126
|
+
).toHaveAttribute('href', '/Allowed');
|
|
127
|
+
expect(screen.queryByText('Denied')).not.toBeInTheDocument();
|
|
128
|
+
expect(screen.queryByText('Empty')).not.toBeInTheDocument();
|
|
129
|
+
const allowedPermissions = permissions;
|
|
130
|
+
await act(async () => {
|
|
131
|
+
permissions = { unrestricted: false, permissions: [] };
|
|
132
|
+
const authorization = app.container.resolve(authorizationClientToken);
|
|
133
|
+
authorization.invalidate();
|
|
134
|
+
await authorization.snapshot();
|
|
135
|
+
});
|
|
136
|
+
await waitFor(() =>
|
|
137
|
+
expect(
|
|
138
|
+
screen.getByRole('region', { name: 'Permission status' }),
|
|
139
|
+
).toHaveAttribute('aria-busy', 'false'),
|
|
140
|
+
);
|
|
141
|
+
expect(screen.queryByText('Group')).not.toBeInTheDocument();
|
|
142
|
+
expect(
|
|
143
|
+
screen.queryByRole('link', { name: 'Allowed' }),
|
|
144
|
+
).not.toBeInTheDocument();
|
|
145
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
146
|
+
await act(async () => {
|
|
147
|
+
permissions = allowedPermissions;
|
|
148
|
+
const authorization = app.container.resolve(authorizationClientToken);
|
|
149
|
+
authorization.invalidate();
|
|
150
|
+
await authorization.snapshot();
|
|
151
|
+
});
|
|
152
|
+
expect(
|
|
153
|
+
await screen.findByRole('link', { name: 'Allowed' }),
|
|
154
|
+
).toBeVisible();
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
);
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import path from 'node:path';
|
|
3
2
|
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
3
|
import tailwind from '@tailwindcss/postcss';
|
|
6
4
|
import postcss, { type Root } from 'postcss';
|
|
7
5
|
import { beforeAll, describe, expect, it } from 'vitest';
|
|
8
6
|
|
|
9
7
|
import { themePresets } from '../../client/theme/theme-presets';
|
|
10
8
|
|
|
11
|
-
const appRoot =
|
|
9
|
+
const appRoot = path.resolve(import.meta.dirname, '../..');
|
|
12
10
|
const sizes = [
|
|
13
11
|
'xs',
|
|
14
12
|
'sm',
|
|
@@ -92,7 +90,7 @@ describe('theme token contract', () => {
|
|
|
92
90
|
const readPreset = (id: string) =>
|
|
93
91
|
postcss.parse(
|
|
94
92
|
readFileSync(
|
|
95
|
-
|
|
93
|
+
path.join(appRoot, `client/theme/themes/${id}.css`),
|
|
96
94
|
'utf8',
|
|
97
95
|
),
|
|
98
96
|
);
|
|
@@ -129,7 +127,7 @@ describe('theme token contract', () => {
|
|
|
129
127
|
it(`${id} defines complete tokens for the page and isolated previews`, () => {
|
|
130
128
|
const root = postcss.parse(
|
|
131
129
|
readFileSync(
|
|
132
|
-
|
|
130
|
+
path.join(appRoot, `client/theme/themes/${id}.css`),
|
|
133
131
|
'utf8',
|
|
134
132
|
),
|
|
135
133
|
);
|
|
@@ -170,13 +168,13 @@ describe('compiled theme utilities', () => {
|
|
|
170
168
|
'stroke-chart-5',
|
|
171
169
|
];
|
|
172
170
|
const source = readFileSync(
|
|
173
|
-
|
|
171
|
+
path.join(appRoot, 'client/styles.css'),
|
|
174
172
|
'utf8',
|
|
175
173
|
);
|
|
176
174
|
const result = await postcss([
|
|
177
175
|
tailwind({ base: appRoot, optimize: false }),
|
|
178
176
|
]).process(source + `\n@source inline("${candidates.join(' ')}");`, {
|
|
179
|
-
from:
|
|
177
|
+
from: path.join(appRoot, 'client/styles.css'),
|
|
180
178
|
});
|
|
181
179
|
compiled = result.root;
|
|
182
180
|
}, 30_000);
|
|
@@ -68,6 +68,7 @@ async function renderUsers(
|
|
|
68
68
|
actions: string[] = [],
|
|
69
69
|
initialRoles: Record<string, string[]> = {},
|
|
70
70
|
requiredRole = false,
|
|
71
|
+
defaultRole = false,
|
|
71
72
|
) {
|
|
72
73
|
let current = { ...original, roleScopes: initialRoles };
|
|
73
74
|
let roleFailure = false;
|
|
@@ -105,7 +106,9 @@ async function renderUsers(
|
|
|
105
106
|
label: 'Application',
|
|
106
107
|
selection: 'multiple',
|
|
107
108
|
requiredOnCreate: requiredRole,
|
|
109
|
+
...(defaultRole ? { defaultValue: ['member'] } : {}),
|
|
108
110
|
options: [
|
|
111
|
+
{ value: 'member', label: 'Member' },
|
|
109
112
|
{ value: 'sales', label: 'Sales team' },
|
|
110
113
|
{ value: 'finance', label: 'Finance team' },
|
|
111
114
|
{
|
|
@@ -535,3 +538,48 @@ it('focuses and describes the required role dropdown when creating without a rol
|
|
|
535
538
|
view.calls.mock.calls.filter(([call]) => call.method === 'POST'),
|
|
536
539
|
).toHaveLength(0);
|
|
537
540
|
});
|
|
541
|
+
|
|
542
|
+
it('uses Member only as a creation default and respects explicitly cleared selection', async () => {
|
|
543
|
+
const user = userEvent.setup();
|
|
544
|
+
const view = await renderUsers(
|
|
545
|
+
'/users',
|
|
546
|
+
'/create',
|
|
547
|
+
true,
|
|
548
|
+
true,
|
|
549
|
+
[],
|
|
550
|
+
{},
|
|
551
|
+
false,
|
|
552
|
+
true,
|
|
553
|
+
);
|
|
554
|
+
const roles = await screen.findByRole('combobox', {
|
|
555
|
+
name: 'Application',
|
|
556
|
+
exact: true,
|
|
557
|
+
});
|
|
558
|
+
expect(roles).toHaveTextContent('Member');
|
|
559
|
+
await user.click(roles);
|
|
560
|
+
await user.click(
|
|
561
|
+
await screen.findByRole('option', { name: 'Member', exact: true }),
|
|
562
|
+
);
|
|
563
|
+
await user.keyboard('{Escape}');
|
|
564
|
+
expect(roles).not.toHaveTextContent('Member');
|
|
565
|
+
expect(screen.getByText(enUS.settings.noRoleAccess)).toBeVisible();
|
|
566
|
+
await user.type(screen.getByRole('textbox', { name: 'Name *' }), 'Roleless');
|
|
567
|
+
await user.type(
|
|
568
|
+
screen.getByRole('textbox', { name: 'Email *' }),
|
|
569
|
+
'roleless@example.test',
|
|
570
|
+
);
|
|
571
|
+
await user.type(
|
|
572
|
+
screen.getByLabelText(`${enUS.settings.password} *`),
|
|
573
|
+
'Roleless-test-123!',
|
|
574
|
+
);
|
|
575
|
+
await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
|
|
576
|
+
await waitFor(() =>
|
|
577
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
578
|
+
expect.objectContaining({
|
|
579
|
+
method: 'POST',
|
|
580
|
+
path: 'users',
|
|
581
|
+
json: expect.objectContaining({ roleScopes: { app: [] } }),
|
|
582
|
+
}),
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { UserMenu } from '../../client/layouts/components/user-menu.tsx';
|
|
5
|
+
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
6
|
+
|
|
7
|
+
async function openAccountMenu(): Promise<void> {
|
|
8
|
+
act(() => screen.getByRole('button', { name: 'Open account menu' }).focus());
|
|
9
|
+
await userEvent.keyboard('{Enter}');
|
|
10
|
+
}
|
|
11
|
+
async function signOutFromMenu() {
|
|
12
|
+
await openAccountMenu();
|
|
13
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: 'Sign out' }));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('account menu sign out', () => {
|
|
17
|
+
it('ends the server session and refreshes the displayed identity', async () => {
|
|
18
|
+
const view = await renderAuthenticatedClient(<UserMenu />);
|
|
19
|
+
await signOutFromMenu();
|
|
20
|
+
await waitFor(() => expect(view.identity().session).toBeNull());
|
|
21
|
+
expect(
|
|
22
|
+
await (await view.session.fetch('/auth/get-session')).json(),
|
|
23
|
+
).toBeNull();
|
|
24
|
+
expect(view.toasts()).toHaveLength(0);
|
|
25
|
+
await openAccountMenu();
|
|
26
|
+
expect(screen.queryByText(view.session.user.email)).not.toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
it('reports a rejected origin without pretending the session ended', async () => {
|
|
29
|
+
const view = await renderAuthenticatedClient(<UserMenu />, {
|
|
30
|
+
beforeRequest(request) {
|
|
31
|
+
// Exercise the real origin guard and error resolver, not a fabricated authentication result.
|
|
32
|
+
if (request.url.endsWith('/sign-out'))
|
|
33
|
+
request.headers.set('origin', 'https://untrusted.example');
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
await signOutFromMenu();
|
|
37
|
+
await waitFor(() => expect(view.toasts()).toHaveLength(1));
|
|
38
|
+
expect(view.toasts()[0]).toMatchObject({
|
|
39
|
+
type: 'error',
|
|
40
|
+
title: expect.stringContaining('This origin is not trusted.'),
|
|
41
|
+
});
|
|
42
|
+
expect(view.identity().session?.user.id).toBe(view.session.user.id);
|
|
43
|
+
expect(
|
|
44
|
+
await (await view.session.fetch('/auth/get-session')).json(),
|
|
45
|
+
).toMatchObject({ user: { id: view.session.user.id } });
|
|
46
|
+
await openAccountMenu();
|
|
47
|
+
expect(
|
|
48
|
+
await screen.findByRole('menuitem', { name: 'Sign out' }),
|
|
49
|
+
).not.toHaveAttribute('aria-disabled', 'true');
|
|
50
|
+
});
|
|
51
|
+
it('reports a network failure and successfully retries sign-out', async () => {
|
|
52
|
+
let offline = true;
|
|
53
|
+
const view = await renderAuthenticatedClient(<UserMenu />, {
|
|
54
|
+
beforeRequest(request) {
|
|
55
|
+
// Network loss cannot be produced by the in-process server.
|
|
56
|
+
if (offline && request.url.endsWith('/sign-out'))
|
|
57
|
+
throw new TypeError('Failed to fetch');
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
await signOutFromMenu();
|
|
61
|
+
await waitFor(() => expect(view.toasts()).toHaveLength(1));
|
|
62
|
+
expect(view.toasts()[0]).toMatchObject({
|
|
63
|
+
type: 'error',
|
|
64
|
+
title: 'Could not reach the server. Check your connection and try again.',
|
|
65
|
+
});
|
|
66
|
+
expect(view.identity().session?.user.id).toBe(view.session.user.id);
|
|
67
|
+
expect(
|
|
68
|
+
await (await view.session.fetch('/auth/get-session')).json(),
|
|
69
|
+
).toMatchObject({ user: { id: view.session.user.id } });
|
|
70
|
+
offline = false;
|
|
71
|
+
await signOutFromMenu();
|
|
72
|
+
await waitFor(() => expect(view.identity().session).toBeNull());
|
|
73
|
+
expect(
|
|
74
|
+
await (await view.session.fetch('/auth/get-session')).json(),
|
|
75
|
+
).toBeNull();
|
|
76
|
+
expect(view.toasts()).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
});
|