mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
fix(licensing): rename variant values to skipper/admiral and store resolved type (#379)
Rename internal variant values from 'personal'/'team' to 'skipper'/'admiral', aligning code with user-facing tier names. Variant type is now resolved once at activation/validation and stored in DB via license_variant_type, instead of string-matching the Lemon Squeezy variant_name on every read. Also captures variant_id for future lookups. Pre-existing installs auto-migrate on first getVariant() call.
This commit is contained in:
@@ -100,7 +100,7 @@ describe('POST /api/system/console-token', () => {
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('team');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('authMiddleware - distributed license headers', () => {
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
|
||||
// Should NOT get 403 PAID_REQUIRED; the proxy tier assertion grants access
|
||||
expect(res.status).not.toBe(403);
|
||||
@@ -54,7 +54,7 @@ describe('authMiddleware - distributed license headers', () => {
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
// Local license is community in test env → should get 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -125,24 +125,24 @@ describe('requirePaid - distributed license', () => {
|
||||
// ─── requireAdmiral guard ───────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmiral - distributed license', () => {
|
||||
it('allows access when proxy asserts paid tier with team variant', async () => {
|
||||
it('allows access when proxy asserts paid tier with admiral variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts paid tier with personal variant', async () => {
|
||||
it('blocks when proxy asserts paid tier with skipper variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
@@ -181,7 +181,7 @@ describe('Security - tier header injection', () => {
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
// User session → tier headers ignored → local community tier → 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -191,7 +191,7 @@ describe('Security - tier header injection', () => {
|
||||
const res = await request(app)
|
||||
.get(PAID_ROUTE)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ function setLicenseState(overrides: Record<string, string>) {
|
||||
'license_status', 'license_key', 'license_valid_until',
|
||||
'license_last_validated', 'license_customer_name',
|
||||
'license_product_name', 'license_variant_name',
|
||||
'license_variant_type', 'license_variant_id',
|
||||
'billing_portal_url', 'billing_portal_expires',
|
||||
];
|
||||
for (const key of keys) {
|
||||
@@ -37,9 +38,9 @@ function setLicenseState(overrides: Record<string, string>) {
|
||||
}
|
||||
|
||||
describe('LicenseService.getVariant()', () => {
|
||||
it('returns "personal" for trial licenses', () => {
|
||||
it('returns "skipper" for trial licenses', () => {
|
||||
setLicenseState({ license_status: 'trial' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('returns null when no variant name is stored', () => {
|
||||
@@ -47,39 +48,51 @@ describe('LicenseService.getVariant()', () => {
|
||||
expect(svc.getVariant()).toBeNull();
|
||||
});
|
||||
|
||||
it('maps "Team" variant name to "team"', () => {
|
||||
it('reads pre-resolved variant type from DB (admiral)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_type: 'admiral' });
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('reads pre-resolved variant type from DB (skipper)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_type: 'skipper' });
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('falls back to name resolution and persists type (Team -> admiral)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Team' });
|
||||
expect(svc.getVariant()).toBe('team');
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Personal" variant name to "personal"', () => {
|
||||
it('falls back to name resolution and persists type (Personal -> skipper)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Personal' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('skipper');
|
||||
});
|
||||
|
||||
it('maps "Admiral" variant name to "team"', () => {
|
||||
it('maps "Admiral" variant name to "admiral"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Admiral' });
|
||||
expect(svc.getVariant()).toBe('team');
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Admiral Lifetime" variant name to "team"', () => {
|
||||
it('maps "Admiral Lifetime" variant name to "admiral"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Admiral Lifetime' });
|
||||
expect(svc.getVariant()).toBe('team');
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Skipper" variant name to "personal"', () => {
|
||||
it('maps "Skipper" variant name to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Skipper' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('maps "Skipper Lifetime" variant name to "personal"', () => {
|
||||
it('maps "Skipper Lifetime" variant name to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Skipper Lifetime' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('defaults unknown variant names to "personal"', () => {
|
||||
it('defaults unknown variant names to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Unknown Variant' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +216,7 @@ describe('LicenseService.getLicenseInfo() - full scenarios', () => {
|
||||
const info = svc.getLicenseInfo();
|
||||
expect(info.tier).toBe('paid');
|
||||
expect(info.status).toBe('active');
|
||||
expect(info.variant).toBe('team');
|
||||
expect(info.variant).toBe('admiral');
|
||||
expect(info.isLifetime).toBe(true);
|
||||
expect(info.trialDaysRemaining).toBeNull();
|
||||
expect(info.customerName).toBe('Test User');
|
||||
@@ -226,7 +239,7 @@ describe('LicenseService.getLicenseInfo() - full scenarios', () => {
|
||||
const info = svc.getLicenseInfo();
|
||||
expect(info.tier).toBe('paid');
|
||||
expect(info.status).toBe('active');
|
||||
expect(info.variant).toBe('personal');
|
||||
expect(info.variant).toBe('skipper');
|
||||
expect(info.isLifetime).toBe(false);
|
||||
expect(info.trialDaysRemaining).toBeNull();
|
||||
expect(info.customerName).toBe('Another User');
|
||||
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
mockInsertSnapshotFiles: vi.fn(),
|
||||
mockClearStackUpdateStatus: vi.fn(),
|
||||
mockGetTier: vi.fn().mockReturnValue('paid'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('team'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('admiral'),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
mockRestartContainer: vi.fn().mockResolvedValue(undefined),
|
||||
mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }),
|
||||
@@ -198,7 +198,7 @@ describe('SchedulerService - license gating', () => {
|
||||
|
||||
it('allows all actions for admiral (pro + team)', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('SchedulerService - license gating', () => {
|
||||
describe('SchedulerService - concurrent task prevention', () => {
|
||||
it('does not execute a task that is already in runningTasks', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([{
|
||||
id: 42,
|
||||
name: 'running-task',
|
||||
@@ -240,7 +240,7 @@ describe('SchedulerService - concurrent task prevention', () => {
|
||||
|
||||
it('removes task from runningTasks after completion', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
@@ -612,7 +612,7 @@ describe('SchedulerService - error handling', () => {
|
||||
describe('SchedulerService - cleanup', () => {
|
||||
it('calls cleanupOldTaskRuns(30) on every tick', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
|
||||
Reference in New Issue
Block a user