fix: align licensing pairing with cloud certificates

This commit is contained in:
saltbo
2026-04-27 08:46:05 -04:00
parent 45b623e362
commit 4872f97d9f
8 changed files with 133 additions and 53 deletions
+23 -10
View File
@@ -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' } },
),
+4
View File
@@ -34,7 +34,9 @@ function signCert(overrides: Record<string, unknown> = {}, 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', () => {
+2
View File
@@ -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,
}
@@ -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<string, unknown>
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', () => {
+24 -33
View File
@@ -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<Env>()
.use(requireAdmin)
@@ -31,7 +43,7 @@ const app = new Hono<Env>()
.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<Env>()
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<Env>()
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<Env>()
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
+2 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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 {
+2
View File
@@ -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
}