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

381 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/register-free'),
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 signedFreeLicense = {
data: {
projectCode: 'flnxi-sucai',
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
instanceId: 'instance-1',
fingerprintHash: 'a'.repeat(64),
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
expiresAt: null,
issuedAt: '2026-05-24T00:00:00.000Z',
},
signature: 'signed-free-license',
issuedAt: '2026-05-24T00:00:00.000Z',
};
function createMockRequest(options: {
url?: string;
headers?: Record<string, string>;
body?: Record<string, unknown>;
} = {}): NextRequest {
const url = options.url || 'http://localhost:3000/api/license/activate-free';
const headers = new Headers(options.headers || {});
return new NextRequest(url, {
method: 'POST',
headers,
body: JSON.stringify({
customerName: '测试公司',
contactInfo: '13800000000',
...options.body,
}),
});
}
function mockRegisterFreeSuccess(overrides: Record<string, unknown> = {}) {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
license: signedFreeLicense,
data: {
activationCode: 'FLNXI-FREE-TEST-1234-5678-ABCD',
isExisting: false,
},
...overrides,
}),
});
}
describe('POST /api/license/activate-free', () => {
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);
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true });
mockSyncPremiumModules.mockResolvedValue({ synced: true });
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
},
} as any);
});
it('保存许可证服务端返回的签名免费许可证,不再写空签名', async () => {
mockRegisterFreeSuccess();
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockSaveLicenseLocally).toHaveBeenCalledWith(
'FLNXI-FREE-TEST-1234-5678-ABCD',
signedFreeLicense
);
expect(mockSaveLicenseLocally.mock.calls[0][1].signature).toBe('signed-free-license');
expect(mockVerifyLicenseSignature).toHaveBeenCalledWith(
signedFreeLicense.data,
signedFreeLicense.signature
);
expect(mockSyncPremiumModules).toHaveBeenCalled();
expect(mockStartHeartbeat).toHaveBeenCalled();
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.customerName).toBe('测试公司');
expect(fetchBody.contactInfo).toBe('13800000000');
});
it('免费版授权模块同步失败不应阻断首次激活', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRegisterFreeSuccess();
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).toHaveBeenCalled();
expect(mockInitializeSystem).toHaveBeenCalled();
expect(mockStartHeartbeat).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down');
consoleWarnSpy.mockRestore();
});
it('已有最高权限账号后,未授权请求不能改写免费许可证', 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(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证服务没有返回签名许可证时拒绝激活,避免保存不可读取的本地许可证', async () => {
mockRegisterFreeSuccess({ license: undefined });
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(502);
expect(data.success).toBe(false);
expect(data.error).toContain('许可证服务返回数据异常');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证签名验证失败时拒绝激活', async () => {
mockRegisterFreeSuccess();
mockVerifyLicenseSignature.mockReturnValue(false);
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('许可证签名验证失败');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证绑定信息不匹配时拒绝激活', async () => {
mockRegisterFreeSuccess({
license: {
...signedFreeLicense,
data: {
...signedFreeLicense.data,
licenseKey: 'FLNXI-FREE-OTHER-1234-5678-ABCD',
},
},
});
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();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('应该设置 license_activated cookie', async () => {
mockRegisterFreeSuccess();
const response = await POST(createMockRequest());
const setCookieHeader = response.headers.get('set-cookie');
expect(setCookieHeader).toContain('license_activated=true');
expect(setCookieHeader).toContain('HttpOnly');
expect(setCookieHeader).toContain('Path=/');
expect(setCookieHeader).toContain('Max-Age=31536000');
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax');
});
it('初始管理员密码来自环境变量时不应在激活响应中回显明文', async () => {
mockRegisterFreeSuccess();
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',
});
});
it('系统生成的一次性初始管理员密码仍只在首次激活响应中返回', async () => {
mockRegisterFreeSuccess();
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');
});
it('免费版激活日志不应输出完整激活码', async () => {
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
mockRegisterFreeSuccess();
const response = await POST(createMockRequest());
const data = await response.json();
const logOutput = JSON.stringify(consoleLogSpy.mock.calls);
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(logOutput).toContain('FLNX****ABCD');
expect(logOutput).not.toContain('FLNXI-FREE-TEST-1234-5678-ABCD');
consoleLogSpy.mockRestore();
});
it('HTTP 请求时 cookie 不带 Secure,HTTPS 或代理 HTTPS 时带 Secure', async () => {
mockRegisterFreeSuccess();
const httpResponse = await POST(createMockRequest({
url: 'http://localhost:3000/api/license/activate-free',
}));
expect(httpResponse.headers.get('set-cookie')).not.toContain('Secure');
mockRegisterFreeSuccess();
const httpsResponse = await POST(createMockRequest({
url: 'https://example.com/api/license/activate-free',
}));
expect(httpsResponse.headers.get('set-cookie')).toContain('Secure');
mockRegisterFreeSuccess();
const forwardedHttpsResponse = await POST(createMockRequest({
url: 'http://localhost:3000/api/license/activate-free',
headers: { 'x-forwarded-proto': 'https' },
}));
expect(forwardedHttpsResponse.headers.get('set-cookie')).toContain('Secure');
});
it('许可证服务器返回失败时应该返回 400', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: false,
error: '试用已过期',
}),
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('试用已过期');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
});
it('许可证服务器网络失败时应该返回 400', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
json: async () => ({ error: '许可证服务不可用' }),
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('许可证服务不可用');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
});
it('缺少客户名称或联系方式时返回 400', async () => {
const missingNameResponse = await POST(createMockRequest({ body: { customerName: '' } }));
expect(missingNameResponse.status).toBe(400);
const missingContactResponse = await POST(createMockRequest({ body: { contactInfo: '' } }));
expect(missingContactResponse.status).toBe(400);
expect(global.fetch).not.toHaveBeenCalled();
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
});
});