diff --git a/server/licensing/e2e-cloud-integration.test.ts b/server/licensing/e2e-cloud-integration.test.ts index 01825a6b..0b9226a2 100644 --- a/server/licensing/e2e-cloud-integration.test.ts +++ b/server/licensing/e2e-cloud-integration.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node /** * E2E Integration Test: zpan ↔ zpan-cloud licensing flow. * @@ -17,15 +18,19 @@ * * Run with: npx vitest run server/licensing/e2e-cloud-integration.test.ts */ + +import { generateKeys, sign } from 'paseto-ts/v4' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SignupMode } from '../../shared/constants' import { CloudUnboundError, createPairing, pollPairing, refreshEntitlement } from '../services/licensing-cloud' import { adminHeaders, createTestApp, seedProLicense } from '../test/setup' import { hasFeature, loadBindingState } from './has-feature' +import { getOrCreateInstanceId } from './instance-id' import { LICENSE_KEYS, loadLicenseState, setLicenseOptions } from './license-state' import { PUBLIC_KEYS } from './public-keys' const CLOUD_BASE_URL = process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud.saltbo.workers.dev' +const { secretKey: E2E_SECRET, publicKey: E2E_PUBLIC } = generateKeys('public') // ─── Phase 1: Live Cloud API contract verification ─────────────────────────── @@ -57,7 +62,7 @@ describe('E2E: zpan-cloud API contract', () => { expect(result.status).toBe('pending') expect(result.refresh_token).toBeUndefined() - expect(result.entitlement).toBeUndefined() + expect(result.certificate).toBeUndefined() }) it('POST /api/entitlements rejects invalid Bearer token with 401', async () => { @@ -229,9 +234,12 @@ describe('E2E: Unbind flow', () => { describe('E2E: Full pairing-to-activation flow (mocked cloud approval)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) + PUBLIC_KEYS.unshift(E2E_PUBLIC) }) afterEach(() => { vi.unstubAllGlobals() + const idx = PUBLIC_KEYS.indexOf(E2E_PUBLIC) + if (idx >= 0) PUBLIC_KEYS.splice(idx, 1) }) it('complete flow: pair → poll pending → poll approved → features active → refresh → unbind', async () => { @@ -269,20 +277,25 @@ describe('E2E: Full pairing-to-activation flow (mocked cloud approval)', () => { expect(pendingRes.status).toBe(200) expect(((await pendingRes.json()) as { status: string }).status).toBe('pending') - // Step 3: Poll — approved (unsigned entitlement from pairing) + const instanceId = await getOrCreateInstanceId(db) + const cert = sign(E2E_SECRET, { + instance_id: instanceId, + account_id: 'user-123', + plan: 'pro', + plan_source: 'membership', + features: ['white_label', 'open_registration', 'teams_unlimited', 'team_quotas'], + hosts: ['https://zpan.example.com'], + expires_at: new Date(Date.now() + 86400_000).toISOString(), + issued_at: new Date().toISOString(), + }) + + // Step 3: Poll — approved (signed certificate from pairing) vi.mocked(fetch).mockResolvedValueOnce( new Response( JSON.stringify({ status: 'approved', refresh_token: 'rt-e2e-secret', - entitlement: { - instance_id: 'test-instance', - account_id: 'user-123', - plan: 'pro', - features: ['white_label', 'open_registration', 'teams_unlimited', 'team_quotas'], - expires_at: new Date(Date.now() + 86400_000).toISOString(), - issued_at: new Date().toISOString(), - }, + certificate: cert, }), { headers: { 'Content-Type': 'application/json' } }, ), diff --git a/server/licensing/verify.test.ts b/server/licensing/verify.test.ts index 9472c47b..23579f3b 100644 --- a/server/licensing/verify.test.ts +++ b/server/licensing/verify.test.ts @@ -34,7 +34,9 @@ function signCert(overrides: Record = {}, key = TEST_SECRET): s account_id: 'acct-1', instance_id: 'inst-abc', plan: 'pro', + plan_source: 'membership', features: ['white_label'], + hosts: ['https://zpan.example.com'], issued_at: new Date().toISOString(), expires_at: futureIso(3_600_000), // 1 hour from now ...overrides, @@ -51,6 +53,8 @@ describe('verifyCertificate', () => { expect(result?.features).toEqual(['white_label']) expect(result?.instance_id).toBe('inst-abc') expect(result?.account_id).toBe('acct-1') + expect(result?.plan_source).toBe('membership') + expect(result?.hosts).toEqual(['https://zpan.example.com']) }) it('returns null for a cert with an invalid signature', () => { diff --git a/server/licensing/verify.ts b/server/licensing/verify.ts index 68dfde84..3d79c138 100644 --- a/server/licensing/verify.ts +++ b/server/licensing/verify.ts @@ -34,7 +34,9 @@ function tryVerify(cert: string, publicKey: string, instanceId: string): License account_id: payload.account_id, instance_id: payload.instance_id, plan: payload.plan, + plan_source: payload.plan_source, features: payload.features, + hosts: payload.hosts, issued_at: payload.issued_at, expires_at: payload.expires_at, } diff --git a/server/routes/licensing-admin.integration.test.ts b/server/routes/licensing-admin.integration.test.ts index ba4af0ab..67fb6e83 100644 --- a/server/routes/licensing-admin.integration.test.ts +++ b/server/routes/licensing-admin.integration.test.ts @@ -1,5 +1,8 @@ +import { generateKeys, sign } from 'paseto-ts/v4' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getOrCreateInstanceId } from '../licensing/instance-id.js' import { LICENSE_KEYS, loadLicenseState, setLicenseOptions } from '../licensing/license-state.js' +import { PUBLIC_KEYS } from '../licensing/public-keys.js' import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' function makeCloudResponse(body: unknown, status = 200): Response { @@ -12,6 +15,26 @@ function makeCloudResponse(body: unknown, status = 200): Response { } as unknown as Response } +const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public') +const originalKeys: string[] = [] + +function futureIso(offsetMs: number): string { + return new Date(Date.now() + offsetMs).toISOString() +} + +function signCert(instanceId: string): string { + return sign(TEST_SECRET, { + account_id: 'acct-1', + instance_id: instanceId, + plan: 'pro', + plan_source: 'membership', + features: ['white_label'], + hosts: ['https://zpan.example.com'], + issued_at: new Date().toISOString(), + expires_at: futureIso(3_600_000), + }) +} + describe('Licensing Admin API — auth guards', () => { it('POST /api/licensing/pair returns 401 without auth', async () => { const { app } = await createTestApp() @@ -55,10 +78,15 @@ describe('Licensing Admin API — auth guards', () => { describe('POST /api/licensing/pair', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) + originalKeys.push(...PUBLIC_KEYS) + PUBLIC_KEYS.length = 0 + PUBLIC_KEYS.push(TEST_PUBLIC) }) afterEach(() => { vi.unstubAllGlobals() + PUBLIC_KEYS.length = 0 + for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key) }) it('calls cloud and returns pairing info', async () => { @@ -81,16 +109,24 @@ describe('POST /api/licensing/pair', () => { const body = (await res.json()) as Record expect(body.code).toBe('ABC-123') expect(body.pairing_url).toBe('https://cloud.zpan.space/pair') + + const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(init.body)).instance_host).toBe('http://localhost') }) }) describe('GET /api/licensing/pair/:code/poll', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) + originalKeys.push(...PUBLIC_KEYS) + PUBLIC_KEYS.length = 0 + PUBLIC_KEYS.push(TEST_PUBLIC) }) afterEach(() => { vi.unstubAllGlobals() + PUBLIC_KEYS.length = 0 + for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key) }) it('returns pending status when cloud returns pending', async () => { @@ -109,19 +145,13 @@ describe('GET /api/licensing/pair/:code/poll', () => { it('stores binding on approved and returns approved status', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) + const instanceId = await getOrCreateInstanceId(db) vi.mocked(fetch).mockResolvedValueOnce( makeCloudResponse({ status: 'approved', refresh_token: 'rt-secret', - entitlement: { - plan: 'pro', - features: ['white_label'], - expires_at: '2026-12-31T00:00:00Z', - account_id: 'a1', - instance_id: 'i1', - issued_at: '2026-01-01T00:00:00Z', - }, + certificate: signCert(instanceId), }), ) @@ -135,6 +165,43 @@ describe('GET /api/licensing/pair/:code/poll', () => { const state = await loadLicenseState(db) expect(state.refreshToken).toBe('rt-secret') }) + + it('rejects approved responses with an invalid certificate', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + + vi.mocked(fetch).mockResolvedValueOnce( + makeCloudResponse({ + status: 'approved', + refresh_token: 'rt-secret', + certificate: signCert('wrong-instance'), + }), + ) + + const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers }) + + expect(res.status).toBe(502) + const state = await loadLicenseState(db) + expect(state.refreshToken).toBeNull() + }) + + it('rejects approved responses when certificate is missing', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + + vi.mocked(fetch).mockResolvedValueOnce( + makeCloudResponse({ + status: 'approved', + refresh_token: 'rt-secret', + }), + ) + + const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers }) + + expect(res.status).toBe(502) + const state = await loadLicenseState(db) + expect(state.refreshToken).toBeNull() + }) }) describe('POST /api/licensing/refresh', () => { diff --git a/server/routes/licensing-admin.ts b/server/routes/licensing-admin.ts index 1b95c5c8..ba37e66c 100644 --- a/server/routes/licensing-admin.ts +++ b/server/routes/licensing-admin.ts @@ -15,6 +15,18 @@ function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT } +function getInstanceOrigin(c: { req: { url: string; header(name: string): string | undefined } }): string { + const requestUrl = new URL(c.req.url) + const forwardedProto = c.req.header('x-forwarded-proto') + const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host') + + if (forwardedProto && forwardedHost) { + return `${forwardedProto}://${forwardedHost}` + } + + return requestUrl.origin +} + const app = new Hono() .use(requireAdmin) @@ -31,7 +43,7 @@ const app = new Hono() .limit(1) const instanceName = titleRows[0]?.value ?? 'ZPan' - const instanceHost = c.req.header('host') ?? new URL(c.req.url).host + const instanceHost = getInstanceOrigin(c) const pairing = await createPairing(baseUrl, instanceId, instanceName, instanceHost) return c.json(pairing) @@ -44,25 +56,14 @@ const app = new Hono() const result = await pollPairing(baseUrl, code) - if (result.status === 'approved' && result.refresh_token && result.entitlement != null) { + if (result.status === 'approved' && result.refresh_token && result.certificate) { const instanceId = await getOrCreateInstanceId(db) - - let cert: string - let expiresAt: number | null = null - - if (typeof result.entitlement === 'string') { - cert = result.entitlement - const entitlement = verifyCertificate(cert, instanceId) - if (entitlement) { - expiresAt = Math.floor(new Date(entitlement.expires_at).getTime() / 1000) - } - } else { - cert = JSON.stringify(result.entitlement) - const parsed = result.entitlement as { expires_at?: string } - if (parsed.expires_at) { - expiresAt = Math.floor(new Date(parsed.expires_at).getTime() / 1000) - } + const cert = result.certificate + const entitlement = verifyCertificate(cert, instanceId) + if (!entitlement) { + return c.json({ error: 'invalid_certificate' }, 502) } + const expiresAt = Math.floor(new Date(entitlement.expires_at).getTime() / 1000) const nowSec = String(Math.floor(Date.now() / 1000)) await setLicenseOptions(db, { @@ -78,10 +79,14 @@ const app = new Hono() return c.json({ status: 'approved' as const, - plan: getPlanFromCert(cert, instanceId), + plan: entitlement.plan, }) } + if (result.status === 'approved') { + return c.json({ error: 'invalid_pairing_response' }, 502) + } + return c.json({ status: result.status }) }) @@ -104,18 +109,4 @@ const app = new Hono() return c.json({ deleted: true }) }) -function getPlanFromCert(cert: string, instanceId: string): string | undefined { - if (cert.startsWith('v4.public.')) { - const entitlement = verifyCertificate(cert, instanceId) - return entitlement?.plan - } - - try { - const parsed = JSON.parse(cert) as { plan?: string } - return parsed.plan - } catch { - return undefined - } -} - export default app diff --git a/server/services/licensing-cloud.test.ts b/server/services/licensing-cloud.test.ts index 5ba2a7e9..ee87f441 100644 --- a/server/services/licensing-cloud.test.ts +++ b/server/services/licensing-cloud.test.ts @@ -77,13 +77,14 @@ describe('licensing-cloud', () => { const payload = { status: 'approved', refresh_token: 'rt-token', - entitlement: 'v4.public.token', + certificate: 'v4.public.token', } vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) const result = await pollPairing(BASE_URL, 'CODE-2') expect(result.status).toBe('approved') expect(result.refresh_token).toBe('rt-token') + expect(result.certificate).toBe('v4.public.token') }) it('throws on non-OK response', async () => { diff --git a/server/services/licensing-cloud.ts b/server/services/licensing-cloud.ts index 22927486..c8b2ad2f 100644 --- a/server/services/licensing-cloud.ts +++ b/server/services/licensing-cloud.ts @@ -12,7 +12,7 @@ export interface PairingResponse { export interface PairingPollResponse { status: 'pending' | 'approved' | 'denied' | 'expired' refresh_token?: string - entitlement?: string | object + certificate?: string } export interface EntitlementRefreshResponse { diff --git a/shared/types/licensing.ts b/shared/types/licensing.ts index a824985c..992939a8 100644 --- a/shared/types/licensing.ts +++ b/shared/types/licensing.ts @@ -6,7 +6,9 @@ export interface LicenseEntitlement { account_id: string instance_id: string plan: 'community' | 'pro' + plan_source?: string features: ProFeature[] + hosts?: string[] issued_at: string expires_at: string }