@autional-cn/shared 0.1.0-rc

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 (81) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +47 -0
  3. package/src/api/__tests__/entry-plane.test.ts +119 -0
  4. package/src/api/bff-client.ts +94 -0
  5. package/src/api/client.ts +174 -0
  6. package/src/api/entry-plane.ts +61 -0
  7. package/src/api/trace.ts +21 -0
  8. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  9. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  10. package/src/auth/__tests__/build-login-url.test.ts +219 -0
  11. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  12. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  13. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  14. package/src/auth/__tests__/session-client-id.test.ts +124 -0
  15. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  16. package/src/auth/auth-machine.ts +151 -0
  17. package/src/auth/auth-queries.ts +118 -0
  18. package/src/auth/build-tenant-urls.ts +48 -0
  19. package/src/auth/oauth-client-from-slug.ts +119 -0
  20. package/src/auth/oauth-client-id-store.ts +41 -0
  21. package/src/auth/oauth-login.ts +291 -0
  22. package/src/auth/pkce.ts +141 -0
  23. package/src/auth/portal-config.ts +68 -0
  24. package/src/auth/roles.ts +102 -0
  25. package/src/auth/service.ts +368 -0
  26. package/src/auth/slug-from-url.ts +92 -0
  27. package/src/auth/store.ts +226 -0
  28. package/src/auth/tenant-route-middleware.ts +113 -0
  29. package/src/auth/tenant-slug-context.tsx +9 -0
  30. package/src/branding/BrandingInitializer.tsx +60 -0
  31. package/src/branding/brand-color.ts +155 -0
  32. package/src/branding/branding-cache.ts +71 -0
  33. package/src/branding/extract-branding.ts +27 -0
  34. package/src/branding/index.ts +15 -0
  35. package/src/branding/tenant-store.ts +13 -0
  36. package/src/branding/types.ts +14 -0
  37. package/src/branding/useBranding.ts +85 -0
  38. package/src/components/AuditStatsOnly.tsx +65 -0
  39. package/src/components/OAuthCallbackPage.tsx +56 -0
  40. package/src/components/PortalGuard.tsx +105 -0
  41. package/src/components/RequireAuth.tsx +142 -0
  42. package/src/components/TenantIndexGuard.tsx +131 -0
  43. package/src/components/__tests__/RequireAuth.test.tsx +244 -0
  44. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  45. package/src/config/__tests__/portal-resolver.test.ts +247 -0
  46. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  47. package/src/config/constants.ts +27 -0
  48. package/src/config/index.ts +411 -0
  49. package/src/config/resolve-basename.ts +57 -0
  50. package/src/constants/api-paths.ts +450 -0
  51. package/src/generated/api.ts +12566 -0
  52. package/src/generated/queries.ts +12623 -0
  53. package/src/generated/types.ts +13888 -0
  54. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  55. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  56. package/src/hooks/useAuth.ts +239 -0
  57. package/src/hooks/useBootstrap.ts +74 -0
  58. package/src/hooks/useCurrentRole.ts +18 -0
  59. package/src/hooks/useLogout.ts +33 -0
  60. package/src/hooks/usePermission.ts +116 -0
  61. package/src/hooks/useSodMode.ts +73 -0
  62. package/src/index.ts +209 -0
  63. package/src/lib/push.ts +41 -0
  64. package/src/seo/dom-utils.ts +94 -0
  65. package/src/seo/index.ts +6 -0
  66. package/src/seo/jsonld.ts +29 -0
  67. package/src/seo/types.ts +29 -0
  68. package/src/seo/usePageMeta.ts +17 -0
  69. package/src/seo/usePageTitle.ts +14 -0
  70. package/src/seo/useSEO.ts +80 -0
  71. package/src/types/index.ts +185 -0
  72. package/src/utils/__tests__/mock-test.ts +15 -0
  73. package/src/utils/browser.ts +6 -0
  74. package/src/utils/case.ts +44 -0
  75. package/src/utils/error.ts +26 -0
  76. package/src/utils/format.ts +63 -0
  77. package/src/utils/password-transmission.test.ts +145 -0
  78. package/src/utils/password-transmission.ts +253 -0
  79. package/src/utils/response.ts +62 -0
  80. package/src/utils/vite.ts +4 -0
  81. package/src/vite-env.d.ts +18 -0
@@ -0,0 +1,218 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4
+ import { render, screen, cleanup, waitFor } from '@testing-library/react';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+ import type { ReactNode } from 'react';
7
+ import { TenantIndexGuard, usePublicTenantSlugs } from '../TenantIndexGuard';
8
+
9
+ function createQueryClient() {
10
+ return new QueryClient({
11
+ defaultOptions: {
12
+ queries: { retry: false, gcTime: Infinity },
13
+ },
14
+ });
15
+ }
16
+
17
+ function wrapper(children: ReactNode) {
18
+ return <QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>;
19
+ }
20
+
21
+ /** mock fetch 返回 items 数组的 ListResponse */
22
+ function stubFetch(items: unknown[], ok = true, status = 200) {
23
+ vi.stubGlobal(
24
+ 'fetch',
25
+ vi.fn().mockResolvedValue({
26
+ ok,
27
+ status,
28
+ json: async () => ({ code: 0, message: 'success', items, total: items.length }),
29
+ }),
30
+ );
31
+ }
32
+
33
+ function stubFetchData(items: unknown[]) {
34
+ vi.stubGlobal(
35
+ 'fetch',
36
+ vi.fn().mockResolvedValue({
37
+ ok: true,
38
+ status: 200,
39
+ json: async () => ({ code: 0, message: 'success', data: items }),
40
+ }),
41
+ );
42
+ }
43
+
44
+ function stubFetchReject() {
45
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
46
+ }
47
+
48
+ function stubFetchNonOk() {
49
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
50
+ }
51
+
52
+ function stubFetchBadShape() {
53
+ vi.stubGlobal(
54
+ 'fetch',
55
+ vi.fn().mockResolvedValue({
56
+ ok: true,
57
+ status: 200,
58
+ json: async () => ({ foo: 'bar' }),
59
+ }),
60
+ );
61
+ }
62
+
63
+ function setPath(path: string) {
64
+ window.history.pushState({}, '', path);
65
+ }
66
+
67
+ describe('usePublicTenantSlugs(响应契约解析)', () => {
68
+ beforeEach(() => {
69
+ setPath('/acme-corp');
70
+ });
71
+
72
+ afterEach(() => {
73
+ cleanup();
74
+ vi.unstubAllGlobals();
75
+ });
76
+
77
+ it('ListResponse 用 items 字段 → 返回租户数组(修复回归)', async () => {
78
+ stubFetch([
79
+ { id: '1', name: 'acme-corp' },
80
+ { id: '2', name: 'beta-inc' },
81
+ ]);
82
+ let result: unknown[] | undefined;
83
+ function Harness() {
84
+ const q = usePublicTenantSlugs();
85
+ result = q.data;
86
+ return null;
87
+ }
88
+ render(wrapper(<Harness />));
89
+ await waitFor(() =>
90
+ expect(result).toEqual([
91
+ { id: '1', name: 'acme-corp' },
92
+ { id: '2', name: 'beta-inc' },
93
+ ]),
94
+ );
95
+ });
96
+
97
+ it('旧 data 字段兼容 → 返回租户数组', async () => {
98
+ stubFetchData([{ id: '1', name: 'acme-corp' }]);
99
+ let result: unknown[] | undefined;
100
+ function Harness() {
101
+ const q = usePublicTenantSlugs();
102
+ result = q.data;
103
+ return null;
104
+ }
105
+ render(wrapper(<Harness />));
106
+ await waitFor(() => expect(result).toEqual([{ id: '1', name: 'acme-corp' }]));
107
+ });
108
+
109
+ it('API 非 200 → 返回空数组(放行语义)', async () => {
110
+ stubFetchNonOk();
111
+ let result: unknown[] | undefined;
112
+ function Harness() {
113
+ const q = usePublicTenantSlugs();
114
+ result = q.data;
115
+ return null;
116
+ }
117
+ render(wrapper(<Harness />));
118
+ await waitFor(() => expect(result).toEqual([]));
119
+ });
120
+
121
+ it('网络异常 → 返回空数组', async () => {
122
+ stubFetchReject();
123
+ let result: unknown[] | undefined;
124
+ function Harness() {
125
+ const q = usePublicTenantSlugs();
126
+ result = q.data;
127
+ return null;
128
+ }
129
+ render(wrapper(<Harness />));
130
+ await waitFor(() => expect(result).toEqual([]));
131
+ });
132
+
133
+ it('响应无 items/data 且非数组 → 返回空数组', async () => {
134
+ stubFetchBadShape();
135
+ let result: unknown[] | undefined;
136
+ function Harness() {
137
+ const q = usePublicTenantSlugs();
138
+ result = q.data;
139
+ return null;
140
+ }
141
+ render(wrapper(<Harness />));
142
+ await waitFor(() => expect(result).toEqual([]));
143
+ });
144
+ });
145
+
146
+ describe('TenantIndexGuard(P0-2 slug 白名单守卫)', () => {
147
+ const children = <div data-testid="children">Home</div>;
148
+ const notFound = <div data-testid="notfound">404</div>;
149
+
150
+ beforeEach(() => {
151
+ setPath('/acme-corp');
152
+ });
153
+
154
+ afterEach(() => {
155
+ cleanup();
156
+ vi.unstubAllGlobals();
157
+ });
158
+
159
+ it('slug 在白名单 → 渲染 children', async () => {
160
+ stubFetch([{ id: '1', name: 'acme-corp' }]);
161
+ render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
162
+ await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
163
+ expect(screen.queryByTestId('notfound')).toBeNull();
164
+ });
165
+
166
+ it('slug 不在白名单 → 渲染 notFound(修复回归:无效 slug 此前错误渲染首页)', async () => {
167
+ stubFetch([{ id: '1', name: 'acme-corp' }]);
168
+ setPath('/nonexistent-tenant');
169
+ render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
170
+ await waitFor(() => expect(screen.getByTestId('notfound')).toBeInTheDocument());
171
+ expect(screen.queryByTestId('children')).toBeNull();
172
+ });
173
+
174
+ it('白名单为空数组 → 放行渲染 children(API 挂掉时退化为不拦截)', async () => {
175
+ stubFetch([]);
176
+ setPath('/any-slug');
177
+ render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
178
+ await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
179
+ });
180
+
181
+ it('URL 多段路径 → 直接 notFound(basename 已剥离 slug,内部必为未知段)', () => {
182
+ setPath('/acme-corp/xyz');
183
+ render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
184
+ expect(screen.getByTestId('notfound')).toBeInTheDocument();
185
+ expect(screen.queryByTestId('children')).toBeNull();
186
+ });
187
+
188
+ it('加载中 → 渲染 loading 占位', async () => {
189
+ let resolveFetch: (v: unknown) => void;
190
+ const pending = new Promise((res) => {
191
+ resolveFetch = res;
192
+ });
193
+ vi.stubGlobal('fetch', vi.fn().mockReturnValue(pending));
194
+ setPath('/acme-corp');
195
+ const loading = <div data-testid="loading">Loading...</div>;
196
+ render(
197
+ wrapper(
198
+ <TenantIndexGuard notFound={notFound} loading={loading}>
199
+ {children}
200
+ </TenantIndexGuard>,
201
+ ),
202
+ );
203
+ expect(screen.getByTestId('loading')).toBeInTheDocument();
204
+ await resolveFetch!({
205
+ ok: true,
206
+ status: 200,
207
+ json: async () => ({ code: 0, items: [{ id: '1', name: 'acme-corp' }] }),
208
+ });
209
+ await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
210
+ });
211
+
212
+ it('空 slug(根路径 /)→ 渲染 children', async () => {
213
+ stubFetch([{ id: '1', name: 'acme-corp' }]);
214
+ setPath('/');
215
+ render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
216
+ await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
217
+ });
218
+ });
@@ -0,0 +1,247 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { getRootDomain, getPortalUrl } from '../index';
3
+
4
+ // ─── getRootDomain (pure function, no mock needed) ───
5
+
6
+ describe('getRootDomain', () => {
7
+ it.each([
8
+ // [hostname, expected]
9
+ ['app.iam.tianv.local', 'iam.tianv.local'],
10
+ ['auth.iam.tianv.local', 'iam.tianv.local'],
11
+ ['app.iam.tianv.com', 'iam.tianv.com'],
12
+ ['auth.iam.tianv.com', 'iam.tianv.com'],
13
+ ['app.autional.com', 'autional.com'],
14
+ ['auth.autional.com', 'autional.com'],
15
+ ['iam.tianv.com', 'tianv.com'], // root domain itself
16
+ ['127.0.0.1', '127.0.0.1'], // IP address
17
+ ['localhost', 'localhost'], // localhost
18
+ ])('hostname=%s → %s', (hostname, expected) => {
19
+ expect(getRootDomain(hostname)).toBe(expected);
20
+ });
21
+ });
22
+
23
+ // ─── getPortalUrl (requires window mock) ───
24
+
25
+ const LOCAL_CONFIG = {
26
+ VITE_PORTAL_CONFIG: {
27
+ auth: { host: 'auth', base: '' },
28
+ landing: { host: '', base: '' },
29
+ admin: { host: 'app', base: '/admin' },
30
+ developer: { host: 'app', base: '/developer' },
31
+ platform: { host: 'platform', base: '' },
32
+ status: { host: 'status', base: '' },
33
+ trust: { host: 'trust', base: '' },
34
+ user: { host: 'user', base: '' },
35
+ authenticator: { host: 'authenticator', base: '' },
36
+ security: { host: 'app', base: '/security' },
37
+ },
38
+ };
39
+
40
+ const COM_CONFIG = {
41
+ VITE_PORTAL_CONFIG: {
42
+ ...LOCAL_CONFIG.VITE_PORTAL_CONFIG,
43
+ },
44
+ };
45
+
46
+ describe('getPortalUrl', () => {
47
+ beforeEach(() => {
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ describe('with VITE_PORTAL_CONFIG + .local domain', () => {
52
+ beforeEach(() => {
53
+ vi.stubGlobal('window', {
54
+ location: {
55
+ hostname: 'app.iam.tianv.local',
56
+ protocol: 'https:',
57
+ origin: 'https://app.iam.tianv.local',
58
+ },
59
+ __APP_CONFIG__: LOCAL_CONFIG,
60
+ });
61
+ });
62
+
63
+ it('resolves auth to auth.* subdomain', () => {
64
+ expect(getPortalUrl('auth')).toBe('https://auth.iam.tianv.local');
65
+ });
66
+
67
+ it('resolves landing to root domain', () => {
68
+ expect(getPortalUrl('landing')).toBe('https://iam.tianv.local');
69
+ });
70
+
71
+ it('resolves app-* portals to app.* subdomain with base path', () => {
72
+ expect(getPortalUrl('admin')).toBe('https://app.iam.tianv.local/admin');
73
+ expect(getPortalUrl('developer')).toBe('https://app.iam.tianv.local/developer');
74
+ expect(getPortalUrl('security')).toBe('https://app.iam.tianv.local/security');
75
+ });
76
+
77
+ it('resolves platform to its own subdomain', () => {
78
+ expect(getPortalUrl('platform')).toBe('https://platform.iam.tianv.local');
79
+ });
80
+
81
+ it('resolves status to its own subdomain', () => {
82
+ expect(getPortalUrl('status')).toBe('https://status.iam.tianv.local');
83
+ });
84
+
85
+ it('resolves trust to its own subdomain', () => {
86
+ expect(getPortalUrl('trust')).toBe('https://trust.iam.tianv.local');
87
+ });
88
+
89
+ it('resolves user to its own subdomain', () => {
90
+ expect(getPortalUrl('user')).toBe('https://user.iam.tianv.local');
91
+ });
92
+
93
+ it('resolves authenticator to its own subdomain', () => {
94
+ expect(getPortalUrl('authenticator')).toBe('https://authenticator.iam.tianv.local');
95
+ });
96
+ });
97
+
98
+ describe('with VITE_PORTAL_CONFIG + .com domain', () => {
99
+ beforeEach(() => {
100
+ vi.stubGlobal('window', {
101
+ location: {
102
+ hostname: 'app.iam.tianv.com',
103
+ protocol: 'https:',
104
+ origin: 'https://app.iam.tianv.com',
105
+ },
106
+ __APP_CONFIG__: COM_CONFIG,
107
+ });
108
+ });
109
+
110
+ it('resolves auth to auth.*.com subdomain', () => {
111
+ expect(getPortalUrl('auth')).toBe('https://auth.iam.tianv.com');
112
+ });
113
+
114
+ it('resolves admin to app.*.com/admin', () => {
115
+ expect(getPortalUrl('admin')).toBe('https://app.iam.tianv.com/admin');
116
+ });
117
+
118
+ it('resolves platform to platform.*.com', () => {
119
+ expect(getPortalUrl('platform')).toBe('https://platform.iam.tianv.com');
120
+ });
121
+ });
122
+
123
+ describe('with VITE_PORTAL_CONFIG + autional.com domain', () => {
124
+ beforeEach(() => {
125
+ vi.stubGlobal('window', {
126
+ location: {
127
+ hostname: 'app.autional.com',
128
+ protocol: 'https:',
129
+ origin: 'https://app.autional.com',
130
+ },
131
+ __APP_CONFIG__: COM_CONFIG,
132
+ });
133
+ });
134
+
135
+ it('resolves auth to auth.autional.com', () => {
136
+ expect(getPortalUrl('auth')).toBe('https://auth.autional.com');
137
+ });
138
+
139
+ it('resolves admin to app.autional.com/admin', () => {
140
+ expect(getPortalUrl('admin')).toBe('https://app.autional.com/admin');
141
+ });
142
+
143
+ it('resolves landing to autional.com', () => {
144
+ expect(getPortalUrl('landing')).toBe('https://autional.com');
145
+ });
146
+ });
147
+
148
+ describe('fallback when VITE_PORTAL_CONFIG is absent (old env.js)', () => {
149
+ beforeEach(() => {
150
+ vi.stubGlobal('window', {
151
+ location: {
152
+ hostname: 'app.iam.tianv.local',
153
+ protocol: 'https:',
154
+ origin: 'https://app.iam.tianv.local',
155
+ },
156
+ __APP_CONFIG__: {
157
+ VITE_AUTH_PAGES_URL: 'https://auth.iam.tianv.com',
158
+ VITE_ADMIN_CONSOLE_URL: 'https://app.iam.tianv.com/admin',
159
+ },
160
+ });
161
+ });
162
+
163
+ it('falls back to VITE_AUTH_PAGES_URL for auth', () => {
164
+ expect(getPortalUrl('auth')).toBe('https://auth.iam.tianv.com');
165
+ });
166
+
167
+ it('falls back to VITE_ADMIN_CONSOLE_URL for admin', () => {
168
+ expect(getPortalUrl('admin')).toBe('https://app.iam.tianv.com/admin');
169
+ });
170
+
171
+ it('falls back to origin for unknown portal', () => {
172
+ expect(getPortalUrl('unknown')).toBe('https://app.iam.tianv.local/unknown');
173
+ });
174
+ });
175
+
176
+ describe('tenant slug is gated by portal shape (F-W8)', () => {
177
+ const CN_CONFIG = {
178
+ VITE_PORTAL_CONFIG: {
179
+ auth: { host: 'auth', base: '' },
180
+ admin: { host: 'admin', base: '' },
181
+ developer: { host: 'developer', base: '' },
182
+ platform: { host: 'platform', base: '' },
183
+ status: { host: 'status', base: '' },
184
+ trust: { host: 'trust', base: '' },
185
+ user: { host: 'user', base: '' },
186
+ authenticator: { host: 'authenticator', base: '' },
187
+ security: { host: 'security', base: '' },
188
+ brand: { host: 'brand', base: '' },
189
+ },
190
+ };
191
+
192
+ beforeEach(() => {
193
+ vi.stubGlobal('window', {
194
+ location: {
195
+ hostname: 'auth.autional.cn',
196
+ protocol: 'https:',
197
+ origin: 'https://auth.autional.cn',
198
+ },
199
+ __APP_CONFIG__: CN_CONFIG,
200
+ });
201
+ });
202
+
203
+ it('appends /<slug> for tenant-scoped portals', () => {
204
+ expect(getPortalUrl('auth', 'demo')).toBe('https://auth.autional.cn/demo');
205
+ expect(getPortalUrl('admin', 'demo')).toBe('https://admin.autional.cn/demo');
206
+ expect(getPortalUrl('user', 'demo')).toBe('https://user.autional.cn/demo');
207
+ expect(getPortalUrl('security', 'demo')).toBe('https://security.autional.cn/demo');
208
+ expect(getPortalUrl('authenticator', 'demo')).toBe(
209
+ 'https://authenticator.autional.cn/demo',
210
+ );
211
+ });
212
+
213
+ it('keeps domain-root portals at root even when a slug is passed', () => {
214
+ expect(getPortalUrl('platform', 'demo')).toBe('https://platform.autional.cn');
215
+ expect(getPortalUrl('status', 'demo')).toBe('https://status.autional.cn');
216
+ expect(getPortalUrl('trust', 'demo')).toBe('https://trust.autional.cn');
217
+ expect(getPortalUrl('developer', 'demo')).toBe('https://developer.autional.cn');
218
+ expect(getPortalUrl('brand', 'demo')).toBe('https://brand.autional.cn');
219
+ });
220
+ });
221
+
222
+ describe('tenant slug gate applies to legacy VITE_*_URL and default-table branches', () => {
223
+ beforeEach(() => {
224
+ vi.stubGlobal('window', {
225
+ location: {
226
+ hostname: 'app.iam.tianv.local',
227
+ protocol: 'https:',
228
+ origin: 'https://app.iam.tianv.local',
229
+ },
230
+ __APP_CONFIG__: {
231
+ VITE_AUTH_PAGES_URL: 'https://auth.iam.tianv.com',
232
+ VITE_PLATFORM_CONSOLE_URL: 'https://platform.iam.tianv.com',
233
+ },
234
+ });
235
+ });
236
+
237
+ it('legacy branch: slug appended for auth, not for platform', () => {
238
+ expect(getPortalUrl('auth', 'demo')).toBe('https://auth.iam.tianv.com/demo');
239
+ expect(getPortalUrl('platform', 'demo')).toBe('https://platform.iam.tianv.com');
240
+ });
241
+
242
+ it('default-table branch: slug appended for user, not for status', () => {
243
+ expect(getPortalUrl('user', 'demo')).toBe('https://user.iam.tianv.local/demo');
244
+ expect(getPortalUrl('status', 'demo')).toBe('https://status.iam.tianv.local');
245
+ });
246
+ });
247
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // 动态导入被测函数,避免顶层 window 依赖
4
+ import { isValidRedirect } from '../index';
5
+
6
+ describe('isValidRedirect', () => {
7
+ beforeEach(() => {
8
+ vi.stubGlobal('window', {
9
+ location: {
10
+ origin: 'https://auth.iam.tianv.local',
11
+ hostname: 'auth.iam.tianv.local',
12
+ },
13
+ });
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
20
+ it('accepts same-origin relative path', () => {
21
+ expect(isValidRedirect('/acme-corp/dashboard')).toBe(true);
22
+ });
23
+
24
+ it('accepts same-origin absolute URL', () => {
25
+ expect(isValidRedirect('https://auth.iam.tianv.local/acme-corp/dashboard')).toBe(true);
26
+ });
27
+
28
+ it('rejects protocol-relative URL (open redirect)', () => {
29
+ expect(isValidRedirect('//evil.com')).toBe(false);
30
+ });
31
+
32
+ it('rejects backslash protocol-relative variant', () => {
33
+ expect(isValidRedirect('/\\evil.com')).toBe(false);
34
+ });
35
+
36
+ it('rejects percent-encoded backslash variant', () => {
37
+ expect(isValidRedirect('/%5cevil.com')).toBe(false);
38
+ });
39
+
40
+ it('rejects external http URL not in allowlist', () => {
41
+ expect(isValidRedirect('https://evil.com/phish')).toBe(false);
42
+ });
43
+
44
+ it('rejects empty string', () => {
45
+ expect(isValidRedirect('')).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,27 @@
1
+ // Build-time domain constants (used by Astro SSG for SEO meta tags).
2
+ // For runtime resolution, use getRootDomain() + getPortalUrl() from ./index.
3
+ // Override via SITE_DOMAIN env var for multi-environment builds.
4
+
5
+ function getEnv(key: string, fallback: string): string {
6
+ try {
7
+ const val = (import.meta as any).env?.[key];
8
+ if (val) return val;
9
+ } catch {
10
+ /* SSR may not have import.meta */
11
+ }
12
+ return fallback;
13
+ }
14
+
15
+ export const SITE_DOMAIN = getEnv('SITE_DOMAIN', 'iam.tianv.com');
16
+ export const SITE_EMAIL = getEnv('SITE_EMAIL', 'support@autional.net');
17
+ export const SITE_BASE_URL = `https://${SITE_DOMAIN}`;
18
+ export const AUTH_DOMAIN = `auth.${SITE_DOMAIN}`;
19
+ export const USER_DOMAIN = `user.${SITE_DOMAIN}`;
20
+ export const APP_DOMAIN = `app.${SITE_DOMAIN}`;
21
+ export const AUTHENTICATOR_DOMAIN = `authenticator.${SITE_DOMAIN}`;
22
+ export const AUTH_BASE_URL = `https://${AUTH_DOMAIN}`;
23
+ export const USER_BASE_URL = `https://${USER_DOMAIN}`;
24
+ export const APP_BASE_URL = `https://${APP_DOMAIN}`;
25
+ export const AUTHENTICATOR_BASE_URL = `https://${AUTHENTICATOR_DOMAIN}`;
26
+ export const DEFAULT_OG_IMAGE = `${SITE_BASE_URL}/og-image.svg`;
27
+ export const DEFAULT_LOGO = `${SITE_BASE_URL}/logo.png`;