Files
dashcaddy/dashcaddy-api/__tests__/license-server-contract.test.js
T
Hermes 5e60c27f2b
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[grade=A] Harden server-managed license renewals
2026-08-22 18:15:37 -07:00

523 lines
24 KiB
JavaScript

const path = require('path');
const fs = require('fs');
function makeCreds() {
return {
values: {},
store: jest.fn(async function(key, value) { this.values[key] = value; }),
retrieve: jest.fn(async function(key) { return this.values[key] || null; }),
delete: jest.fn(async function(key) { delete this.values[key]; }),
};
}
describe('server-managed stable license contract', () => {
const previous = process.env.LICENSE_SERVER_URL;
beforeEach(() => {
jest.resetModules();
process.env.LICENSE_SERVER_URL = 'https://licenses.dashcaddy.net';
try { fs.unlinkSync('/tmp/dc-license-contract-config.json.license-revoked'); } catch (_) { /* absent */ }
});
afterAll(() => {
if (previous === undefined) delete process.env.LICENSE_SERVER_URL;
else process.env.LICENSE_SERVER_URL = previous;
});
test('refresh keeps the same key while accepting an extended server expiry', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const extendedExpiry = new Date(Date.now() + 90 * 86400000).toISOString();
manager._validateOnline = jest.fn().mockResolvedValue({
success: true,
activation: {
code,
durationDays: 90,
expiresAt: extendedExpiry,
features: ['sso', 'recipes', 'swarm'],
},
});
manager._updateConfig = jest.fn().mockResolvedValue();
expect(await manager.refreshOnline(true)).toBe(true);
expect(manager.activation.code).toBe(code);
expect(manager.activation.expiresAt).toBe(extendedExpiry);
expect(manager.activation.validationMethod).toBe('online');
expect(creds.store).toHaveBeenCalled();
});
test('server outage does not create a fresh offline activation', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
const result = await manager.activate('DC-20F00-00059-JN7W8-0SW2N-MA200');
expect(result.success).toBe(false);
expect(result.message).toMatch(/temporarily unavailable/);
expect(manager.activation).toBeNull();
});
test('background timer forces refresh every 15 minutes', async () => {
jest.useFakeTimers();
try {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
};
manager.refreshOnline = jest.fn().mockResolvedValue(true);
manager._startOnlineRefresh();
await jest.advanceTimersByTimeAsync(15 * 60 * 1000);
expect(manager.refreshOnline).toHaveBeenCalledWith(true);
clearInterval(manager._onlineRefreshTimer);
} finally {
jest.useRealTimers();
}
});
test('explicit server rejection revokes cached entitlement', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'online',
};
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'License revoked' });
manager._updateConfig = jest.fn().mockResolvedValue();
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('server outage never trusts a legacy offline cache as server-managed', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'offline',
};
manager._validateOnline = jest.fn().mockResolvedValue(null);
const result = await manager.activate(code);
expect(result.success).toBe(false);
expect(result.message).toMatch(/temporarily unavailable/);
});
test('startup quarantines a stored legacy offline entitlement during outage', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
creds.values['license.activation'] = JSON.stringify({
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'offline',
});
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
manager._updateConfig = jest.fn().mockResolvedValue();
await manager.load();
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('activation-time explicit rejection revokes matching cached entitlement', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'online',
};
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
manager._updateConfig = jest.fn().mockResolvedValue();
const result = await manager.activate(code);
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('deactivate during refresh cannot resurrect entitlement', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager.activation = { code, durationDays: 30, expiresAt: new Date(Date.now() + 86400000).toISOString(), validationMethod: 'online' };
let release;
manager._validateOnline = jest.fn(() => new Promise(resolve => { release = resolve; }));
manager._updateConfig = jest.fn().mockResolvedValue();
manager._notifyDeactivation = jest.fn().mockResolvedValue();
const refresh = manager.refreshOnline(true);
const deactivate = manager.deactivate();
await new Promise(resolve => setImmediate(resolve));
release({ success: true, activation: { code, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString() } });
await refresh;
expect((await deactivate).success).toBe(true);
expect(manager.activation).toBeNull();
});
test('different-key activation waits for refresh and remains current', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const oldCode = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const newCode = 'DC-20F00-0005A-JN7W8-0SW2N-MA200';
manager.activation = { code: oldCode, durationDays: 30, expiresAt: new Date(Date.now() + 86400000).toISOString(), validationMethod: 'online' };
let release;
manager._validateOnline = jest.fn()
.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }))
.mockResolvedValueOnce({ success: true, activation: { code: newCode, durationDays: 30, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), features: ['sso'] } });
manager._updateConfig = jest.fn().mockResolvedValue();
const refresh = manager.refreshOnline(true);
const activate = manager.activate(newCode);
await new Promise(resolve => setImmediate(resolve));
release({ success: true, activation: { code: oldCode, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString() } });
await refresh;
expect((await activate).success).toBe(true);
expect(manager.activation.code).toBe(newCode);
});
test('concurrent activations commit in request order without stale overwrite', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
const firstCode = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const secondCode = 'DC-20F00-0005A-JN7W8-0SW2N-MA200';
let releaseFirst;
manager._validateOnline = jest.fn()
.mockImplementationOnce(() => new Promise(resolve => { releaseFirst = resolve; }))
.mockResolvedValueOnce({ success: true, activation: { code: secondCode, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString(), features: ['sso'] } });
manager._updateConfig = jest.fn().mockResolvedValue();
const first = manager.activate(firstCode);
const second = manager.activate(secondCode);
await new Promise(resolve => setImmediate(resolve));
releaseFirst({ success: true, activation: { code: firstCode, durationDays: 30, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), features: ['sso'] } });
expect((await first).success).toBe(true);
expect((await second).success).toBe(true);
expect(manager.activation.code).toBe(secondCode);
});
test.each([429, 500, 502, 503])('retryable HTTP %i never revokes cached online entitlement', async (status) => {
const originalFetch = global.fetch;
try {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status,
json: async () => ({ error: 'temporary failure' }),
});
const { LicenseManager } = require('../src/managers/license-manager');
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'online',
};
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation.code).toBe(code);
} finally {
global.fetch = originalFetch;
}
});
test.each([
{ durationDays: 30, features: ['sso'] },
{ expiresAt: 'not-a-date', durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() - 1000).toISOString(), durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() + 86400000).toISOString(), durationDays: 0, features: ['sso'] },
{ expiresAt: new Date(Date.now() + 86400000).toISOString(), durationDays: 30, features: 'sso' },
{ expiresAt: new Date(Date.now() + 20 * 365 * 86400000).toISOString(), durationDays: 30, features: ['sso'] },
])('malformed HTTP 200 success never creates an unbounded entitlement', async (payload) => {
const originalFetch = global.fetch;
try {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, ...payload }),
});
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
const result = await manager.activate('DC-20F00-00059-JN7W8-0SW2N-MA200');
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
} finally {
global.fetch = originalFetch;
}
});
test.each([
{ durationDays: 30, features: ['sso'] },
{ expiresAt: 'bad-date', durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() - 1000).toISOString(), durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() + 20 * 365 * 86400000).toISOString(), durationDays: 30, features: ['sso'], activatedAt: new Date().toISOString() },
])('startup outage rejects malformed cached online entitlement', async (cached) => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify({
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
validationMethod: 'online',
...cached,
});
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
manager._updateConfig = jest.fn().mockResolvedValue();
await manager.load();
expect(manager.activation).toBeNull();
});
test('deactivate waiting on authoritative rejection does not dereference revoked state', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
features: ['sso'],
validationMethod: 'online',
};
let release;
manager._validateOnline = jest.fn(() => new Promise(resolve => { release = resolve; }));
manager._updateConfig = jest.fn().mockResolvedValue();
const refresh = manager.refreshOnline(true);
const deactivate = manager.deactivate();
await new Promise(resolve => setImmediate(resolve));
release({ success: false, message: 'Revoked' });
await refresh;
const result = await deactivate;
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
});
test('revocation tombstone prevents restart resurrection when credential deletion fails', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const cached = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
creds.delete = jest.fn().mockRejectedValue(new Error('keychain unavailable'));
const first = new LicenseManager(creds, configPath, {});
first.activation = cached;
first._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
first._updateConfig = jest.fn().mockResolvedValue();
expect(await first.refreshOnline(true)).toBe(false);
expect(fs.existsSync(`${configPath}.license-revoked`)).toBe(true);
const restarted = new LicenseManager(creds, configPath, {});
restarted._validateOnline = jest.fn().mockResolvedValue(null);
restarted._updateConfig = jest.fn().mockResolvedValue();
await restarted.load();
expect(restarted.activation).toBeNull();
expect(restarted._updateConfig).toHaveBeenCalled();
});
test('tombstone write failure still clears rejected entitlement in memory', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), { error: jest.fn(), warn: jest.fn() });
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
manager._writeRevocationTombstone = jest.fn().mockRejectedValue(new Error('disk full'));
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
manager._updateConfig = jest.fn().mockResolvedValue();
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('corrupt tombstone fails closed during restart', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
fs.writeFileSync(`${configPath}.license-revoked`, '{partial', { mode: 0o600 });
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify({
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
});
const manager = new LicenseManager(creds, configPath, {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
await manager.load();
expect(manager.activation).toBeNull();
expect(manager._validateOnline).not.toHaveBeenCalled();
});
test('activation persistence failure rolls back in-memory premium access', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
creds.store = jest.fn().mockRejectedValue(new Error('keychain full'));
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager._validateOnline = jest.fn().mockResolvedValue({
success: true,
activation: {
code,
durationDays: 30,
activatedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
features: ['sso'],
}
});
const result = await manager.activate(code);
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
expect(manager.isPro()).toBe(false);
expect(manager.hasFeature('sso')).toBe(false);
});
test('combined revocation persistence failures cannot restore plaintext config backup', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = `/tmp/dc-combined-failure-${process.pid}.json`;
const cached = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
fs.writeFileSync(configPath, JSON.stringify({ licenseBackup: cached }));
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
creds.delete = jest.fn().mockRejectedValue(new Error('keychain locked'));
const manager = new LicenseManager(creds, configPath, {});
manager.activation = cached;
manager._writeRevocationTombstone = jest.fn().mockRejectedValue(new Error('disk full'));
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
manager._updateConfig = jest.fn().mockRejectedValue(new Error('config locked'));
await manager.refreshOnline(true);
expect(manager.activation).toBeNull();
await expect(creds.retrieve('license.activation')).resolves.not.toBeNull();
const restarted = new LicenseManager(creds, configPath, {});
restarted._validateOnline = jest.fn().mockResolvedValue(null);
await restarted.load();
expect(restarted.activation).toBeNull();
fs.unlinkSync(configPath);
});
test('ambiguous empty HTTP 200 preserves bounded cached entitlement', async () => {
const originalFetch = global.fetch;
try {
global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation).not.toBeNull();
} finally {
global.fetch = originalFetch;
}
});
test('startup outage fails closed and automatically recovers in the same process', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
const cached = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
const unavailable = new LicenseManager(creds, configPath, {});
unavailable._validateOnline = jest.fn().mockResolvedValue(null);
unavailable._updateConfig = jest.fn().mockResolvedValue();
await unavailable.load();
expect(unavailable.activation).toBeNull();
await expect(creds.retrieve('license.activation')).resolves.not.toBeNull();
expect(fs.existsSync(`${configPath}.license-revoked`)).toBe(false);
unavailable._validateOnline = jest.fn().mockResolvedValue({
success: true,
activation: { ...cached, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString() }
});
const recovered = await unavailable._retryStartupValidation();
expect(recovered).toBe(true);
expect(unavailable.activation.code).toBe(cached.code);
expect(unavailable.activation.validationMethod).toBe('online');
});
test('startup recovery persistence failure stays fail-closed and remains retryable', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
const cached = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
const manager = new LicenseManager(creds, configPath, {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
manager._updateConfig = jest.fn().mockResolvedValue();
await manager.load();
expect(manager.activation).toBeNull();
manager._validateOnline.mockResolvedValue({ success: true, activation: cached });
creds.store.mockRejectedValueOnce(new Error('credential disk full'));
expect(await manager._retryStartupValidation()).toBe(false);
expect(manager.activation).toBeNull();
expect(manager._pendingStartupCode).toBe(cached.code);
const preserved = await creds.retrieve('license.activation');
manager._updateConfig.mockRejectedValueOnce(new Error('config disk full'));
expect(await manager._retryStartupValidation()).toBe(false);
expect(manager.activation).toBeNull();
expect(await creds.retrieve('license.activation')).toBe(preserved);
expect(manager._pendingStartupCode).toBe(cached.code);
expect(await manager._retryStartupValidation()).toBe(true);
expect(manager.activation.code).toBe(cached.code);
});
});