212 lines
6.5 KiB
TypeScript
212 lines
6.5 KiB
TypeScript
// Regression: a signed offline license key must survive (a) app restarts more than
|
|
// 30 days after activation and (b) a cloud sign-in followed by sign-out.
|
|
// A "restart" is simulated by calling initialize() again on the same service,
|
|
// which reloads every field from the license store.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { generateLicenseKeyPair, issueSignedLicenseKey } from '@d3ro/core/utils/crypto-license'
|
|
import {
|
|
getLicenseService,
|
|
resetLicenseServiceForTests,
|
|
} from '../../../src/main/services/LicenseService'
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
const T0 = new Date('2026-01-01T00:00:00Z').getTime()
|
|
|
|
let previousPublicKey: string | undefined
|
|
/** issueKey() generates a fresh key pair per call; remember the public key per issued key. */
|
|
const publicKeyByLicense = new Map<string, string>()
|
|
|
|
function previousKeyFor(key: string): string {
|
|
const pem = publicKeyByLicense.get(key)
|
|
if (!pem) throw new Error('unknown key')
|
|
return pem
|
|
}
|
|
|
|
function issueKey(tier: 'pro' | 'pro_plus', expiresAt: number | null): string {
|
|
const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair()
|
|
process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem
|
|
const key = issueSignedLicenseKey(
|
|
{
|
|
licenseId: `lic-r1-11-${tier}`,
|
|
tier,
|
|
customerEmail: 'buyer@example.test',
|
|
issuedAt: T0,
|
|
expiresAt,
|
|
machineId: null,
|
|
},
|
|
privateKeyPem,
|
|
)
|
|
publicKeyByLicense.set(key, publicKeyPem)
|
|
return key
|
|
}
|
|
|
|
beforeEach(() => {
|
|
previousPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(T0)
|
|
resetLicenseServiceForTests()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
if (previousPublicKey === undefined) delete process.env.D3RO_LICENSE_PUBLIC_KEY
|
|
else process.env.D3RO_LICENSE_PUBLIC_KEY = previousPublicKey
|
|
resetLicenseServiceForTests()
|
|
})
|
|
|
|
describe('LicenseService — signed offline key lifetime (redteam r1-11)', () => {
|
|
it('keeps a one-year pro key after a restart 31 days after activation', async () => {
|
|
const key = issueKey('pro', T0 + 365 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
const result = await svc.activate(key)
|
|
expect(result.success).toBe(true)
|
|
|
|
vi.setSystemTime(T0 + 31 * DAY_MS)
|
|
svc.initialize()
|
|
|
|
expect(svc.tier).toBe('pro')
|
|
expect(svc.getInfo().licenseKey).toBe(key)
|
|
expect(svc.getInfo().expiresAt).toBe(T0 + 365 * DAY_MS)
|
|
})
|
|
|
|
it('keeps a perpetual key after a restart a year later', async () => {
|
|
const key = issueKey('pro_plus', null)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
|
|
vi.setSystemTime(T0 + 400 * DAY_MS)
|
|
svc.initialize()
|
|
|
|
expect(svc.tier).toBe('pro_plus')
|
|
expect(svc.getInfo().licenseKey).toBe(key)
|
|
})
|
|
|
|
it('drops the key once its own expiresAt has passed', async () => {
|
|
const key = issueKey('pro', T0 + 60 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
|
|
vi.setSystemTime(T0 + 61 * DAY_MS)
|
|
svc.initialize()
|
|
|
|
expect(svc.tier).toBe('free')
|
|
expect(svc.getInfo().licenseKey).toBeNull()
|
|
})
|
|
|
|
it('a free cloud account does not demote the key, before or after a restart', async () => {
|
|
const key = issueKey('pro', T0 + 365 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
|
|
svc.syncFromCloud('free')
|
|
expect(svc.tier).toBe('pro')
|
|
|
|
svc.initialize()
|
|
expect(svc.tier).toBe('pro')
|
|
expect(svc.getInfo().licenseKey).toBe(key)
|
|
})
|
|
|
|
it('sign-out after a pro cloud account keeps the key and its tier', async () => {
|
|
const key = issueKey('pro', T0 + 365 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
|
|
svc.syncFromCloud('pro_plus')
|
|
expect(svc.tier).toBe('pro_plus')
|
|
|
|
svc.resetToFree()
|
|
expect(svc.tier).toBe('pro')
|
|
expect(svc.getInfo().licenseKey).toBe(key)
|
|
|
|
svc.initialize()
|
|
expect(svc.tier).toBe('pro')
|
|
expect(svc.getInfo().licenseKey).toBe(key)
|
|
})
|
|
|
|
it('a higher cloud tier wins over the key and survives a restart until sign-out', async () => {
|
|
const key = issueKey('pro', T0 + 365 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
svc.syncFromCloud('pro_plus')
|
|
|
|
svc.initialize()
|
|
expect(svc.tier).toBe('pro_plus')
|
|
|
|
svc.resetToFree()
|
|
expect(svc.tier).toBe('pro')
|
|
})
|
|
|
|
it('emits tier-changed only when the effective tier changes', async () => {
|
|
const key = issueKey('pro', T0 + 365 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
const tiers: string[] = []
|
|
svc.on('tier-changed', (info: { tier: string }) => tiers.push(info.tier))
|
|
|
|
svc.syncFromCloud('free')
|
|
svc.syncFromCloud('pro')
|
|
svc.syncFromCloud('pro_plus')
|
|
svc.resetToFree()
|
|
|
|
expect(tiers).toEqual(['pro_plus', 'pro'])
|
|
})
|
|
|
|
it('deactivate clears the key but keeps the cloud tier', async () => {
|
|
const key = issueKey('pro', T0 + 365 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
svc.syncFromCloud('pro_plus')
|
|
|
|
await svc.deactivate()
|
|
expect(svc.getInfo().licenseKey).toBeNull()
|
|
expect(svc.tier).toBe('pro_plus')
|
|
|
|
svc.resetToFree()
|
|
expect(svc.tier).toBe('free')
|
|
})
|
|
|
|
it('does not erase a stored key it cannot verify at startup (e.g. verification key missing)', async () => {
|
|
const key = issueKey('pro', T0 + 365 * DAY_MS)
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
await svc.activate(key)
|
|
|
|
// Verification key rotated / missing: the key is not honoured, but it stays on disk.
|
|
const { publicKeyPem } = generateLicenseKeyPair()
|
|
process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem
|
|
svc.initialize()
|
|
expect(svc.tier).toBe('free')
|
|
|
|
// Restored verification key: the same stored key is honoured again.
|
|
process.env.D3RO_LICENSE_PUBLIC_KEY = previousKeyFor(key)
|
|
svc.initialize()
|
|
expect(svc.tier).toBe('pro')
|
|
expect(svc.getInfo().licenseKey).toBe(key)
|
|
})
|
|
|
|
it('a reverse trial still ends after 14 days', () => {
|
|
const svc = getLicenseService()
|
|
svc.initialize()
|
|
expect(svc.startTrial('trial@example.test').success).toBe(true)
|
|
expect(svc.tier).toBe('pro_plus')
|
|
|
|
vi.setSystemTime(T0 + 13 * DAY_MS)
|
|
svc.initialize()
|
|
expect(svc.tier).toBe('pro_plus')
|
|
|
|
vi.setSystemTime(T0 + 15 * DAY_MS)
|
|
svc.initialize()
|
|
expect(svc.tier).toBe('free')
|
|
expect(svc.getInfo().isTrial).toBe(false)
|
|
expect(svc.startTrial('again@example.test').success).toBe(false)
|
|
})
|
|
})
|