@nocobase/app-template-default 1.0.0-beta.76 → 1.0.0-beta.78

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.
@@ -0,0 +1,250 @@
1
+ // @vitest-environment node
2
+
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { CookieJar } from 'jsdom';
8
+ import { Hono } from 'hono';
9
+ import { I18nRuntime } from '@nocobase/i18n';
10
+ import {
11
+ createI18nMiddleware,
12
+ getContextSession,
13
+ getRequestTranslator,
14
+ } from '@nocobase/i18n/server';
15
+ import { i18nToken } from '@nocobase/app-server/i18n';
16
+ import { resolveStandaloneAppRuntime } from '@nocobase/app-server/node';
17
+ import { createPublicBasePathAdapter } from '@nocobase/app-server/runtime';
18
+ import {
19
+ resolveAppSessionConfig,
20
+ type AppSessionConfigInput,
21
+ } from '@nocobase/app-server/session';
22
+ import {
23
+ createSessionManager,
24
+ createSessionMiddleware,
25
+ type NocoBaseSessionManager,
26
+ } from '@nocobase/session';
27
+ import { ServiceContainer } from '@nocobase/service-provider';
28
+ import { i18nApiRoutes } from '@nocobase/app-plugin-i18n/server';
29
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
30
+
31
+ import appRuntime from '../../server/runtime.js';
32
+
33
+ const ORIGIN = 'http://localhost';
34
+ const SECRET = 'shared-synthetic-session-secret-at-least-32-characters';
35
+ const rootDir = fileURLToPath(new URL('../..', import.meta.url));
36
+ let directory: string;
37
+ let configPath: string;
38
+ const managers: NocoBaseSessionManager[] = [];
39
+
40
+ beforeEach(async () => {
41
+ directory = await mkdtemp(path.join(tmpdir(), 'app-session-isolation-'));
42
+ configPath = path.join(directory, 'config.json');
43
+ await writeFile(
44
+ configPath,
45
+ JSON.stringify({
46
+ session: {
47
+ secret: SECRET,
48
+ default: 'fs',
49
+ stores: {
50
+ fs: { driver: 'fs', base: path.join(directory, 'shared-sessions') },
51
+ },
52
+ gcLottery: { hits: 0, total: 100 },
53
+ },
54
+ }),
55
+ );
56
+ });
57
+
58
+ afterEach(async () => {
59
+ for (const manager of managers.splice(0)) await manager.dispose();
60
+ await rm(directory, { recursive: true, force: true });
61
+ });
62
+
63
+ async function application(basePath: string) {
64
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
65
+ rootDir,
66
+ configPath,
67
+ basePath,
68
+ env: { NODE_ENV: 'test' },
69
+ });
70
+ const session = resolveAppSessionConfig(
71
+ runtime.config.get<AppSessionConfigInput>('session')!,
72
+ SECRET,
73
+ );
74
+ const manager = createSessionManager(session);
75
+ managers.push(manager);
76
+ expect(runtime.config.get('app.publicBasePath')).toBe(
77
+ basePath.replace(/\/$/u, ''),
78
+ );
79
+ const i18n = new I18nRuntime({
80
+ defaultLocale: 'en-US',
81
+ locales: ['en-US', 'zh-CN'],
82
+ applicationNamespace: '@test/session',
83
+ });
84
+ i18n.registerApplicationNamespace('@test/session', {
85
+ 'en-US': async () => ({ greeting: 'Hello' }),
86
+ 'zh-CN': async () => ({ greeting: '你好' }),
87
+ });
88
+ await i18n.init();
89
+ const container = new ServiceContainer();
90
+ container.instance(i18nToken, i18n);
91
+ const router = new Hono();
92
+ router.use('*', createSessionMiddleware(manager), createI18nMiddleware(i18n));
93
+ router.route(
94
+ '/api',
95
+ await i18nApiRoutes.createRouter({
96
+ config: runtime.config,
97
+ container,
98
+ paths: runtime.paths,
99
+ router,
100
+ appName: runtime.appName,
101
+ publicBasePath: basePath,
102
+ }),
103
+ );
104
+ router.get('/message', (context) =>
105
+ context.json({ message: getRequestTranslator(context)('greeting') }),
106
+ );
107
+ router.post('/clear', async (context) => {
108
+ await getContextSession(context)!.destroy();
109
+ return context.json({ cleared: true });
110
+ });
111
+ return { manager, session, ...createPublicBasePathAdapter(router, basePath) };
112
+ }
113
+
114
+ type TestApplication = Awaited<ReturnType<typeof application>>;
115
+
116
+ async function request(
117
+ jar: CookieJar,
118
+ app: TestApplication,
119
+ base: string,
120
+ endpoint: string,
121
+ init: RequestInit = {},
122
+ ) {
123
+ const url = `${ORIGIN}${base}${endpoint}`;
124
+ const headers = new Headers(init.headers);
125
+ headers.set('cookie', await jar.getCookieString(url));
126
+ const response = await app.fetch(new Request(url, { ...init, headers }));
127
+ for (const cookie of response.headers.getSetCookie())
128
+ await jar.setCookie(cookie, url);
129
+ return response;
130
+ }
131
+
132
+ async function select(
133
+ jar: CookieJar,
134
+ app: TestApplication,
135
+ base: string,
136
+ locale: string,
137
+ ) {
138
+ const response = await request(jar, app, base, '/api/i18n/locale', {
139
+ method: 'PUT',
140
+ headers: { 'content-type': 'application/json' },
141
+ body: JSON.stringify({ locale }),
142
+ });
143
+ expect(response.status).toBe(200);
144
+ expect(await response.json()).toMatchObject({ data: { locale } });
145
+ return response;
146
+ }
147
+
148
+ async function message(jar: CookieJar, app: TestApplication, base: string) {
149
+ const response = await request(jar, app, base, '/message');
150
+ expect(response.status).toBe(200);
151
+ return (await response.json()).message;
152
+ }
153
+
154
+ describe('application language session cookies', () => {
155
+ it.each([
156
+ ['/studio', '/crm'],
157
+ ['', '/main'],
158
+ ['/crm', '/crm/child'],
159
+ ['/west/crm', '/east/crm'],
160
+ ['/a-b', '/a_b'],
161
+ ['/crm', '/CRM'],
162
+ ['/a(b)', '/a%28b%29'],
163
+ ])(
164
+ 'isolates %s and %s even with shared keys and session storage',
165
+ async (firstBase, secondBase) => {
166
+ const first = await application(firstBase);
167
+ const second = await application(secondBase);
168
+ const jar = new CookieJar();
169
+ const response = await select(jar, first, firstBase, 'zh-CN');
170
+ expect(response.headers.getSetCookie()[0]).toContain(
171
+ `Path=${firstBase || '/'}`,
172
+ );
173
+ // Both managers can decrypt and load the same record: distinct names must provide the isolation.
174
+ const cookieValue = (
175
+ await jar.getCookies(`${ORIGIN}${firstBase}/message`)
176
+ ).find((cookie) => cookie.key === first.session.cookie.name)!.value;
177
+ expect(
178
+ await second.manager.createRequestSession({ cookieValue }).get(),
179
+ ).toMatchObject({ locale: 'zh-CN' });
180
+ expect(await message(jar, second, secondBase)).toBe('Hello');
181
+ await select(jar, second, secondBase, 'en-US');
182
+ expect(await message(jar, first, firstBase)).toBe('你好');
183
+ expect(await message(jar, second, secondBase)).toBe('Hello');
184
+ await request(jar, second, secondBase, '/clear', { method: 'POST' });
185
+ expect(await message(jar, first, firstBase)).toBe('你好');
186
+ expect(await message(jar, second, secondBase)).toBe('Hello');
187
+ expect(
188
+ (await jar.getCookies(`${ORIGIN}${secondBase}/message`)).some(
189
+ (cookie) => cookie.key === second.session.cookie.name,
190
+ ),
191
+ ).toBe(false);
192
+ },
193
+ );
194
+
195
+ it('ignores old parent cookies, prefers the new cookie, and clears only its own name', async () => {
196
+ const app = await application('/crm');
197
+ const legacy = createSessionManager({
198
+ ...app.session,
199
+ cookie: { ...app.session.cookie, name: 'nocobase_session', path: '/' },
200
+ });
201
+ managers.push(legacy);
202
+ const old = legacy.createRequestSession({});
203
+ await old.set('locale', 'zh-CN');
204
+ const persisted = await old.persist();
205
+ const jar = new CookieJar();
206
+ await jar.setCookie(
207
+ `nocobase_session=${persisted.cookieValue}; Path=/`,
208
+ ORIGIN,
209
+ );
210
+ expect(await message(jar, app, '/crm')).toBe('Hello');
211
+ await select(jar, app, '/crm', 'en-US');
212
+ expect(await message(jar, app, '/crm')).toBe('Hello');
213
+ await request(jar, app, '/crm', '/clear', { method: 'POST' });
214
+ const cookies = await jar.getCookies(`${ORIGIN}/crm/message`);
215
+ expect(cookies.map((cookie) => cookie.key)).toEqual(['nocobase_session']);
216
+ expect(cookies[0]!.value).toBe(persisted.cookieValue);
217
+ });
218
+
219
+ it('normalizes root and trailing slashes and preserves explicit name/path overrides', async () => {
220
+ const root = await application('/');
221
+ expect(root.session.cookie).toMatchObject({
222
+ name: 'nocobase_session_%2F',
223
+ path: '/',
224
+ });
225
+ const trailing = await application('/crm/');
226
+ expect(trailing.session.cookie).toMatchObject({
227
+ name: 'nocobase_session_%2Fcrm',
228
+ path: '/crm',
229
+ });
230
+ await writeFile(
231
+ configPath,
232
+ JSON.stringify({
233
+ session: {
234
+ secret: SECRET,
235
+ cookie: {
236
+ name: 'custom_session',
237
+ path: '/shared',
238
+ sameSite: 'strict',
239
+ },
240
+ },
241
+ }),
242
+ );
243
+ const custom = await application('/crm');
244
+ expect(custom.session.cookie).toMatchObject({
245
+ name: 'custom_session',
246
+ path: '/shared',
247
+ sameSite: 'strict',
248
+ });
249
+ });
250
+ });