2026-06-02 19:25:48 +08:00

292 lines
10 KiB
TypeScript

import { NextRequest } from 'next/server'
import { POST } from '../route'
import { verifyLicenseSignature } from '@/lib/license/crypto-service'
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'
import { syncPremiumModules } from '@/lib/license/premium-module-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
jest.mock('@/lib/license/fingerprint-service', () => ({
generateFingerprint: jest.fn().mockResolvedValue({
machineId: 'machine-1',
hostname: 'nas-1',
macAddress: 'AA:BB:CC:DD:EE:FF',
instanceId: 'instance-1',
}),
}))
jest.mock('@/lib/license/server-config', () => ({
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/activate'),
getLicenseProjectCode: jest.fn(() => 'flnxi-sucai'),
}))
jest.mock('@/lib/license/crypto-service', () => ({
verifyLicenseSignature: jest.fn(),
}))
jest.mock('@/lib/license/license-service', () => ({
saveLicenseLocally: jest.fn(),
initializeSystem: jest.fn(),
startHeartbeat: jest.fn(),
}))
jest.mock('@/lib/license/premium-module-service', () => ({
syncPremiumModules: jest.fn(),
}))
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}))
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>
const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction<typeof syncPremiumModules>
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>
const signedLicense = {
data: {
projectCode: 'flnxi-sucai',
licenseKey: 'ABCD-1234-EFGH-5678',
instanceId: 'instance-1',
fingerprintHash: 'a'.repeat(64),
type: 'PRO',
limits: { maxUsers: 100, maxMaterials: 10000 },
features: {
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
intranetPenetration: true,
},
expiresAt: null,
issuedAt: '2026-05-24T00:00:00.000Z',
},
signature: 'signed-license',
issuedAt: '2026-05-24T00:00:00.000Z',
}
function createMockRequest(options: {
url?: string
headers?: Record<string, string>
body?: Record<string, unknown>
} = {}) {
const url = options.url || 'http://localhost:3000/api/license/activate'
const headers = new Headers(options.headers || {})
return new NextRequest(url, {
method: 'POST',
headers,
body: JSON.stringify({
licenseKey: 'ABCD-1234-EFGH-5678',
...options.body,
}),
})
}
function mockActivateSuccess(overrides: Record<string, unknown> = {}) {
;(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
license: signedLicense,
...overrides,
}),
})
}
describe('POST /api/license/activate', () => {
beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn()
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai'
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32)
mockVerifyLicenseSignature.mockReturnValue(true)
mockSyncPremiumModules.mockResolvedValue({ synced: true })
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true })
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
},
} as any)
})
test('缺少激活码时应返回 400', async () => {
const response = await POST(createMockRequest({ body: { licenseKey: '' } }))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.success).toBe(false)
expect(data.error).toBe('请输入激活码')
expect(global.fetch).not.toHaveBeenCalled()
})
test('激活成功时保存签名许可证并设置 cookie', async () => {
mockActivateSuccess()
const response = await POST(createMockRequest())
const data = await response.json()
const setCookieHeader = response.headers.get('set-cookie')
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense)
expect(mockVerifyLicenseSignature).toHaveBeenCalledWith(signedLicense.data, signedLicense.signature)
expect(mockSyncPremiumModules).toHaveBeenCalled()
expect(mockStartHeartbeat).toHaveBeenCalled()
expect(setCookieHeader).toContain('license_activated=true')
expect(setCookieHeader).toContain('HttpOnly')
expect(setCookieHeader).toContain('Max-Age=31536000')
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)
expect(fetchBody.timestamp).toEqual(expect.any(Number))
expect(fetchBody.nonce).toMatch(/^[0-9a-f]{32}$/)
expect(fetchBody.signature).toMatch(/^[0-9a-f]{64}$/)
expect(fetchBody.licenseKey).toBe('ABCD-1234-EFGH-5678')
})
test('已有最高权限账号后,未授权请求不能改写本地许可证', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.success).toBe(false)
expect(data.error).toBe('系统已初始化,许可证变更需要最高权限账号登录')
expect(global.fetch).not.toHaveBeenCalled()
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockInitializeSystem).not.toHaveBeenCalled()
expect(mockStartHeartbeat).not.toHaveBeenCalled()
})
test('环境变量来源的初始管理员密码不应在激活响应中回显', async () => {
mockActivateSuccess()
mockInitializeSystem.mockResolvedValueOnce({
admin: {
created: true,
email: 'admin@local.flnxi',
username: 'admin',
initialPassword: 'Env-Password-Should-Not-Return',
mustChangePassword: true,
passwordSource: 'environment',
},
} as any)
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.initialAdmin).toEqual({
email: 'admin@local.flnxi',
username: 'admin',
password: null,
mustChangePassword: true,
passwordSource: 'environment',
})
})
test('系统生成的一次性初始管理员密码仍只在首次激活响应中返回', async () => {
mockActivateSuccess()
mockInitializeSystem.mockResolvedValueOnce({
admin: {
created: true,
email: 'admin@local.flnxi',
username: 'admin',
initialPassword: 'Generated-Password-123',
mustChangePassword: true,
passwordSource: 'generated',
},
} as any)
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.initialAdmin.password).toBe('Generated-Password-123')
expect(data.initialAdmin.passwordSource).toBe('generated')
})
test('许可证服务器拒绝或签名验证失败时不保存本地许可证', async () => {
mockActivateSuccess({
success: false,
error: '许可证无效',
license: undefined,
})
const rejectedResponse = await POST(createMockRequest())
const rejectedData = await rejectedResponse.json()
expect(rejectedResponse.status).toBe(400)
expect(rejectedData.error).toBe('许可证无效')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
mockActivateSuccess()
mockVerifyLicenseSignature.mockReturnValueOnce(false)
const invalidSignatureResponse = await POST(createMockRequest())
const invalidSignatureData = await invalidSignatureResponse.json()
expect(invalidSignatureResponse.status).toBe(400)
expect(invalidSignatureData.error).toBe('许可证签名验证失败')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
})
test('许可证绑定信息不匹配时不保存本地许可证', async () => {
mockActivateSuccess({
license: {
...signedLicense,
data: {
...signedLicense.data,
instanceId: 'other-instance-id',
},
},
})
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('不属于当前设备')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
})
test('授权模块清单同步失败不应阻断许可证激活', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockActivateSuccess()
mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' })
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense)
expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down')
consoleWarnSpy.mockRestore()
})
test('HTTPS 或代理 HTTPS 时 cookie 带 Secure', async () => {
mockActivateSuccess()
const httpsResponse = await POST(createMockRequest({
url: 'https://example.com/api/license/activate',
}))
expect(httpsResponse.headers.get('set-cookie')).toContain('Secure')
mockActivateSuccess()
const forwardedHttpsResponse = await POST(createMockRequest({
url: 'http://localhost:3000/api/license/activate',
headers: { 'x-forwarded-proto': 'https' },
}))
expect(forwardedHttpsResponse.headers.get('set-cookie')).toContain('Secure')
})
})