@autional/shared 0.0.0-stage → 0.1.0-rc.33

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 (115) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +49 -3
  3. package/src/api/__tests__/contract.test.ts +172 -0
  4. package/src/api/__tests__/entry-plane.test.ts +119 -0
  5. package/src/api/__tests__/namespace.test.ts +27 -0
  6. package/src/api/__tests__/preflight-refresh.test.ts +89 -0
  7. package/src/api/bff-client.ts +94 -0
  8. package/src/api/client.ts +191 -0
  9. package/src/api/entry-plane.ts +61 -0
  10. package/src/api/namespace.ts +14 -0
  11. package/src/api/trace.ts +21 -0
  12. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  13. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  14. package/src/auth/__tests__/auth-trace.test.ts +321 -0
  15. package/src/auth/__tests__/build-login-url.test.ts +259 -0
  16. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  17. package/src/auth/__tests__/jwt-payload.test.ts +49 -0
  18. package/src/auth/__tests__/logout-redirect.test.ts +44 -0
  19. package/src/auth/__tests__/logout-revoke.test.ts +219 -0
  20. package/src/auth/__tests__/oauth-callback-error-codes.test.ts +69 -0
  21. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  22. package/src/auth/__tests__/oauth-initiate-stay.test.ts +118 -0
  23. package/src/auth/__tests__/oauth-login-user-resolution.test.ts +185 -0
  24. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  25. package/src/auth/__tests__/session-client-id.test.ts +147 -0
  26. package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
  27. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  28. package/src/auth/auth-machine.ts +151 -0
  29. package/src/auth/auth-queries.ts +118 -0
  30. package/src/auth/auth-trace.ts +511 -0
  31. package/src/auth/build-tenant-urls.ts +48 -0
  32. package/src/auth/jwt-payload.ts +21 -0
  33. package/src/auth/oauth-client-from-slug.ts +119 -0
  34. package/src/auth/oauth-client-id-store.ts +41 -0
  35. package/src/auth/oauth-login.ts +388 -0
  36. package/src/auth/pkce.ts +141 -0
  37. package/src/auth/portal-config.ts +68 -0
  38. package/src/auth/roles.ts +120 -0
  39. package/src/auth/service.ts +433 -0
  40. package/src/auth/slug-from-url.ts +92 -0
  41. package/src/auth/store.ts +227 -0
  42. package/src/auth/tenant-route-middleware.ts +113 -0
  43. package/src/auth/tenant-slug-context.tsx +9 -0
  44. package/src/branding/BrandingInitializer.tsx +60 -0
  45. package/src/branding/__tests__/brand-vars.test.ts +126 -0
  46. package/src/branding/brand-color.ts +193 -0
  47. package/src/branding/branding-cache.ts +71 -0
  48. package/src/branding/extract-branding.ts +27 -0
  49. package/src/branding/index.ts +15 -0
  50. package/src/branding/tenant-store.ts +13 -0
  51. package/src/branding/types.ts +14 -0
  52. package/src/branding/useBranding.ts +101 -0
  53. package/src/components/AuditStatsOnly.tsx +65 -0
  54. package/src/components/OAuthCallbackPage.tsx +83 -0
  55. package/src/components/PortalGuard.tsx +105 -0
  56. package/src/components/RequireAuth.tsx +220 -0
  57. package/src/components/TenantIndexGuard.tsx +131 -0
  58. package/src/components/TenantRootRedirect.tsx +49 -0
  59. package/src/components/__tests__/RequireAuth.adversarial.test.tsx +165 -0
  60. package/src/components/__tests__/RequireAuth.stay.test.tsx +131 -0
  61. package/src/components/__tests__/RequireAuth.test.tsx +296 -0
  62. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  63. package/src/components/__tests__/TenantRootRedirect.test.tsx +180 -0
  64. package/src/config/__tests__/portal-resolver.test.ts +302 -0
  65. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  66. package/src/config/constants.ts +27 -0
  67. package/src/config/index.ts +426 -0
  68. package/src/config/resolve-basename.ts +57 -0
  69. package/src/constants/api-paths.ts +450 -0
  70. package/src/generated/api.ts +13012 -0
  71. package/src/generated/queries.ts +12984 -0
  72. package/src/generated/types.ts +13982 -0
  73. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  74. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  75. package/src/hooks/__tests__/useCurrentTenantIdOr.test.tsx +38 -0
  76. package/src/hooks/__tests__/useLogout.test.tsx +61 -0
  77. package/src/hooks/__tests__/usePermission.map.test.tsx +156 -0
  78. package/src/hooks/__tests__/usePortalCatalog.test.tsx +301 -0
  79. package/src/hooks/__tests__/useSodMode.test.tsx +205 -0
  80. package/src/hooks/useAuth.ts +239 -0
  81. package/src/hooks/useBootstrap.ts +74 -0
  82. package/src/hooks/useCurrentRole.ts +18 -0
  83. package/src/hooks/useCurrentTenantIdOr.ts +29 -0
  84. package/src/hooks/useLogout.ts +42 -0
  85. package/src/hooks/usePermission.ts +128 -0
  86. package/src/hooks/usePortalCatalog.ts +199 -0
  87. package/src/hooks/useSodMode.ts +81 -0
  88. package/src/index.ts +247 -0
  89. package/src/lib/__tests__/push.test.ts +30 -0
  90. package/src/lib/push.ts +44 -0
  91. package/src/seo/dom-utils.ts +94 -0
  92. package/src/seo/index.ts +6 -0
  93. package/src/seo/jsonld.ts +29 -0
  94. package/src/seo/types.ts +29 -0
  95. package/src/seo/usePageMeta.ts +17 -0
  96. package/src/seo/usePageTitle.ts +14 -0
  97. package/src/seo/useSEO.ts +80 -0
  98. package/src/types/index.ts +190 -0
  99. package/src/utils/__tests__/case.test.ts +94 -0
  100. package/src/utils/__tests__/error.test.ts +72 -0
  101. package/src/utils/__tests__/mock-test.ts +15 -0
  102. package/src/utils/__tests__/page.test.ts +54 -0
  103. package/src/utils/__tests__/query-state.test.ts +120 -0
  104. package/src/utils/browser.ts +15 -0
  105. package/src/utils/case.ts +59 -0
  106. package/src/utils/error.ts +35 -0
  107. package/src/utils/format.ts +63 -0
  108. package/src/utils/page.ts +57 -0
  109. package/src/utils/password-transmission.test.ts +145 -0
  110. package/src/utils/password-transmission.ts +253 -0
  111. package/src/utils/query-state.ts +85 -0
  112. package/src/utils/response.ts +71 -0
  113. package/src/utils/vite.ts +4 -0
  114. package/src/vite-env.d.ts +19 -0
  115. package/README.md +0 -3
@@ -0,0 +1,180 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { render, cleanup, waitFor } from '@testing-library/react';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+ import type { ReactNode } from 'react';
7
+
8
+ // ============================================================
9
+ // F-W7-a:TenantRootRedirect 的裸根 slug 解析
10
+ // - slug 解析**唯一权威 = 公开租户名单**(id → name,name 即 slug)
11
+ // - 会话 store 的 tenants[].name 是**展示名**("Demo Tenant"),拼进 URL 会落
12
+ // /Demo%20Tenant/ → 404(回归修复;与 auth 站 8859e4d pickSessionSlug 同口径)
13
+ // - 有会话但公开名单未返回:等待,不抢跑漏斗 brand
14
+ // ============================================================
15
+
16
+ const { mockState } = vi.hoisted(() => ({
17
+ mockState: {
18
+ currentTenantId: null as string | null,
19
+ // 会话 store 的租户(展示名口径)——回归锁:组件若回退用它,断言必红
20
+ storeTenants: [] as Array<{ id: string; name: string; role: string }>,
21
+ replace: vi.fn(),
22
+ },
23
+ }));
24
+
25
+ vi.mock('../../hooks/useAuth', async (importOriginal) => ({
26
+ ...(await importOriginal<typeof import('../../hooks/useAuth')>()),
27
+ useCurrentTenantId: () => mockState.currentTenantId,
28
+ useTenants: () => mockState.storeTenants,
29
+ }));
30
+
31
+ import { TenantRootRedirect } from '../TenantRootRedirect';
32
+ import { getPortalUrl } from '../../config';
33
+
34
+ function wrapper(children: ReactNode) {
35
+ return (
36
+ <QueryClientProvider
37
+ client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })}
38
+ >
39
+ {children}
40
+ </QueryClientProvider>
41
+ );
42
+ }
43
+
44
+ function stubTenantList(items: unknown[]) {
45
+ vi.stubGlobal(
46
+ 'fetch',
47
+ vi.fn().mockResolvedValue({
48
+ ok: true,
49
+ status: 200,
50
+ json: async () => ({ code: 0, message: 'success', items, total: items.length }),
51
+ }),
52
+ );
53
+ }
54
+
55
+ function expectedBrandFunnel() {
56
+ return `${getPortalUrl('brand')}/?redirect=${encodeURIComponent(
57
+ window.location.origin + window.location.pathname,
58
+ )}`;
59
+ }
60
+
61
+ /** 剥除 authTrace 面包屑(rt=)后比较;rt 装饰本身由专门断言锁定。 */
62
+ function withoutRt(url: string): string {
63
+ const u = new URL(url, 'http://trace.invalid');
64
+ u.searchParams.delete('rt');
65
+ return u.pathname + u.search;
66
+ }
67
+
68
+ const originalWindowLocation = window.location;
69
+
70
+ beforeEach(() => {
71
+ vi.clearAllMocks();
72
+ mockState.currentTenantId = null;
73
+ mockState.storeTenants = [];
74
+ window.history.pushState({}, '', '/');
75
+ delete (window as any).__APP_CONFIG__;
76
+
77
+ // replace 拦截 + 关键读取面委托真身(pushState 后仍反映当前值)
78
+ delete (window as any).location;
79
+ (window as any).location = Object.defineProperties(
80
+ {},
81
+ {
82
+ ...Object.getOwnPropertyDescriptors(originalWindowLocation),
83
+ protocol: { get: () => originalWindowLocation.protocol, configurable: true },
84
+ hostname: { get: () => originalWindowLocation.hostname, configurable: true },
85
+ origin: { get: () => originalWindowLocation.origin, configurable: true },
86
+ href: { get: () => originalWindowLocation.href, configurable: true },
87
+ pathname: { get: () => originalWindowLocation.pathname, configurable: true },
88
+ replace: { get: () => mockState.replace, configurable: true },
89
+ },
90
+ );
91
+ });
92
+
93
+ afterEach(() => {
94
+ if (window.location !== originalWindowLocation) {
95
+ Object.defineProperty(window, 'location', { value: originalWindowLocation, writable: true });
96
+ }
97
+ cleanup();
98
+ vi.unstubAllGlobals();
99
+ });
100
+
101
+ describe('TenantRootRedirect 裸根漏斗', () => {
102
+ it('有会话 + 名单命中 → 跳 /<slug>/,且 slug 取自公开名单(非展示名)', async () => {
103
+ mockState.currentTenantId = 't1';
104
+ // 会话 store 里是展示名(老缺陷来源)——组件不得使用
105
+ mockState.storeTenants = [{ id: 't1', name: 'Demo Tenant', role: 'owner' }];
106
+ stubTenantList([{ id: 't1', name: 'demo', display_name: 'Demo Tenant' }]);
107
+
108
+ render(wrapper(<TenantRootRedirect />));
109
+
110
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
111
+ const url = String(mockState.replace.mock.calls[0][0]);
112
+ expect(url).toContain('rt=localhost.funnel-slug.');
113
+ expect(withoutRt(url)).toBe('/demo/');
114
+ expect(url).not.toContain('Demo%20Tenant');
115
+ expect(url).not.toContain('Demo');
116
+ });
117
+
118
+ it('名单项只有 slug 字段(无 name)→ 回退 slug', async () => {
119
+ mockState.currentTenantId = 't1';
120
+ stubTenantList([{ id: 't1', slug: 'demo-fallback' }]);
121
+
122
+ render(wrapper(<TenantRootRedirect />));
123
+
124
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
125
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe('/demo-fallback/');
126
+ });
127
+
128
+ it('有会话但名单未返回(加载中)→ 等待,不抢跑 brand 漏斗', async () => {
129
+ mockState.currentTenantId = 't1';
130
+ vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
131
+
132
+ render(wrapper(<TenantRootRedirect />));
133
+
134
+ await new Promise((r) => setTimeout(r, 60));
135
+ expect(mockState.replace).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it('无会话 → 漏斗 brand(带 redirect=当前 origin+pathname)', async () => {
139
+ stubTenantList([{ id: 't1', name: 'demo' }]);
140
+
141
+ render(wrapper(<TenantRootRedirect />));
142
+
143
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
144
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
145
+ });
146
+
147
+ it('有会话但名单无匹配(陈旧会话)→ 漏斗 brand', async () => {
148
+ mockState.currentTenantId = 't-unknown';
149
+ stubTenantList([{ id: 't1', name: 'demo' }]);
150
+
151
+ render(wrapper(<TenantRootRedirect />));
152
+
153
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
154
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
155
+ });
156
+
157
+ it('名单接口失败(返回空数组)→ 有会话按无匹配处理,漏斗 brand(不卡死)', async () => {
158
+ mockState.currentTenantId = 't1';
159
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
160
+
161
+ render(wrapper(<TenantRootRedirect />));
162
+
163
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
164
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
165
+ });
166
+
167
+ it('门户不可派生(IP 主机,如 127.0.0.1 静态托管)→ 不导航,保持当前页(不炸 ErrorBoundary)', async () => {
168
+ stubTenantList([{ id: 't1', name: 'demo' }]);
169
+ Object.defineProperties(window.location, {
170
+ hostname: { get: () => '127.0.0.1', configurable: true },
171
+ origin: { get: () => 'http://127.0.0.1:18951', configurable: true },
172
+ protocol: { get: () => 'http:', configurable: true },
173
+ });
174
+
175
+ render(wrapper(<TenantRootRedirect />));
176
+
177
+ await new Promise((r) => setTimeout(r, 80));
178
+ expect(mockState.replace).not.toHaveBeenCalled();
179
+ });
180
+ });
@@ -0,0 +1,302 @@
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
+ expect(getPortalUrl('platform', 'demo')).toBe('https://platform.autional.cn/demo');
212
+ });
213
+
214
+ it('keeps domain-root portals at root even when a slug is passed', () => {
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 and platform', () => {
238
+ expect(getPortalUrl('auth', 'demo')).toBe('https://auth.iam.tianv.com/demo');
239
+ expect(getPortalUrl('platform', 'demo')).toBe('https://platform.iam.tianv.com/demo');
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
+
248
+ describe('IP host cannot derive subdomain portals (guard returns empty ⇒ callers fall back)', () => {
249
+ const IP_CONFIG = {
250
+ VITE_PORTAL_CONFIG: {
251
+ auth: { host: 'auth', base: '' },
252
+ landing: { host: '', base: '' },
253
+ brand: { host: 'brand', base: '' },
254
+ },
255
+ };
256
+
257
+ beforeEach(() => {
258
+ vi.stubGlobal('window', {
259
+ location: {
260
+ hostname: '127.0.0.1',
261
+ protocol: 'http:',
262
+ origin: 'http://127.0.0.1:18951',
263
+ },
264
+ __APP_CONFIG__: IP_CONFIG,
265
+ });
266
+ });
267
+
268
+ it('subdomain portals (brand/auth, with or without slug) → empty string', () => {
269
+ expect(getPortalUrl('brand')).toBe('');
270
+ expect(getPortalUrl('auth')).toBe('');
271
+ expect(getPortalUrl('auth', 'demo')).toBe('');
272
+ });
273
+
274
+ it('host-less portals stay resolvable: landing = IP root itself (legal)', () => {
275
+ expect(getPortalUrl('landing')).toBe('http://127.0.0.1');
276
+ });
277
+
278
+ it('192.168.x.x private IP: same guard', () => {
279
+ vi.stubGlobal('window', {
280
+ location: {
281
+ hostname: '192.168.3.201',
282
+ protocol: 'http:',
283
+ origin: 'http://192.168.3.201',
284
+ },
285
+ __APP_CONFIG__: IP_CONFIG,
286
+ });
287
+ expect(getPortalUrl('brand')).toBe('');
288
+ });
289
+
290
+ it('default-table branch (no env config) under IP host: same guard', () => {
291
+ vi.stubGlobal('window', {
292
+ location: {
293
+ hostname: '127.0.0.1',
294
+ protocol: 'http:',
295
+ origin: 'http://127.0.0.1:18951',
296
+ },
297
+ });
298
+ expect(getPortalUrl('brand')).toBe('');
299
+ expect(getPortalUrl('landing')).toBe('http://127.0.0.1');
300
+ });
301
+ });
302
+ });
@@ -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`;