381 lines
14 KiB
TypeScript
381 lines
14 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/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();
|
||
});
|
||
});
|