mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
fix: align licensing pairing with cloud certificates
This commit is contained in:
@@ -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' } },
|
||||
),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user