@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.
- package/README.en.md +37 -3
- package/README.ja.md +37 -3
- package/README.md +37 -3
- package/dist/chunk-2H7UOFLK.js +0 -0
- package/dist/{chunk-MJGUBOAV.js → chunk-F4IBPVZ5.js} +6 -42
- package/dist/chunk-F4IBPVZ5.js.map +1 -0
- package/dist/{chunk-O3ANVICQ.js → chunk-WUSFJFW5.js} +13 -7
- package/dist/chunk-WUSFJFW5.js.map +1 -0
- package/dist/cli-config-templates-6XA7V6CC.js +5 -0
- package/dist/{cli-config-templates-OWBEGBZQ.js.map → cli-config-templates-6XA7V6CC.js.map} +1 -1
- package/dist/{cli-skill-templates-ZYXES3NT.js → cli-skill-templates-45CK4RQ2.js} +3 -3
- package/dist/{cli-skill-templates-ZYXES3NT.js.map → cli-skill-templates-45CK4RQ2.js.map} +1 -1
- package/dist/index.js +107 -61
- package/dist/index.js.map +1 -1
- package/dist/{src-TBIET7YU.js → src-DUGLX4YB.js} +7270 -2119
- package/dist/src-DUGLX4YB.js.map +1 -0
- package/package.json +17 -17
- package/templates/README.md +4 -4
- package/templates/apps/admin/src/routes/+layout.svelte.hbs +7 -0
- package/templates/apps/admin/src/routes/+page.svelte.hbs +5 -4
- package/templates/apps/admin/src/routes/admin/+layout.svelte.hbs +2 -2
- package/templates/apps/api/.npmrc.hbs +1 -2
- package/templates/apps/api/Dockerfile.hbs +6 -5
- package/templates/apps/api/apps/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-contract/src/app-schemas.ts.hbs +2 -0
- package/templates/apps/api/apps/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/config/_core.yml.hbs +8 -0
- package/templates/apps/api/apps/{{projectName}}-service/package.json.hbs +1 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +7 -8
- package/templates/apps/api/apps/{{projectName}}-service/src/index.ts.hbs +4 -3
- package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +0 -1
- package/templates/apps/api/apps/{{projectName}}-service/src/server/procedures/app-procedures.ts.hbs +4 -0
- package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +3 -1
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +3 -1
- package/templates/apps/api/package.json.hbs +2 -2
- package/templates/apps/api/pnpm-workspace.yaml.hbs +26 -0
- package/templates/apps/api/tsconfig.base.json +0 -1
- package/templates/apps/fullstack/.dockerignore.hbs +3 -0
- package/templates/apps/fullstack/.npmrc.hbs +1 -2
- package/templates/apps/fullstack/Dockerfile.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/capacitor.config.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/index.html.hbs +7 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/en-US.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/messages/zh-CN.json.hbs +1 -34
- package/templates/apps/fullstack/apps/{{projectName}}-app/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/App.svelte.hbs +30 -170
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/lib/capacitor.ts.hbs +6 -5
- package/templates/apps/fullstack/apps/{{projectName}}-app/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +3 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/package.json.hbs +5 -4
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src/App.svelte.hbs +10 -141
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/Cargo.toml.hbs +5 -5
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/src-tauri/tauri.conf.json.hbs +3 -3
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/vite.config.ts.hbs +21 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/index.html.hbs +6 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/en-US.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/messages/zh-CN.json.hbs +1 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/package.json.hbs +2 -2
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/App.svelte.hbs +5 -143
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/app.d.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/src/main.ts.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/vite.config.ts.hbs +48 -22
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +206 -16
- package/templates/apps/fullstack/package.json.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/package.json.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +66 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +96 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/app-schemas.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/index.ts.hbs +3 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/{{projectName}}-contract.ts.hbs +5 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tests/contract.test.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-contract/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +13 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +2 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +21 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +10 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +63 -28
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/index.ts.hbs +26 -11
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +276 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/messages.ts.hbs +29 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +94 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/app-procedures.ts.hbs +6 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/index.ts.hbs +5 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +98 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +446 -33
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/runtime.test.ts.hbs +48 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tsup.config.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +19 -26
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +78 -15
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +4 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/index.ts.hbs +3 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +53 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +73 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +55 -45
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AuditManagement.svelte.hbs +61 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +57 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/RoleManagement.svelte.hbs +49 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +174 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +399 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/theme-store.svelte.ts.hbs +7 -39
- package/templates/apps/fullstack/packages/{{projectName}}-shared/svelte.config.js.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/api-client.test.ts.hbs +151 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +101 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/vitest.config.ts.hbs +7 -0
- package/templates/apps/fullstack/playwright.config.ts.hbs +6 -0
- package/templates/apps/fullstack/pnpm-workspace.yaml.hbs +24 -0
- package/templates/apps/fullstack/tsconfig.base.json +0 -1
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +4 -0
- package/templates/apps/h5/src/routes/+page.svelte.hbs +3 -2
- package/templates/apps/mobile-app/index.html.hbs +2 -2
- package/templates/apps/mobile-app/src/App.svelte.hbs +6 -2
- package/templates/apps/mobile-app/src/lib/app-config.ts.hbs +13 -0
- package/templates/apps/website/src/routes/+layout.svelte.hbs +8 -2
- package/templates/apps/website/src/routes/+page.svelte.hbs +4 -3
- package/templates/base/.npmrc.hbs +1 -15
- package/templates/base/Dockerfile.hbs +9 -8
- package/templates/base/e2e/app.spec.ts.hbs +1 -0
- package/templates/base/package.json.hbs +39 -42
- package/templates/base/pnpm-workspace.yaml.hbs +26 -0
- package/templates/base/src/app.d.ts.hbs +4 -0
- package/templates/base/src/hooks.server.ts.hbs +0 -8
- package/templates/base/src/lib/server/init.ts.hbs +26 -5
- package/templates/base/src/routes/+layout.server.ts.hbs +18 -0
- package/templates/base/tests/scaffold.test.ts.hbs +21 -1
- package/templates/base/vite.config.ts.hbs +21 -0
- package/templates/features/ai/routes/api/chat/+server.ts +6 -7
- package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +1 -1
- package/templates/features/iam/routes-shared/api/auth/login/+server.ts +14 -10
- package/templates/features/iam/routes-shared/api/auth/logout/+server.ts +6 -4
- package/templates/features/storage/routes/api/upload/+server.ts +10 -8
- package/templates/skills/CLAUDE.md +0 -14
- package/templates/skills/bridges/fullstack/AGENTS.md.hbs +13 -2
- package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +3 -0
- package/templates/skills/hai-api-client/SKILL.md +1 -1
- package/templates/skills/hai-app-create/development.md +6 -0
- package/templates/skills/hai-build/reference.md +4 -2
- package/templates/skills/hai-core/SKILL.md +2 -0
- package/templates/skills/hai-core/reference.md +6 -4
- package/templates/skills/hai-framework-sync/SKILL.md +1 -2
- package/templates/skills/hai-serv/SKILL.md +1 -0
- package/templates/skills/hai-serv/reference.md +3 -1
- package/templates/skills/hai-ui/SKILL.md +6 -0
- package/templates/skills/hai-ui/reference.md +7 -4
- package/templates/skills/hai-vecdb/reference.md +53 -49
- package/dist/chunk-MJGUBOAV.js.map +0 -1
- package/dist/chunk-O3ANVICQ.js.map +0 -1
- package/dist/cli-config-templates-OWBEGBZQ.js +0 -5
- package/dist/src-TBIET7YU.js.map +0 -1
- package/templates/apps/fullstack/apps/{{projectName}}-app/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/tests/api.test.ts.hbs +0 -28
- package/templates/apps/fullstack/apps/{{projectName}}-web/tests/api.test.ts.hbs +0 -55
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/LanguageSwitcher.svelte.hbs +0 -27
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ThemeSwitcher.svelte.hbs +0 -18
- package/templates/skills/bridges/admin/CLAUDE.md +0 -15
- package/templates/skills/bridges/api/CLAUDE.md +0 -15
- package/templates/skills/bridges/fullstack/CLAUDE.md.hbs +0 -9
- package/templates/skills/bridges/h5/CLAUDE.md +0 -15
- package/templates/skills/bridges/mobile-app/CLAUDE.md +0 -15
- package/templates/skills/bridges/website/CLAUDE.md +0 -15
- 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 {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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('
|
|
39
|
-
const app
|
|
40
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
48
|
-
expect(
|
|
49
|
-
|
|
50
|
-
expect(
|
|
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
|
+
})
|