794 lines
27 KiB
TypeScript
794 lines
27 KiB
TypeScript
/**
|
|
* 许可证服务模块测试
|
|
* 测试 lib/license/license-service.ts 中的核心功能
|
|
*/
|
|
|
|
// Mock 依赖模块
|
|
jest.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
systemConfig: {
|
|
findUnique: jest.fn(),
|
|
upsert: jest.fn(),
|
|
update: jest.fn(),
|
|
delete: jest.fn(),
|
|
},
|
|
user: {
|
|
count: jest.fn(),
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
},
|
|
material: {
|
|
count: jest.fn(),
|
|
},
|
|
department: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
category: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
scene: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
$transaction: jest.fn((fn) => fn({
|
|
user: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
},
|
|
department: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
category: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
scene: {
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
})),
|
|
},
|
|
}));
|
|
|
|
jest.mock('../license/fingerprint-service', () => ({
|
|
generateFingerprint: jest.fn(),
|
|
getInstanceId: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('../license/crypto-service', () => ({
|
|
verifyLicenseSignature: jest.fn(),
|
|
encryptLocal: jest.fn((data) => `encrypted:${data}`),
|
|
decryptLocal: jest.fn((data) => data.replace('encrypted:', '')),
|
|
signLocalIntegrity: jest.fn((data) => `integrity:${data}`),
|
|
verifyLocalIntegrity: jest.fn((data, signature) => signature === `integrity:${data}`),
|
|
}));
|
|
|
|
jest.mock('@/lib/system-integrity', () => ({
|
|
ensureBuiltinTemplates: jest.fn(),
|
|
}));
|
|
|
|
// Mock global fetch
|
|
const mockFetch = jest.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
import { prisma } from '@/lib/prisma';
|
|
import { verifyLicenseSignature } from '../license/crypto-service';
|
|
import { generateFingerprint, getInstanceId } from '../license/fingerprint-service';
|
|
import { LICENSE_CONFIGS } from '../license/types';
|
|
import type { SignedLicense } from '../license/types';
|
|
|
|
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>;
|
|
const mockGenerateFingerprint = generateFingerprint as jest.MockedFunction<typeof generateFingerprint>;
|
|
const mockGetInstanceId = getInstanceId as jest.MockedFunction<typeof getInstanceId>;
|
|
|
|
function createSignedLicense(overrides: Record<string, any> = {}): SignedLicense {
|
|
const issuedAt = new Date().toISOString();
|
|
|
|
return {
|
|
data: {
|
|
type: 'FREE' as const,
|
|
limits: { maxUsers: 10, maxMaterials: 100 },
|
|
features: {
|
|
customTemplates: false,
|
|
systemSettings: false,
|
|
departmentIsolation: false,
|
|
apiAccess: false,
|
|
intranetPenetration: false,
|
|
},
|
|
expiresAt: null,
|
|
leaseExpiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
|
issuedAt,
|
|
...overrides,
|
|
},
|
|
signature: 'valid-signature',
|
|
issuedAt,
|
|
};
|
|
}
|
|
|
|
function createStoredLocalLicense(overrides: Record<string, any> = {}) {
|
|
const signedLicense = overrides.signedLicense || createSignedLicense(overrides.signedLicenseData || {});
|
|
|
|
return {
|
|
instanceId: 'test-instance-id',
|
|
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
|
|
licenseData: `encrypted:${JSON.stringify(signedLicense)}`,
|
|
lastVerified: new Date().toISOString(),
|
|
cachedConfig: {
|
|
type: 'FREE',
|
|
limits: { maxUsers: 10, maxMaterials: 100 },
|
|
features: {
|
|
customTemplates: false,
|
|
systemSettings: false,
|
|
departmentIsolation: false,
|
|
apiAccess: false,
|
|
intranetPenetration: false,
|
|
},
|
|
expiresAt: null,
|
|
leaseExpiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
|
},
|
|
signedLicense: undefined,
|
|
signedLicenseData: undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function addClockIntegrity(localData: Record<string, any>) {
|
|
const payload = JSON.stringify({
|
|
instanceId: localData.instanceId,
|
|
licenseKey: localData.licenseKey,
|
|
licenseData: localData.licenseData,
|
|
lastVerified: localData.lastVerified,
|
|
lastSeenAt: localData.lastSeenAt ?? null,
|
|
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
|
|
});
|
|
|
|
return {
|
|
...localData,
|
|
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
|
|
clockIntegrity: `integrity:${payload}`,
|
|
};
|
|
}
|
|
|
|
describe('license-service', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockFetch.mockReset();
|
|
mockVerifyLicenseSignature.mockReturnValue(true);
|
|
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai';
|
|
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32);
|
|
mockGenerateFingerprint.mockResolvedValue({
|
|
machineId: 'machine-1',
|
|
hostname: 'nas-1',
|
|
macAddress: 'AA:BB:CC:DD:EE:FF',
|
|
instanceId: 'instance-1',
|
|
});
|
|
mockGetInstanceId.mockResolvedValue('test-instance-id');
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.restoreAllMocks();
|
|
delete process.env.LICENSE_PROJECT_CODE;
|
|
delete process.env.LICENSE_REQUEST_SIGNING_SECRET;
|
|
});
|
|
|
|
describe('activateFreeTrial', () => {
|
|
beforeEach(() => {
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
});
|
|
|
|
it('应该成功激活免费试用版', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
}),
|
|
});
|
|
|
|
const { activateFreeTrial } = await import('../license/license-service');
|
|
const result = await activateFreeTrial('测试公司');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/license/activate-free',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
);
|
|
});
|
|
|
|
it('服务器注册失败时应该返回失败(必须联网)', async () => {
|
|
// Mock 服务器注册失败
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { activateFreeTrial } = await import('../license/license-service');
|
|
const result = await activateFreeTrial('测试公司');
|
|
|
|
// 现在必须联网,网络错误应该返回失败
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('网络错误,请检查网络连接后重试');
|
|
});
|
|
|
|
it('服务器返回失败时应该返回错误信息', async () => {
|
|
// Mock 服务器返回失败
|
|
mockFetch.mockResolvedValueOnce({
|
|
json: () => Promise.resolve({
|
|
success: false,
|
|
error: '服务器繁忙,请稍后重试'
|
|
}),
|
|
});
|
|
|
|
const { activateFreeTrial } = await import('../license/license-service');
|
|
const result = await activateFreeTrial('测试公司');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('服务器繁忙,请稍后重试');
|
|
});
|
|
|
|
it('应该向本地激活接口发送客户信息', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
json: () => Promise.resolve({
|
|
success: true,
|
|
}),
|
|
});
|
|
|
|
const { activateFreeTrial } = await import('../license/license-service');
|
|
await activateFreeTrial('测试公司', '13800000000');
|
|
|
|
const fetchCall = mockFetch.mock.calls[0];
|
|
if (fetchCall) {
|
|
const body = JSON.parse(fetchCall[1].body);
|
|
expect(body.customerName).toBe('测试公司');
|
|
expect(body.contactInfo).toBe('13800000000');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getLicenseType', () => {
|
|
it('未激活时应该返回 FREE', async () => {
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
|
|
const { getLicenseType } = await import('../license/license-service');
|
|
const type = await getLicenseType();
|
|
|
|
expect(type).toBe('FREE');
|
|
});
|
|
});
|
|
|
|
describe('isFeatureEnabled', () => {
|
|
it('未激活时应该返回 false', async () => {
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
|
|
const { isFeatureEnabled } = await import('../license/license-service');
|
|
const enabled = await isFeatureEnabled('customTemplates');
|
|
|
|
expect(enabled).toBe(false);
|
|
});
|
|
|
|
it('本地许可证过期后应拒绝功能使用', async () => {
|
|
const expiredAt = new Date(Date.now() - 60_000).toISOString();
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(createStoredLocalLicense({
|
|
signedLicenseData: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: expiredAt,
|
|
},
|
|
})),
|
|
});
|
|
|
|
const { isFeatureEnabled, getLicenseType, checkLimit } = await import('../license/license-service');
|
|
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
|
|
await expect(getLicenseType()).resolves.toBe('FREE');
|
|
await expect(checkLimit('materials')).resolves.toEqual({ allowed: false, current: 0, max: 0 });
|
|
});
|
|
|
|
it('在线租约过期后应拒绝功能和额度使用', async () => {
|
|
const expiredLeaseAt = new Date(Date.now() - 60_000).toISOString();
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(createStoredLocalLicense({
|
|
signedLicenseData: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
leaseExpiresAt: expiredLeaseAt,
|
|
},
|
|
})),
|
|
});
|
|
|
|
const { isFeatureEnabled, getLicenseType, checkLimit } = await import('../license/license-service');
|
|
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
|
|
await expect(getLicenseType()).resolves.toBe('FREE');
|
|
await expect(checkLimit('users')).resolves.toEqual({ allowed: false, current: 0, max: 0 });
|
|
});
|
|
|
|
it('付费许可证租约未过期时应继续允许付费功能', async () => {
|
|
const futureLeaseAt = new Date(Date.now() + 60_000).toISOString();
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(createStoredLocalLicense({
|
|
signedLicenseData: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
leaseExpiresAt: futureLeaseAt,
|
|
},
|
|
})),
|
|
});
|
|
|
|
const { isFeatureEnabled, checkLimit } = await import('../license/license-service');
|
|
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true);
|
|
await expect(checkLimit('materials')).resolves.toEqual({ allowed: true, current: 0, max: -1 });
|
|
});
|
|
|
|
it('旧许可证没有在线租约字段时暂按兼容证处理', async () => {
|
|
const legacyLicense = createSignedLicense({
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
});
|
|
delete (legacyLicense.data as unknown as Record<string, unknown>).leaseExpiresAt;
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(createStoredLocalLicense({
|
|
signedLicense: legacyLicense,
|
|
})),
|
|
});
|
|
|
|
const { isFeatureEnabled, getLocalLicense } = await import('../license/license-service');
|
|
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true);
|
|
await expect(getLocalLicense()).resolves.toMatchObject({
|
|
cachedConfig: {
|
|
leaseExpiresAt: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('业务功能检查每次以本地已验签许可证为准,降级后不继续信旧缓存', async () => {
|
|
const proLicense = createStoredLocalLicense({
|
|
signedLicenseData: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
},
|
|
});
|
|
const freeLicense = createStoredLocalLicense({
|
|
signedLicenseData: {
|
|
type: 'FREE',
|
|
limits: { maxUsers: 10, maxMaterials: 100 },
|
|
features: {
|
|
customTemplates: false,
|
|
systemSettings: false,
|
|
departmentIsolation: false,
|
|
apiAccess: false,
|
|
intranetPenetration: false,
|
|
},
|
|
expiresAt: null,
|
|
},
|
|
});
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock)
|
|
.mockResolvedValueOnce({ key: 'license', value: JSON.stringify(proLicense) })
|
|
.mockResolvedValueOnce({ key: 'license', value: JSON.stringify(freeLicense) });
|
|
|
|
const { isFeatureEnabled } = await import('../license/license-service');
|
|
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true);
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('checkLimit', () => {
|
|
it('未激活时应该返回不允许', async () => {
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
|
|
const { checkLimit } = await import('../license/license-service');
|
|
const result = await checkLimit('users');
|
|
|
|
expect(result.allowed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('verify (许可证吊销检测)', () => {
|
|
const mockLocalLicense = createStoredLocalLicense();
|
|
|
|
it('许可证被吊销时应该返回 revoked: true', async () => {
|
|
// Mock 本地有许可证
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(mockLocalLicense),
|
|
});
|
|
|
|
// Mock 本地验证接口返回已吊销
|
|
mockFetch.mockResolvedValueOnce({
|
|
json: () => Promise.resolve({
|
|
valid: false,
|
|
revoked: true,
|
|
error: '许可证已被禁用'
|
|
}),
|
|
});
|
|
|
|
const { verify } = await import('../license/license-service');
|
|
const result = await verify();
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.revoked).toBe(true);
|
|
expect(result.error).toBe('许可证已被禁用');
|
|
});
|
|
|
|
it('许可证被删除时应该返回 revoked: true', async () => {
|
|
// Mock 本地有许可证
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(mockLocalLicense),
|
|
});
|
|
|
|
// Mock 本地验证接口返回已吊销
|
|
mockFetch.mockResolvedValueOnce({
|
|
json: () => Promise.resolve({
|
|
valid: false,
|
|
revoked: true,
|
|
error: '许可证不存在'
|
|
}),
|
|
});
|
|
|
|
const { verify } = await import('../license/license-service');
|
|
const result = await verify();
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.revoked).toBe(true);
|
|
expect(result.error).toBe('许可证不存在');
|
|
});
|
|
|
|
it('未找到本地许可证时应该返回错误', async () => {
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
|
|
const { verify } = await import('../license/license-service');
|
|
const result = await verify();
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('未找到许可证');
|
|
});
|
|
|
|
it('在线租约过期后,网络失败也不应继续走本地宽限期', async () => {
|
|
const expiredLeaseAt = new Date(Date.now() - 60_000).toISOString();
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(createStoredLocalLicense({
|
|
lastVerified: new Date().toISOString(),
|
|
signedLicenseData: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
leaseExpiresAt: expiredLeaseAt,
|
|
},
|
|
})),
|
|
});
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { verify } = await import('../license/license-service');
|
|
const result = await verify();
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('网络错误');
|
|
});
|
|
});
|
|
|
|
describe('服务端许可证心跳', () => {
|
|
it('startHeartbeat 应直接调用内部心跳逻辑,不再依赖需要登录态的 HTTP 路由', async () => {
|
|
jest.useFakeTimers();
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(createStoredLocalLicense()),
|
|
});
|
|
(prisma.user.count as jest.Mock).mockResolvedValue(1);
|
|
(prisma.material.count as jest.Mock).mockResolvedValue(0);
|
|
mockFetch.mockResolvedValueOnce({
|
|
status: 200,
|
|
json: () => Promise.resolve({ acknowledged: true }),
|
|
});
|
|
|
|
const { startHeartbeat, stopHeartbeat } = await import('../license/license-service');
|
|
|
|
startHeartbeat();
|
|
await jest.advanceTimersByTimeAsync(60 * 60 * 1000);
|
|
stopHeartbeat();
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/heartbeat'),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
})
|
|
);
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'/api/license/heartbeat',
|
|
expect.anything()
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('本地许可证验签收紧', () => {
|
|
it('应优先使用验签后的许可证内容,而不是本地 cachedConfig', async () => {
|
|
const tamperedLocalLicense = createStoredLocalLicense({
|
|
cachedConfig: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
},
|
|
});
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(tamperedLocalLicense),
|
|
});
|
|
|
|
const { getLicenseType, isFeatureEnabled } = await import('../license/license-service');
|
|
|
|
expect(await getLicenseType()).toBe('FREE');
|
|
expect(await isFeatureEnabled('customTemplates')).toBe(false);
|
|
});
|
|
|
|
it('本地签名无效时应视为无许可证', async () => {
|
|
mockVerifyLicenseSignature.mockReturnValue(false);
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(createStoredLocalLicense()),
|
|
});
|
|
|
|
const { getLocalLicense } = await import('../license/license-service');
|
|
const localLicense = await getLocalLicense();
|
|
|
|
expect(localLicense).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('本地时间回拨检测', () => {
|
|
const now = new Date('2026-05-26T08:00:00.000Z');
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(now);
|
|
(prisma.systemConfig.update as jest.Mock).mockResolvedValue({});
|
|
(prisma.systemConfig.upsert as jest.Mock).mockResolvedValue({});
|
|
});
|
|
|
|
it('旧本地许可证没有时间锚点时应自动补齐并继续兼容', async () => {
|
|
const localLicense = createStoredLocalLicense();
|
|
delete (localLicense as Record<string, unknown>).lastSeenAt;
|
|
delete (localLicense as Record<string, unknown>).clockRollbackDetectedAt;
|
|
delete (localLicense as Record<string, unknown>).clockIntegrity;
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(localLicense),
|
|
});
|
|
|
|
const { getLocalLicense } = await import('../license/license-service');
|
|
const result = await getLocalLicense();
|
|
|
|
expect(result).toMatchObject({
|
|
lastSeenAt: now.toISOString(),
|
|
clockRollbackDetectedAt: null,
|
|
});
|
|
expect(result?.clockIntegrity).toEqual(expect.stringContaining('integrity:'));
|
|
expect(prisma.systemConfig.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { key: 'license' },
|
|
}));
|
|
});
|
|
|
|
it('只读读取许可证不应刷新本地时间状态', async () => {
|
|
const localLicense = createStoredLocalLicense();
|
|
delete (localLicense as Record<string, unknown>).lastSeenAt;
|
|
delete (localLicense as Record<string, unknown>).clockRollbackDetectedAt;
|
|
delete (localLicense as Record<string, unknown>).clockIntegrity;
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(localLicense),
|
|
});
|
|
|
|
const { getLocalLicenseReadOnly } = await import('../license/license-service');
|
|
const result = await getLocalLicenseReadOnly();
|
|
|
|
expect(result).toMatchObject({
|
|
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
|
|
});
|
|
expect(prisma.systemConfig.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('系统时间小幅回拨时不应误伤正常许可证', async () => {
|
|
const futureWithinTolerance = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
|
|
const localLicense = addClockIntegrity(createStoredLocalLicense({
|
|
lastVerified: futureWithinTolerance,
|
|
lastSeenAt: futureWithinTolerance,
|
|
signedLicenseData: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
},
|
|
}));
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(localLicense),
|
|
});
|
|
|
|
const { isFeatureEnabled } = await import('../license/license-service');
|
|
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true);
|
|
});
|
|
|
|
it('系统时间明显回拨时应拒绝功能、额度和许可证类型读取', async () => {
|
|
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const futureBeyondTolerance = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
|
const localLicense = addClockIntegrity(createStoredLocalLicense({
|
|
lastVerified: futureBeyondTolerance,
|
|
lastSeenAt: futureBeyondTolerance,
|
|
signedLicenseData: {
|
|
type: 'PRO',
|
|
limits: { maxUsers: -1, maxMaterials: -1 },
|
|
features: {
|
|
customTemplates: true,
|
|
systemSettings: true,
|
|
departmentIsolation: true,
|
|
apiAccess: true,
|
|
intranetPenetration: true,
|
|
},
|
|
expiresAt: null,
|
|
},
|
|
}));
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(localLicense),
|
|
});
|
|
|
|
const { isFeatureEnabled, getLicenseType, checkLimit } = await import('../license/license-service');
|
|
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
|
|
await expect(getLicenseType()).resolves.toBe('FREE');
|
|
await expect(checkLimit('materials')).resolves.toEqual({ allowed: false, current: 0, max: 0 });
|
|
expect(prisma.systemConfig.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: { key: 'license' },
|
|
}));
|
|
});
|
|
|
|
it('本地时间状态被直接篡改时应视为无许可证', async () => {
|
|
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const localLicense = {
|
|
...addClockIntegrity(createStoredLocalLicense({
|
|
lastSeenAt: now.toISOString(),
|
|
})),
|
|
clockIntegrity: 'tampered-integrity',
|
|
};
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(localLicense),
|
|
});
|
|
|
|
const { getLocalLicense, isFeatureEnabled } = await import('../license/license-service');
|
|
|
|
await expect(getLocalLicense()).resolves.toBeNull();
|
|
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
|
|
});
|
|
|
|
it('保存新许可证时应写入本地时间锚点和校验签名', async () => {
|
|
const signedLicense = createSignedLicense();
|
|
|
|
const { saveLicenseLocally } = await import('../license/license-service');
|
|
await saveLicenseLocally('FLNXI-FREE-TEST-1234-5678-ABCD', signedLicense);
|
|
|
|
const upsertArg = (prisma.systemConfig.upsert as jest.Mock).mock.calls[0]?.[0];
|
|
const savedData = JSON.parse(upsertArg.create.value);
|
|
|
|
expect(savedData.lastSeenAt).toBe(now.toISOString());
|
|
expect(savedData.clockRollbackDetectedAt).toBeNull();
|
|
expect(savedData.clockIntegrity).toEqual(expect.stringContaining('integrity:'));
|
|
});
|
|
|
|
it('网络失败走宽限期前,如果本地时间明显回拨也不应放行', async () => {
|
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const futureBeyondTolerance = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
|
const localLicense = addClockIntegrity(createStoredLocalLicense({
|
|
lastVerified: futureBeyondTolerance,
|
|
lastSeenAt: futureBeyondTolerance,
|
|
}));
|
|
|
|
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
key: 'license',
|
|
value: JSON.stringify(localLicense),
|
|
});
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
const { verify } = await import('../license/license-service');
|
|
const result = await verify();
|
|
|
|
expect(result).toEqual({ valid: false, error: '未找到许可证' });
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('LICENSE_CONFIGS 免费版配置', () => {
|
|
it('FREE 版本应该有正确的限制', () => {
|
|
expect(LICENSE_CONFIGS.FREE.limits.maxUsers).toBe(10);
|
|
expect(LICENSE_CONFIGS.FREE.limits.maxMaterials).toBe(100);
|
|
});
|
|
|
|
it('FREE 版本应该禁用高级功能', () => {
|
|
expect(LICENSE_CONFIGS.FREE.features.customTemplates).toBe(false);
|
|
expect(LICENSE_CONFIGS.FREE.features.departmentIsolation).toBe(false);
|
|
expect(LICENSE_CONFIGS.FREE.features.intranetPenetration).toBe(false);
|
|
});
|
|
});
|