@h-ai/cli 0.1.0-alpha.57 → 0.1.0-alpha.58

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 (175) hide show
  1. package/README.en.md +37 -3
  2. package/README.ja.md +37 -3
  3. package/README.md +37 -3
  4. package/dist/chunk-2H7UOFLK.js +0 -0
  5. package/dist/{chunk-MJGUBOAV.js → chunk-F4IBPVZ5.js} +6 -42
  6. package/dist/chunk-F4IBPVZ5.js.map +1 -0
  7. package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
  8. package/dist/chunk-WUSFJFW5.js.map +1 -0
  9. package/dist/cli-config-templates-6XA7V6CC.js +5 -0
  10. package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
  11. package/dist/{cli-skill-templates-ZYXES3NT.js → cli-skill-templates-45CK4RQ2.js} +3 -3
  12. package/dist/{cli-skill-templates-ZYXES3NT.js.map → cli-skill-templates-45CK4RQ2.js.map} +1 -1
  13. package/dist/index.js +107 -61
  14. package/dist/index.js.map +1 -1
  15. package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
  16. package/dist/src-DUGLX4YB.js.map +1 -0
  17. package/package.json +17 -17
  18. package/templates/README.md +4 -4
  19. package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
  20. package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
  21. package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
  22. package/templates/apps/api/.npmrc.hbs +1 -2
  23. package/templates/apps/api/Dockerfile.hbs +6 -5
  24. package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
  25. package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
  26. package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  27. package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
  28. package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
  29. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
  30. package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
  31. package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
  32. package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
  33. package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
  34. package/templates/apps/api/e2e/api-service.spec.ts.hbs +3 -1
  35. package/templates/apps/api/package.json.hbs +2 -2
  36. package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
  37. package/templates/apps/api/tsconfig.base.json +0 -1
  38. package/templates/apps/fullstack/.dockerignore.hbs +3 -0
  39. package/templates/apps/fullstack/.npmrc.hbs +1 -2
  40. package/templates/apps/fullstack/Dockerfile.hbs +6 -5
  41. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
  42. package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
  43. package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
  44. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
  45. package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
  46. package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
  47. package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
  48. package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
  49. package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
  50. package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
  51. package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
  52. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
  53. package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
  54. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
  55. package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
  56. package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
  57. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
  58. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
  59. package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
  60. package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
  61. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
  62. package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
  63. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
  64. package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
  65. package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
  66. package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
  67. package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
  68. package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
  69. package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
  70. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +206 -16
  71. package/templates/apps/fullstack/package.json.hbs +2 -2
  72. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
  73. package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
  74. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
  75. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
  76. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
  77. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
  78. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
  79. package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
  80. package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
  81. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
  82. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
  83. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
  84. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
  85. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
  86. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
  87. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
  88. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
  89. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
  90. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
  91. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
  92. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
  93. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
  94. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
  95. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
  96. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
  97. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
  98. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
  99. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
  100. package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
  101. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
  102. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
  103. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
  104. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
  105. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
  106. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
  107. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
  108. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
  109. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
  110. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
  111. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
  112. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
  113. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
  114. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
  115. package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
  116. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
  117. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
  118. package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
  119. package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
  120. package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
  121. package/templates/apps/fullstack/tsconfig.base.json +0 -1
  122. package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
  123. package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
  124. package/templates/apps/mobile-app/index.html.hbs +2 -2
  125. package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
  126. package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
  127. package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
  128. package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
  129. package/templates/base/.npmrc.hbs +1 -15
  130. package/templates/base/Dockerfile.hbs +9 -8
  131. package/templates/base/e2e/app.spec.ts.hbs +1 -0
  132. package/templates/base/package.json.hbs +39 -42
  133. package/templates/base/pnpm-workspace.yaml.hbs +26 -0
  134. package/templates/base/src/app.d.ts.hbs +4 -0
  135. package/templates/base/src/hooks.server.ts.hbs +0 -8
  136. package/templates/base/src/lib/server/init.ts.hbs +26 -5
  137. package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
  138. package/templates/base/tests/scaffold.test.ts.hbs +21 -1
  139. package/templates/base/vite.config.ts.hbs +21 -0
  140. package/templates/features/ai/routes/api/chat/+server.ts +6 -7
  141. package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
  142. package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
  143. package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
  144. package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
  145. package/templates/features/storage/routes/api/upload/+server.ts +10 -8
  146. package/templates/skills/CLAUDE.md +0 -14
  147. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +13 -2
  148. package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +3 -0
  149. package/templates/skills/hai-api-client/SKILL.md +1 -1
  150. package/templates/skills/hai-app-create/development.md +6 -0
  151. package/templates/skills/hai-build/reference.md +4 -2
  152. package/templates/skills/hai-core/SKILL.md +2 -0
  153. package/templates/skills/hai-core/reference.md +6 -4
  154. package/templates/skills/hai-framework-sync/SKILL.md +1 -2
  155. package/templates/skills/hai-serv/SKILL.md +1 -0
  156. package/templates/skills/hai-serv/reference.md +3 -1
  157. package/templates/skills/hai-ui/SKILL.md +6 -0
  158. package/templates/skills/hai-ui/reference.md +7 -4
  159. package/templates/skills/hai-vecdb/reference.md +53 -49
  160. package/dist/chunk-MJGUBOAV.js.map +0 -1
  161. package/dist/chunk-O3ANVICQ.js.map +0 -1
  162. package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
  163. package/dist/src-TBIET7YU.js.map +0 -1
  164. package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
  165. package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
  166. package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
  167. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
  168. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
  169. package/templates/skills/bridges/admin/CLAUDE.md +0 -15
  170. package/templates/skills/bridges/api/CLAUDE.md +0 -15
  171. package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -9
  172. package/templates/skills/bridges/h5/CLAUDE.md +0 -15
  173. package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -15
  174. package/templates/skills/bridges/website/CLAUDE.md +0 -15
  175. package/templates/skills/claude-skill.md.hbs +0 -7
@@ -0,0 +1,98 @@
1
+ import type { CoreConfig, HaiResult } from '@h-ai/core'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import { audit, AuditInitConfigSchema } from '@h-ai/audit'
6
+ import { cache, CacheConfigSchema } from '@h-ai/cache'
7
+ import { core } from '@h-ai/core'
8
+ import { crypto } from '@h-ai/crypto'
9
+ import { iam, IamConfigSchema } from '@h-ai/iam'
10
+ import { reldb, ReldbConfigSchema } from '@h-ai/reldb'
11
+ import { ServConfigSchema } from '@h-ai/serv'
12
+ import { storage, StorageConfigSchema } from '@h-ai/storage'
13
+ import { serverM } from './messages.js'
14
+
15
+ let initialized = false
16
+ let initializing: Promise<void> | undefined
17
+
18
+ /** 初始化失败停止启动并反序释放资源;不把功能缺失降级为成功。 */
19
+ export async function initializeModules(): Promise<void> {
20
+ if (initialized)
21
+ return
22
+ initializing ??= runInitialization().finally(() => {
23
+ initializing = undefined
24
+ })
25
+ return initializing
26
+ }
27
+
28
+ async function runInitialization(): Promise<void> {
29
+ try {
30
+ requireInitialized(core.config.validate('serv', ServConfigSchema), 'serv-config')
31
+ const db = ReldbConfigSchema.parse(core.config.getOrThrow('db'))
32
+ const cacheConfig = CacheConfigSchema.parse(core.config.getOrThrow('cache'))
33
+ const storageConfig = StorageConfigSchema.parse(core.config.getOrThrow('storage'))
34
+ const iamConfig = IamConfigSchema.parse(core.config.getOrThrow('iam'))
35
+ const auditConfig = AuditInitConfigSchema.parse(core.config.get('audit') ?? {})
36
+ if (db.type === 'sqlite' && db.database !== ':memory:')
37
+ await mkdir(path.dirname(db.database), { recursive: true })
38
+ requireInitialized(await reldb.init(db), 'reldb')
39
+ requireInitialized(await cache.init(cacheConfig), 'cache')
40
+ requireInitialized(await crypto.init(), 'crypto')
41
+ requireInitialized(await storage.init(storageConfig), 'storage')
42
+ requireInitialized(await iam.init(iamConfig), 'iam')
43
+ requireInitialized(await audit.init(auditConfig), 'audit')
44
+ await ensureRolesAndAdmin()
45
+ initialized = true
46
+ const app = core.config.getOrThrow<CoreConfig>('core')
47
+ core.logger.info('Application initialized', { name: app.name, version: app.version, environment: app.env })
48
+ }
49
+ catch (error) {
50
+ await closeModules()
51
+ throw error
52
+ }
53
+ }
54
+
55
+ /** 此函数仅用于进程启动边界,业务请求继续返回 HaiResult。 */
56
+ function requireInitialized<T>(result: HaiResult<T>, moduleName: string): T {
57
+ if (!result.success)
58
+ throw new Error(`Failed to initialize ${moduleName}: ${result.error.message} (${result.error.code})`)
59
+ return result.data
60
+ }
61
+
62
+ async function ensureRolesAndAdmin(): Promise<void> {
63
+ for (const code of ['admin', 'user'] as const) {
64
+ const existing = requireInitialized(await iam.authz.getRoleByCode(code), 'iam-role')
65
+ if (!existing)
66
+ requireInitialized(await iam.authz.createRole({ code, name: serverM(code), description: serverM(code === 'admin' ? 'adminDescription' : 'userDescription'), isSystem: true }), 'iam-role')
67
+ }
68
+ const users = requireInitialized(await iam.user.listUsers({ page: 1, pageSize: 1 }), 'iam-users')
69
+ if (users.total > 0)
70
+ return
71
+ const password = process.env.HAI_ADMIN_DEFAULT_PASSWORD?.trim()
72
+ if (!password)
73
+ throw new Error('Set HAI_ADMIN_DEFAULT_PASSWORD before the first start to create the administrator account')
74
+ const registered = requireInitialized(await iam.user.register({ username: 'admin', password, displayName: serverM('admin') }), 'iam-bootstrap')
75
+ const adminRole = requireInitialized(await iam.authz.getRoleByCode('admin'), 'iam-role')
76
+ if (!adminRole)
77
+ throw new Error('Administrator role is missing')
78
+ const assigned = await iam.authz.syncRoles(registered.user.id, [adminRole.id])
79
+ if (!assigned.success) {
80
+ const removed = await iam.user.deleteUser(registered.user.id)
81
+ if (!removed.success)
82
+ core.logger.error('Failed to clean up administrator bootstrap', { error: removed.error.code })
83
+ requireInitialized(assigned, 'iam-bootstrap-role')
84
+ }
85
+ core.logger.info('Initial administrator created', { username: 'admin', passwordSource: 'HAI_ADMIN_DEFAULT_PASSWORD' })
86
+ }
87
+
88
+ /** 关闭顺序与依赖初始化顺序相反,同时覆盖启动失败路径。 */
89
+ export async function closeModules(): Promise<void> {
90
+ for (const [name, module] of [['audit', audit], ['iam', iam], ['storage', storage], ['crypto', crypto], ['cache', cache], ['reldb', reldb]] as const) {
91
+ if (!module.isInitialized)
92
+ continue
93
+ const result = await module.close()
94
+ if (result && !result.success)
95
+ core.logger.error('Failed to close application module', { module: name, error: result.error.code })
96
+ }
97
+ initialized = false
98
+ }
@@ -1,52 +1,465 @@
1
- import { apiContract } from '@h-ai/api-contract'
2
- import { {{fullstack.contractExportName}} } from '{{fullstack.contractPackageName}}'
3
- import { describe, expect, it } from 'vitest'
1
+ import type { CoreConfig, HaiResult } from '@h-ai/core'
2
+ import type { TransportClient } from '@h-ai/crypto'
3
+ import type { AdminUser } from '{{fullstack.contractPackageName}}'
4
+ import { Buffer } from 'node:buffer'
5
+ import { mkdtemp, rm } from 'node:fs/promises'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { audit } from '@h-ai/audit'
9
+ import { cache } from '@h-ai/cache'
10
+ import { core, err, HaiCommonError } from '@h-ai/core'
11
+ import { crypto } from '@h-ai/crypto'
12
+ import { iam } from '@h-ai/iam'
13
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
4
14
  import { createServerApp } from '../src/app-server.js'
15
+ import { closeModules, initializeModules } from '../src/server/runtime.js'
5
16
 
6
- const API_PREFIX = '/api/v1'
17
+ const PREFIX = '/api/v1'
18
+ const TEST_PASSWORD = 'TemplateTest123!'
19
+ const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII='
20
+ let app: ReturnType<typeof createServerApp>
21
+ let transport: TransportClient
22
+ let storageRoot: string
23
+ let cookie = ''
24
+ let adminToken = ''
25
+ let adminId = ''
26
+ let userToken = ''
27
+ let user: AdminUser
28
+ let encryptedRequests = 0
29
+ let encryptedResponses = 0
30
+ interface LoginData { user: { id: string }, tokens: { accessToken: string, refreshToken?: string }, roles: string[] }
7
31
 
8
- describe('{{fullstack.servPackageName}}', () => {
9
- it('returns health status', async () => {
10
- const app = createServerApp()
11
- const response = await app.request('/health')
12
-
13
- expect(response.status).toBe(200)
14
- await expect(response.json()).resolves.toEqual({ status: 'ok' })
32
+ /** 使用真实加密协议与 HTTP 服务,同时保留浏览器 refresh cookie。 */
33
+ async function call<T>(route: string, input?: object, token?: string, method = 'POST'): Promise<HaiResult<T>> {
34
+ const response = await transport.encryptedFetch(`http://localhost${PREFIX}${route}`, {
35
+ method,
36
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
37
+ ...(input ? { body: JSON.stringify(input) } : {}),
15
38
  })
39
+ return response.json() as Promise<HaiResult<T>>
40
+ }
16
41
 
17
- it('returns app info as HaiResult', async () => {
18
- const app = createServerApp()
19
- const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.info)}`, { method: 'POST' })
20
- const body = await response.json()
21
-
22
- expect(response.status).toBe(200)
23
- expect(body.success).toBe(true)
24
- expect(body.data.name).toBe('{{fullstack.servPackageName}}')
42
+ beforeAll(async () => {
43
+ storageRoot = await mkdtemp(path.join(os.tmpdir(), '{{projectName}}-avatars-'))
44
+ vi.stubEnv('HAI_DB_DATABASE', '":memory:"')
45
+ vi.stubEnv('HAI_STORAGE_ROOT', storageRoot)
46
+ vi.stubEnv('HAI_ADMIN_DEFAULT_PASSWORD', TEST_PASSWORD)
47
+ core.init({ configDir: './config' })
48
+ await initializeModules()
49
+ app = createServerApp()
50
+ transport = crypto.transport.createClient({
51
+ keyExchangeUrl: `http://localhost${PREFIX}/_hai/key-exchange`,
52
+ fetch: async (input, init) => {
53
+ const request = new Request(input, init)
54
+ if (cookie && request.url.endsWith('/auth/refresh'))
55
+ request.headers.set('cookie', cookie)
56
+ if (request.headers.get('x-encrypted') === 'true')
57
+ encryptedRequests++
58
+ const response = await app.fetch(request)
59
+ if (response.headers.get('x-encrypted') === 'true') {
60
+ encryptedResponses++
61
+ const wire = await response.clone().text()
62
+ expect(wire).not.toContain(TEST_PASSWORD)
63
+ expect(wire).not.toContain('"username"')
64
+ }
65
+ const nextCookie = response.headers.get('set-cookie')
66
+ if (nextCookie)
67
+ cookie = nextCookie.split(';')[0] ?? ''
68
+ return response
69
+ },
25
70
  })
71
+ }, 30_000)
26
72
 
27
- it('validates echo input', async () => {
28
- const app = createServerApp()
29
- const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
73
+ afterAll(async () => {
74
+ transport?.destroy()
75
+ await closeModules()
76
+ vi.unstubAllEnvs()
77
+ await rm(storageRoot, { recursive: true, force: true })
78
+ })
79
+
80
+ describe('{{fullstack.servPackageName}} mini administration', () => {
81
+ it('keeps health public, requires encryption and reads runtime identity from core', async () => {
82
+ expect((await app.request('/health')).status).toBe(200)
83
+ expect((await app.request(`${PREFIX}/app/info`, { method: 'POST' })).status).toBe(400)
84
+ const result = await call<{ name: string, version: string, env: string, defaultLocale: string }>('/app/info')
85
+ expect(result.success).toBe(true)
86
+ if (!result.success)
87
+ return
88
+ const config = core.config.getOrThrow<CoreConfig>('core')
89
+ expect(result.data).toMatchObject({ name: config.name, version: config.version, env: config.env, defaultLocale: config.defaultLocale })
90
+ const invalid = await transport.encryptedFetch(`http://localhost${PREFIX}/app/echo`, {
30
91
  method: 'POST',
31
92
  headers: { 'content-type': 'application/json' },
32
93
  body: JSON.stringify({ message: '' }),
33
94
  })
95
+ expect(invalid.status).toBe(400)
96
+ })
97
+
98
+ it('logs in and refreshes with a browser cookie without exposing its refresh token', async () => {
99
+ const result = await call<LoginData>('/auth/login', { identifier: 'admin', password: TEST_PASSWORD })
100
+ expect(result.success).toBe(true)
101
+ if (!result.success)
102
+ return
103
+ adminToken = result.data.tokens.accessToken
104
+ adminId = result.data.user.id
105
+ expect(result.data.roles).toEqual(['admin'])
106
+ expect(result.data.tokens.refreshToken).toBeUndefined()
107
+ expect(cookie).toContain('hai_refresh_token=')
108
+ const refreshed = await call<{ tokens: { accessToken: string, refreshToken?: string } }>('/auth/refresh', {})
109
+ expect(refreshed.success).toBe(true)
110
+ if (refreshed.success) {
111
+ expect(refreshed.data.tokens.refreshToken).toBeUndefined()
112
+ adminToken = refreshed.data.tokens.accessToken
113
+ }
114
+ })
115
+
116
+ it('does not mount unselected default IAM routes', async () => {
117
+ for (const [route, method] of [
118
+ ['/auth/register', 'POST'],
119
+ ['/auth/login/otp', 'POST'],
120
+ ['/auth/otp/send', 'POST'],
121
+ ['/iam/users', 'GET'],
122
+ ['/iam/roles', 'POST'],
123
+ ] as const) {
124
+ const response = await transport.encryptedFetch(`http://localhost${PREFIX}${route}`, {
125
+ method,
126
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${adminToken}` },
127
+ ...(method === 'POST' ? { body: '{}' } : {}),
128
+ })
129
+ expect(response.status).toBe(404)
130
+ }
131
+ })
34
132
 
35
- expect(response.status).toBe(400)
133
+ it('creates a normal user and enforces guards on every administration API', async () => {
134
+ const created = await call<AdminUser>('/admin/users/create', { username: 'normal-user', password: TEST_PASSWORD, role: 'user', enabled: true }, adminToken)
135
+ expect(created.success).toBe(true)
136
+ if (!created.success)
137
+ return
138
+ user = created.data
139
+ const loggedIn = await call<LoginData>('/auth/login', { identifier: user.username, password: TEST_PASSWORD })
140
+ expect(loggedIn.success).toBe(true)
141
+ if (!loggedIn.success)
142
+ return
143
+ userToken = loggedIn.data.tokens.accessToken
144
+ for (const [route, input] of [
145
+ ['/admin/users/list', { page: 1, pageSize: 20 }],
146
+ ['/admin/users/create', { username: 'forbidden', password: TEST_PASSWORD }],
147
+ ['/admin/users/update', { id: adminId, enabled: false }],
148
+ ['/admin/users/delete', { id: adminId }],
149
+ ['/admin/users/avatar', { id: adminId, contentType: 'image/png', base64: PNG }],
150
+ ['/admin/roles/list', undefined],
151
+ ['/admin/roles/update', { id: 'missing', description: 'x' }],
152
+ ['/admin/audit/list', { page: 1, pageSize: 20 }],
153
+ ] as const) {
154
+ const denied = await call(route, input, userToken)
155
+ expect(denied.success).toBe(false)
156
+ if (!denied.success)
157
+ expect(denied.error.httpStatus).toBe(403)
158
+ }
36
159
  })
37
160
 
38
- it('returns echo result as HaiResult', async () => {
39
- const app = createServerApp()
40
- const response = await app.request(`${API_PREFIX}${apiContract.pathOf({{fullstack.contractExportName}}.app.echo)}`, {
161
+ it('allows refresh-token body transport only for an identified trusted native origin', async () => {
162
+ for (const [clientName, origin] of [['{{projectName}}-app', 'capacitor://localhost'], ['{{projectName}}-desktop', 'http://tauri.localhost']] as const) {
163
+ const headers = { 'content-type': 'application/json', 'x-request-client': clientName, 'x-hai-token-transport': 'body', origin }
164
+ const login = await transport.encryptedFetch(`http://localhost${PREFIX}/auth/login`, {
165
+ method: 'POST',
166
+ headers,
167
+ body: JSON.stringify({ identifier: 'admin', password: TEST_PASSWORD }),
168
+ })
169
+ const result = await login.json() as HaiResult<LoginData>
170
+ expect(result.success).toBe(true)
171
+ if (!result.success)
172
+ continue
173
+ expect(result.data.tokens.refreshToken).toBeTruthy()
174
+ expect(login.headers.get('set-cookie')).toBeNull()
175
+ const refresh = await transport.encryptedFetch(`http://localhost${PREFIX}/auth/refresh`, {
176
+ method: 'POST',
177
+ headers,
178
+ body: JSON.stringify({ refreshToken: result.data.tokens.refreshToken }),
179
+ })
180
+ const refreshed = await refresh.json() as HaiResult<{ tokens: { refreshToken: string } }>
181
+ expect(refreshed.success).toBe(true)
182
+ if (refreshed.success)
183
+ expect(refreshed.data.tokens.refreshToken).not.toBe(result.data.tokens.refreshToken)
184
+ }
185
+ for (const [clientName, origin] of [['{{projectName}}-app', 'http://untrusted.example'], ['unrecognized-client', 'capacitor://localhost']] as const) {
186
+ const response = await transport.encryptedFetch(`http://localhost${PREFIX}/auth/login`, {
187
+ method: 'POST',
188
+ headers: { 'content-type': 'application/json', 'x-request-client': clientName, 'x-hai-token-transport': 'body', origin },
189
+ body: JSON.stringify({ identifier: 'admin', password: TEST_PASSWORD }),
190
+ })
191
+ const result = await response.json() as HaiResult<LoginData>
192
+ expect(result.success).toBe(true)
193
+ if (result.success)
194
+ expect(result.data.tokens.refreshToken).toBeUndefined()
195
+ }
196
+ })
197
+
198
+ it('paginates users and protects administrators from self deletion, disable and downgrade', async () => {
199
+ const listed = await call<{ items: AdminUser[], total: number }>('/admin/users/list', { page: 1, pageSize: 1 }, adminToken)
200
+ expect(listed.success).toBe(true)
201
+ if (listed.success) {
202
+ expect(listed.data.items).toHaveLength(1)
203
+ expect(listed.data.total).toBe(2)
204
+ }
205
+ for (const [route, input] of [['/admin/users/delete', { id: adminId }], ['/admin/users/update', { id: adminId, enabled: false }], ['/admin/users/update', { id: adminId, role: 'user' }]] as const) {
206
+ const denied = await call(route, input, adminToken)
207
+ expect(denied.success).toBe(false)
208
+ if (!denied.success)
209
+ expect(denied.error.httpStatus).toBe(403)
210
+ }
211
+ })
212
+
213
+ it('updates personal preferences, validates images and serves only allowed avatar resources', async () => {
214
+ expect((await call('/profile/update', { displayName: 'Updated', locale: 'en-US', theme: '{{fullstack.theme.defaultTheme}}' }, userToken)).success).toBe(true)
215
+ const current = await call<{ metadata: { locale: string, theme: string } }>('/auth/me', undefined, userToken, 'GET')
216
+ expect(current.success).toBe(true)
217
+ if (current.success)
218
+ expect(current.data.metadata).toMatchObject({ locale: 'en-US', theme: '{{fullstack.theme.defaultTheme}}' })
219
+ expect((await call('/profile/update', { email: 'temporary@example.com' }, userToken)).success).toBe(true)
220
+ const cleared = await call<AdminUser>('/profile/update', { email: '' }, userToken)
221
+ expect(cleared.success).toBe(true)
222
+ if (cleared.success)
223
+ expect(cleared.data.email).toBe('')
224
+ const invalid = await call('/profile/avatar', { contentType: 'image/png', base64: 'PHN2Zz48L3N2Zz4=' }, userToken)
225
+ expect(invalid.success).toBe(false)
226
+ if (!invalid.success)
227
+ expect(invalid.error.httpStatus).toBe(400)
228
+ const oversized = await call('/profile/avatar', { contentType: 'image/png', base64: Buffer.alloc(512 * 1024 + 1).toString('base64') }, userToken)
229
+ expect(oversized.success).toBe(false)
230
+ const anonymous = await call('/profile/avatar', { contentType: 'image/png', base64: PNG })
231
+ expect(anonymous.success).toBe(false)
232
+ if (!anonymous.success)
233
+ expect(anonymous.error.httpStatus).toBe(401)
234
+ const injected = await transport.encryptedFetch(`http://localhost${PREFIX}/profile/update`, {
41
235
  method: 'POST',
42
- headers: { 'content-type': 'application/json' },
43
- body: JSON.stringify({ message: 'hello unit' }),
236
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${userToken}` },
237
+ body: JSON.stringify({ id: adminId, enabled: false, displayName: 'Intruder' }),
238
+ })
239
+ expect(injected.status).toBe(400)
240
+ const uploaded = await call<{ avatarUrl: string }>('/profile/avatar', { contentType: 'image/png', base64: PNG }, userToken)
241
+ expect(uploaded.success).toBe(true)
242
+ if (!uploaded.success)
243
+ return
244
+ expect(uploaded.data.avatarUrl).toMatch(/^\/api\/v1\/assets\/avatars\/[\w-]+\/[\w-]+\.png$/)
245
+ const asset = await app.request(uploaded.data.avatarUrl)
246
+ expect(asset.status).toBe(200)
247
+ expect(asset.headers.get('content-type')).toBe('image/png')
248
+ expect((await asset.arrayBuffer()).byteLength).toBeGreaterThan(0)
249
+ expect((await app.request(`${PREFIX}/assets/avatars/%2e%2e/config/_core.yml`)).status).toBe(404)
250
+ expect((await call('/admin/users/avatar', { id: user.id, contentType: 'image/png', base64: PNG }, adminToken)).success).toBe(true)
251
+ expect((await app.request(uploaded.data.avatarUrl)).status).toBe(404)
252
+ const hostile = await transport.encryptedFetch(`http://attacker.example${PREFIX}/profile/avatar`, {
253
+ method: 'POST',
254
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${userToken}` },
255
+ body: JSON.stringify({ contentType: 'image/png', base64: PNG }),
256
+ })
257
+ const hostileResult = await hostile.json() as HaiResult<{ avatarUrl: string }>
258
+ expect(hostileResult.success).toBe(true)
259
+ if (hostileResult.success) {
260
+ expect(hostileResult.data.avatarUrl).toMatch(/^\/api\/v1\/assets\//)
261
+ expect(hostileResult.data.avatarUrl).not.toContain('attacker.example')
262
+ const stored = await iam.user.getUser(user.id)
263
+ expect(stored.success).toBe(true)
264
+ if (stored.success)
265
+ expect(stored.data?.avatarUrl).toBe(hostileResult.data.avatarUrl)
266
+ }
267
+ })
268
+
269
+ it('lists two roles, updates descriptions and queries audit records without credentials or file content', async () => {
270
+ const roles = await call<{ items: { id: string, code: string, userCount: number }[] }>('/admin/roles/list', undefined, adminToken)
271
+ expect(roles.success).toBe(true)
272
+ if (roles.success) {
273
+ expect(roles.data.items.map(item => item.code).sort()).toEqual(['admin', 'user'])
274
+ const role = roles.data.items.find(item => item.code === 'user')!
275
+ expect(role.userCount).toBe(1)
276
+ expect((await call('/admin/roles/update', { id: role.id, description: 'Updated description' }, adminToken)).success).toBe(true)
277
+ }
278
+ const logs = await call<{ items: { action: string, details: string | null }[], total: number }>('/admin/audit/list', { page: 1, pageSize: 100 }, adminToken)
279
+ expect(logs.success).toBe(true)
280
+ if (logs.success) {
281
+ expect(logs.data.total).toBeGreaterThan(0)
282
+ expect(JSON.stringify(logs.data)).not.toContain(TEST_PASSWORD)
283
+ expect(JSON.stringify(logs.data)).not.toContain(PNG)
284
+ }
285
+ })
286
+
287
+ it('does not write profile fields when role synchronization fails', async () => {
288
+ const before = await iam.user.getUser(user.id)
289
+ expect(before.success).toBe(true)
290
+ const sync = vi.spyOn(iam.authz, 'syncRoles').mockResolvedValueOnce(err(HaiCommonError.API_ERROR, 'Injected role persistence failure'))
291
+ const failed = await call('/admin/users/update', { id: user.id, role: 'admin', email: 'changed@example.com', displayName: 'Should not change' }, adminToken)
292
+ sync.mockRestore()
293
+ expect(failed.success).toBe(false)
294
+ const after = await iam.user.getUser(user.id)
295
+ expect(after.success).toBe(true)
296
+ if (before.success && after.success)
297
+ expect(after.data).toMatchObject({ email: before.data?.email, displayName: before.data?.displayName })
298
+ })
299
+
300
+ it('keeps an already committed profile update successful when best-effort audit fails', async () => {
301
+ const log = vi.spyOn(audit, 'log').mockResolvedValueOnce(err(HaiCommonError.API_ERROR, 'Injected audit persistence failure'))
302
+ const logger = vi.spyOn(core.logger, 'error').mockImplementation(() => {})
303
+ try {
304
+ const result = await call<AdminUser>('/profile/update', { displayName: 'Committed profile' }, userToken)
305
+ expect(result.success).toBe(true)
306
+ if (result.success)
307
+ expect(result.data.displayName).toBe('Committed profile')
308
+ expect(logger).toHaveBeenCalledWith('Failed to record audit event', expect.objectContaining({ action: 'update', resource: 'profile' }))
309
+ }
310
+ finally {
311
+ log.mockRestore()
312
+ logger.mockRestore()
313
+ }
314
+ })
315
+
316
+ it('rolls back roles when profile persistence fails and keeps role changes effective in active sessions', async () => {
317
+ const update = vi.spyOn(iam.user, 'updateUser').mockResolvedValueOnce(err(HaiCommonError.API_ERROR, 'Injected user persistence failure'))
318
+ const failed = await call('/admin/users/update', { id: user.id, role: 'admin', email: 'changed@example.com' }, adminToken)
319
+ update.mockRestore()
320
+ expect(failed.success).toBe(false)
321
+ const roles = await iam.authz.getUserRoles(user.id)
322
+ expect(roles.success).toBe(true)
323
+ if (roles.success)
324
+ expect(roles.data.map(role => role.code)).toEqual(['user'])
325
+ expect((await call('/admin/users/update', { id: user.id, role: 'admin' }, adminToken)).success).toBe(true)
326
+ expect((await call('/admin/users/list', { page: 1, pageSize: 20 }, userToken)).success).toBe(true)
327
+ expect((await call('/admin/users/update', { id: user.id, role: 'user' }, adminToken)).success).toBe(true)
328
+ const denied = await call('/admin/users/list', { page: 1, pageSize: 20 }, userToken)
329
+ expect(denied.success).toBe(false)
330
+ if (!denied.success)
331
+ expect(denied.error.httpStatus).toBe(403)
332
+ })
333
+
334
+ it('rejects stale administrator snapshots and reports fresh user roles', async () => {
335
+ const stale = await iam.session.update(userToken, { roles: ['admin'], permissions: ['*'] })
336
+ expect(stale.success).toBe(true)
337
+ for (const [route, input] of [['/admin/users/list', { page: 1, pageSize: 20 }], ['/admin/roles/list', undefined], ['/admin/audit/list', { page: 1, pageSize: 20 }]] as const) {
338
+ const denied = await call(route, input, userToken)
339
+ expect(denied.success).toBe(false)
340
+ if (!denied.success)
341
+ expect(denied.error.httpStatus).toBe(403)
342
+ }
343
+ const current = await call<{ roles: string[] }>('/auth/me', undefined, userToken, 'GET')
344
+ expect(current.success).toBe(true)
345
+ if (current.success)
346
+ expect(current.data.roles).toEqual(['user'])
347
+ })
348
+
349
+ it('returns a conflict when another administrator holds the mutation lock and resumes after release', async () => {
350
+ const locked = await cache.lock.acquire('hai:app:admin-mutation', { owner: 'test-owner', ttl: 10 })
351
+ expect(locked.success).toBe(true)
352
+ try {
353
+ const result = await call('/admin/users/update', { id: user.id, displayName: 'Locked' }, adminToken)
354
+ expect(result.success).toBe(false)
355
+ if (!result.success)
356
+ expect(result.error.httpStatus).toBe(409)
357
+ }
358
+ finally {
359
+ expect((await cache.lock.release('hai:app:admin-mutation', 'test-owner')).success).toBe(true)
360
+ }
361
+ expect((await call('/admin/users/update', { id: user.id, displayName: 'Unlocked' }, adminToken)).success).toBe(true)
362
+ })
363
+
364
+ it('keeps single-writer protection after an expired lease and resumes after a slow mutation', async () => {
365
+ const other = await call<AdminUser>('/admin/users/create', { username: 'lease-admin', password: TEST_PASSWORD, role: 'admin' }, adminToken)
366
+ expect(other.success).toBe(true)
367
+ if (!other.success)
368
+ return
369
+ const otherLogin = await call<LoginData>('/auth/login', { identifier: 'lease-admin', password: TEST_PASSWORD })
370
+ expect(otherLogin.success).toBe(true)
371
+ if (!otherLogin.success)
372
+ return
373
+ let resume: () => void = () => {}
374
+ let entered: () => void = () => {}
375
+ const paused = new Promise<void>((resolve) => {
376
+ resume = resolve
377
+ })
378
+ const entry = new Promise<void>((resolve) => {
379
+ entered = resolve
44
380
  })
45
- const body = await response.json()
381
+ const original = iam.user.updateUser
382
+ const update = vi.spyOn(iam.user, 'updateUser').mockImplementationOnce(async (id, data) => {
383
+ entered()
384
+ await paused
385
+ return original(id, data)
386
+ })
387
+ const first = call('/admin/users/update', { id: other.data.id, enabled: false }, adminToken)
388
+ await entry
389
+ const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 301_000)
390
+ try {
391
+ const conflicting = await call('/admin/users/update', { id: adminId, enabled: false }, otherLogin.data.tokens.accessToken)
392
+ expect(conflicting.success).toBe(false)
393
+ if (!conflicting.success)
394
+ expect(conflicting.error.httpStatus).toBe(409)
395
+ resume()
396
+ expect((await first).success).toBe(true)
397
+ const admin = await iam.user.getUser(adminId)
398
+ expect(admin.success).toBe(true)
399
+ if (admin.success)
400
+ expect(admin.data?.enabled).toBe(true)
401
+ }
402
+ finally {
403
+ resume()
404
+ now.mockRestore()
405
+ update.mockRestore()
406
+ }
407
+ expect((await call('/admin/users/delete', { id: other.data.id }, adminToken)).success).toBe(true)
408
+ })
409
+
410
+ it('serializes mutually destructive admin requests and preserves an enabled administrator', async () => {
411
+ const created = await call<AdminUser>('/admin/users/create', { username: 'second-admin', password: TEST_PASSWORD, role: 'admin' }, adminToken)
412
+ expect(created.success).toBe(true)
413
+ if (!created.success)
414
+ return
415
+ const loggedIn = await call<LoginData>('/auth/login', { identifier: 'second-admin', password: TEST_PASSWORD })
416
+ expect(loggedIn.success).toBe(true)
417
+ if (!loggedIn.success)
418
+ return
419
+ // 对手同时请求禁用原管理员;锁内重新鉴权阻止两个请求全部成功。
420
+ const results = await Promise.all([
421
+ call('/admin/users/update', { id: created.data.id, enabled: false }, adminToken),
422
+ call('/admin/users/update', { id: adminId, enabled: false }, loggedIn.data.tokens.accessToken),
423
+ ])
424
+ expect(results.filter(result => result.success)).toHaveLength(1)
425
+ const admins = await iam.user.listUsers({ enabled: true, include: ['roles'] })
426
+ expect(admins.success).toBe(true)
427
+ if (admins.success) {
428
+ const surviving = admins.data.items.filter(item => item.roles?.some(role => role.code === 'admin'))
429
+ expect(surviving).toHaveLength(1)
430
+ // 后续用仍有效的管理员会话验证其他操作。
431
+ if (surviving[0]?.id !== adminId) {
432
+ adminToken = loggedIn.data.tokens.accessToken
433
+ adminId = created.data.id
434
+ }
435
+ }
436
+ })
46
437
 
47
- expect(response.status).toBe(200)
48
- expect(body.success).toBe(true)
49
- expect(body.data.message).toBe('hello unit')
50
- expect(body.data.requestId).toBeDefined()
438
+ it('invalidates sessions on disable and password change and proves encrypted traffic', async () => {
439
+ expect((await call('/admin/users/update', { id: user.id, enabled: false }, adminToken)).success).toBe(true)
440
+ const denied = await call('/auth/me', undefined, userToken, 'GET')
441
+ expect(denied.success).toBe(false)
442
+ if (!denied.success)
443
+ expect(denied.error.httpStatus).toBe(401)
444
+ expect((await call('/admin/users/update', { id: user.id, enabled: true }, adminToken)).success).toBe(true)
445
+ const loggedIn = await call<LoginData>('/auth/login', { identifier: user.username, password: TEST_PASSWORD })
446
+ expect(loggedIn.success).toBe(true)
447
+ if (!loggedIn.success)
448
+ return
449
+ expect((await call('/auth/change-password', { oldPassword: TEST_PASSWORD, newPassword: 'ChangedTest123!' }, loggedIn.data.tokens.accessToken)).success).toBe(true)
450
+ expect((await call('/auth/me', undefined, loggedIn.data.tokens.accessToken, 'GET')).success).toBe(false)
451
+ const changedLogin = await call<LoginData>('/auth/login', { identifier: user.username, password: 'ChangedTest123!' })
452
+ expect(changedLogin.success).toBe(true)
453
+ if (changedLogin.success) {
454
+ expect((await call('/auth/logout', {}, changedLogin.data.tokens.accessToken)).success).toBe(true)
455
+ expect((await call('/auth/me', undefined, changedLogin.data.tokens.accessToken, 'GET')).success).toBe(false)
456
+ }
457
+ expect((await call('/admin/users/delete', { id: user.id }, adminToken)).success).toBe(true)
458
+ const removed = await iam.user.getUser(user.id)
459
+ expect(removed.success).toBe(true)
460
+ if (removed.success)
461
+ expect(removed.data).toBeNull()
462
+ expect(encryptedRequests).toBeGreaterThan(10)
463
+ expect(encryptedResponses).toBeGreaterThan(10)
51
464
  })
52
465
  })
@@ -0,0 +1,48 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { audit } from '@h-ai/audit'
5
+ import { cache } from '@h-ai/cache'
6
+ import { core } from '@h-ai/core'
7
+ import { crypto } from '@h-ai/crypto'
8
+ import { iam } from '@h-ai/iam'
9
+ import { reldb } from '@h-ai/reldb'
10
+ import { storage } from '@h-ai/storage'
11
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
12
+ import { closeModules, initializeModules } from '../src/server/runtime.js'
13
+
14
+ let storageRoot: string
15
+
16
+ beforeEach(async () => {
17
+ storageRoot = await mkdtemp(path.join(os.tmpdir(), '{{projectName}}-bootstrap-'))
18
+ vi.stubEnv('HAI_DB_DATABASE', '":memory:"')
19
+ vi.stubEnv('HAI_STORAGE_ROOT', storageRoot)
20
+ vi.stubEnv('HAI_IAM_PASSWORD_REQUIREUPPERCASE', 'true')
21
+ core.init({ configDir: './config' })
22
+ })
23
+
24
+ afterEach(async () => {
25
+ await closeModules()
26
+ vi.unstubAllEnvs()
27
+ await rm(storageRoot, { recursive: true, force: true })
28
+ })
29
+
30
+ it.each([
31
+ { password: 'abc', reason: /at least 8 characters|至少为 8 个字符/ },
32
+ { password: 'lowercase123!', reason: /uppercase letters|大写字母/ },
33
+ ])('startup explains the failed password policy and permits a clean retry: $password', async ({ password, reason }) => {
34
+ vi.stubEnv('HAI_ADMIN_DEFAULT_PASSWORD', password)
35
+ await expect(initializeModules()).rejects.toThrow(reason)
36
+ for (const module of [audit, iam, storage, crypto, cache, reldb])
37
+ expect(module.isInitialized).toBe(false)
38
+
39
+ // 失败后再次初始化,确认未保留半成品管理员或未释放的模块。
40
+ vi.stubEnv('HAI_ADMIN_DEFAULT_PASSWORD', 'Bootstrap-retry-Admin123!')
41
+ await initializeModules()
42
+ const users = await iam.user.listUsers({ page: 1, pageSize: 10 })
43
+ expect(users.success).toBe(true)
44
+ if (users.success) {
45
+ expect(users.data.total).toBe(1)
46
+ expect(users.data.items[0]?.username).toBe('admin')
47
+ }
48
+ })
@@ -3,7 +3,7 @@ import { defineConfig } from 'tsup'
3
3
  export default defineConfig({
4
4
  entry: ['src/index.ts'],
5
5
  format: ['esm'],
6
- dts: true,
6
+ dts: { compilerOptions: { ignoreDeprecations: '6.0' } },
7
7
  sourcemap: true,
8
8
  clean: true,
9
9
  })