267 lines
9.4 KiB
TypeScript
267 lines
9.4 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'),
|
|
}))
|
|
|
|
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: {
|
|
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 () => {
|
|
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')
|
|
})
|
|
})
|