mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
483 lines
18 KiB
TypeScript
483 lines
18 KiB
TypeScript
// @vitest-environment node
|
|
/**
|
|
* E2E Integration Test: zpan ↔ zpan-cloud licensing flow.
|
|
*
|
|
* This test exercises the REAL staging cloud API
|
|
* to verify the full licensing lifecycle:
|
|
* 1. Pairing creation → cloud returns device code
|
|
* 2. Pairing poll → pending status
|
|
* 3. Entitlement refresh → 401 for invalid tokens (CloudUnboundError)
|
|
* 4. Feature gate: community (unbound) → Pro features disabled
|
|
* 5. Feature gate: Pro binding with valid PASETO → Pro features enabled
|
|
* 6. Feature gate: expired cert → Pro features disabled
|
|
* 7. Unbind → binding row deleted, features revoked
|
|
*
|
|
* Pairing approval requires a logged-in cloud user session, so we test up to
|
|
* the poll-pending stage against the live API, then use locally-signed PASETO
|
|
* certs to test the full feature-gate chain end-to-end.
|
|
*
|
|
* Run with: pnpm exec 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,
|
|
type PairingResponse,
|
|
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 { createLicenseBinding, loadLicenseState } from './license-state'
|
|
import { PUBLIC_KEYS } from './public-keys'
|
|
|
|
const CLOUD_BASE_URL = process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev'
|
|
const CLOUD_BASE_ORIGIN = new URL(CLOUD_BASE_URL).origin
|
|
const { secretKey: E2E_SECRET, publicKey: E2E_PUBLIC } = generateKeys('public')
|
|
|
|
function nowSec(): number {
|
|
return Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
function signLicenseAssertion(overrides: Record<string, unknown> = {}): string {
|
|
const now = nowSec()
|
|
return sign(E2E_SECRET, {
|
|
type: 'zpan.license',
|
|
issuer: 'https://cloud.zpan.space',
|
|
subject: 'e2e-binding',
|
|
accountId: 'user-123',
|
|
instanceId: 'test-instance',
|
|
edition: 'pro',
|
|
authorizedHosts: [],
|
|
licenseValidUntil: now + 365 * 24 * 60 * 60,
|
|
issuedAt: now,
|
|
notBefore: now,
|
|
expiresAt: now + 86400,
|
|
...overrides,
|
|
})
|
|
}
|
|
|
|
async function approvePairingInCloud(pairing: PairingResponse): Promise<void> {
|
|
const email = process.env.E2E_CLOUD_PRO_EMAIL
|
|
const password = process.env.E2E_CLOUD_PRO_PASSWORD
|
|
if (!email || !password) throw new Error('E2E_CLOUD_PRO_EMAIL and E2E_CLOUD_PRO_PASSWORD are required')
|
|
|
|
const cloudOrigin = new URL(pairing.pairingUrl).origin
|
|
const signIn = await fetch(`${cloudOrigin}/api/auth/sign-in/email`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Origin: cloudOrigin },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
if (!signIn.ok) {
|
|
const text = await signIn.text().catch(() => '')
|
|
throw new Error(`Cloud test account sign-in failed: ${signIn.status} ${text}`)
|
|
}
|
|
|
|
const cookies = (signIn.headers as Headers & { getSetCookie(): string[] }).getSetCookie()
|
|
const cloudHeaders = { 'Content-Type': 'application/json', Origin: cloudOrigin, Cookie: cookies.join('; ') }
|
|
const licenses = await fetch(`${cloudOrigin}/api/licenses`, { headers: cloudHeaders })
|
|
if (!licenses.ok) {
|
|
const text = await licenses.text().catch(() => '')
|
|
throw new Error(`Cloud license list failed: ${licenses.status} ${text}`)
|
|
}
|
|
|
|
const licenseBody = (await licenses.json()) as { data?: Array<{ id: string }> } | Array<{ id: string }>
|
|
const activeLicenses = Array.isArray(licenseBody) ? licenseBody : (licenseBody.data ?? [])
|
|
for (const license of activeLicenses) {
|
|
const deleted = await fetch(`${cloudOrigin}/api/licenses/${encodeURIComponent(license.id)}`, {
|
|
method: 'DELETE',
|
|
headers: cloudHeaders,
|
|
})
|
|
if (!deleted.ok) {
|
|
const text = await deleted.text().catch(() => '')
|
|
throw new Error(`Cloud license cleanup failed: ${deleted.status} ${text}`)
|
|
}
|
|
}
|
|
|
|
const approve = await fetch(`${cloudOrigin}/api/pairings/${encodeURIComponent(pairing.code)}`, {
|
|
method: 'PATCH',
|
|
headers: cloudHeaders,
|
|
body: JSON.stringify({ action: 'approve' }),
|
|
})
|
|
if (!approve.ok) {
|
|
const text = await approve.text().catch(() => '')
|
|
throw new Error(`Cloud pairing approval failed: ${approve.status} ${text}`)
|
|
}
|
|
}
|
|
|
|
// ─── Phase 1: Live Cloud API contract verification ───────────────────────────
|
|
|
|
describe('E2E: zpan-cloud API contract', () => {
|
|
it('POST /api/pairings creates a pairing with correct shape', async () => {
|
|
const result = await createPairing(
|
|
CLOUD_BASE_URL,
|
|
`e2e-${Date.now()}`,
|
|
'E2E Test Instance',
|
|
'https://e2e-test.local',
|
|
)
|
|
|
|
expect(result).toMatchObject({
|
|
code: expect.stringMatching(/^[A-Z0-9]{3}-[A-Z0-9]{3}$/),
|
|
pairingUrl: expect.stringContaining('/pair?code='),
|
|
expiresAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
|
})
|
|
expect(new URL(result.pairingUrl).origin).toBe(CLOUD_BASE_ORIGIN)
|
|
})
|
|
|
|
it('GET /api/pairings/:code returns pending for a fresh code', async () => {
|
|
const pairing = await createPairing(
|
|
CLOUD_BASE_URL,
|
|
`e2e-poll-${Date.now()}`,
|
|
'E2E Poll Test',
|
|
'https://e2e-poll.local',
|
|
)
|
|
|
|
const result = await pollPairing(CLOUD_BASE_URL, pairing.code)
|
|
|
|
expect(result.status).toBe('pending')
|
|
expect(result.refreshToken).toBeUndefined()
|
|
expect(result.certificate).toBeUndefined()
|
|
})
|
|
|
|
it('POST /api/entitlements rejects invalid Bearer token with 401', async () => {
|
|
await expect(refreshEntitlement(CLOUD_BASE_URL, 'totally-invalid-token')).rejects.toThrow(CloudUnboundError)
|
|
})
|
|
|
|
it('GET /health returns ok', async () => {
|
|
const res = await fetch(`${CLOUD_BASE_URL}/health`)
|
|
expect(res.ok).toBe(true)
|
|
const body = (await res.json()) as { ok: boolean }
|
|
expect(body.ok).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─── Phase 2: zpan feature gates — Community (no binding) ───────────────────
|
|
|
|
describe('E2E: Feature gates — Community (unbound)', () => {
|
|
it('loadBindingState returns { bound: false } when no license', async () => {
|
|
const { db } = await createTestApp()
|
|
const state = await loadBindingState(db)
|
|
|
|
expect(state.bound).toBe(false)
|
|
expect(state.active).toBeUndefined()
|
|
expect(state.edition).toBeUndefined()
|
|
expect(state.license_valid_until).toBeUndefined()
|
|
expect(state.certificate_expires_at).toBeUndefined()
|
|
})
|
|
|
|
it('hasFeature returns false for all Pro features when unbound', async () => {
|
|
const { db } = await createTestApp()
|
|
const state = await loadBindingState(db)
|
|
|
|
expect(hasFeature('open_registration', state)).toBe(false)
|
|
expect(hasFeature('white_label', state)).toBe(false)
|
|
expect(hasFeature('teams_unlimited', state)).toBe(false)
|
|
expect(hasFeature('storages_unlimited', state)).toBe(false)
|
|
})
|
|
|
|
it('PUT /api/system/options returns 402 when enabling open signup without Pro', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
const res = await app.request('/api/system/options/auth_signup_mode', {
|
|
method: 'PUT',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ value: SignupMode.OPEN }),
|
|
})
|
|
|
|
expect(res.status).toBe(402)
|
|
const body = (await res.json()) as { error: string; feature: string }
|
|
expect(body.error).toBe('feature_not_available')
|
|
expect(body.feature).toBe('open_registration')
|
|
})
|
|
})
|
|
|
|
// ─── Phase 3: zpan feature gates — Pro (active binding) ─────────────────────
|
|
|
|
describe('E2E: Feature gates — Pro (active binding)', () => {
|
|
it('loadBindingState returns active Pro metadata when bound', async () => {
|
|
const { db } = await createTestApp()
|
|
await seedProLicense(db)
|
|
|
|
const state = await loadBindingState(db)
|
|
|
|
expect(state.bound).toBe(true)
|
|
expect(state.active).toBe(true)
|
|
expect(state.edition).toBe('pro')
|
|
expect(state.license_valid_until).toEqual(expect.any(Number))
|
|
expect(state.certificate_expires_at).toEqual(expect.any(Number))
|
|
})
|
|
|
|
it('hasFeature returns true for all Pro features when bound', async () => {
|
|
const { db } = await createTestApp()
|
|
await seedProLicense(db)
|
|
|
|
const state = await loadBindingState(db)
|
|
|
|
expect(hasFeature('open_registration', state)).toBe(true)
|
|
expect(hasFeature('white_label', state)).toBe(true)
|
|
expect(hasFeature('teams_unlimited', state)).toBe(true)
|
|
expect(hasFeature('storages_unlimited', state)).toBe(true)
|
|
})
|
|
|
|
it('PUT /api/system/options allows open signup with Pro license', async () => {
|
|
const { app, db } = await createTestApp()
|
|
await seedProLicense(db)
|
|
const headers = await adminHeaders(app)
|
|
|
|
const res = await app.request('/api/system/options/auth_signup_mode', {
|
|
method: 'PUT',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ value: SignupMode.OPEN }),
|
|
})
|
|
|
|
expect(res.status).toBeLessThan(300) // 200 or 201
|
|
})
|
|
|
|
it('hasFeature enables every local Pro gate for an active binding', async () => {
|
|
const { db } = await createTestApp()
|
|
await seedProLicense(db)
|
|
|
|
const state = await loadBindingState(db)
|
|
|
|
expect(hasFeature('white_label', state)).toBe(true)
|
|
expect(hasFeature('open_registration', state)).toBe(true)
|
|
expect(hasFeature('teams_unlimited', state)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─── Phase 4: Expired cert → features revoked ───────────────────────────────
|
|
|
|
describe('E2E: Feature gates — expired certificate', () => {
|
|
it('hasFeature returns false when cert is expired', async () => {
|
|
const { db } = await createTestApp()
|
|
|
|
PUBLIC_KEYS.unshift(E2E_PUBLIC)
|
|
const expiresAt = nowSec() - 1
|
|
await createLicenseBinding(db, {
|
|
cloudBindingId: 'expired-binding',
|
|
cloudStoreId: 'store-expired',
|
|
instanceId: 'test-instance',
|
|
cloudAccountId: 'user-123',
|
|
refreshToken: 'test-token',
|
|
cachedCert: signLicenseAssertion({ expiresAt, licenseValidUntil: expiresAt }),
|
|
cachedExpiresAt: expiresAt,
|
|
lastRefreshAt: nowSec(),
|
|
})
|
|
|
|
const state = await loadBindingState(db)
|
|
|
|
expect(state.bound).toBe(true)
|
|
expect(state.active).toBe(false)
|
|
expect(state.edition).toBeUndefined()
|
|
expect(hasFeature('open_registration', state)).toBe(false)
|
|
expect(hasFeature('white_label', state)).toBe(false)
|
|
|
|
PUBLIC_KEYS.splice(PUBLIC_KEYS.indexOf(E2E_PUBLIC), 1)
|
|
})
|
|
})
|
|
|
|
// ─── Phase 5: Unbind → features revoked ──────────────────────────────────────
|
|
|
|
describe('E2E: Unbind flow', () => {
|
|
it('DELETE /api/licensing/binding unbinds a staging-approved binding and revokes features', async () => {
|
|
const { app, db } = await createTestApp({ ZPAN_CLOUD_URL: CLOUD_BASE_URL })
|
|
const headers = await adminHeaders(app)
|
|
|
|
const pairRes = await app.request('/api/licensing/pair', { method: 'POST', headers })
|
|
expect(pairRes.status).toBe(200)
|
|
const pairing = (await pairRes.json()) as PairingResponse
|
|
await approvePairingInCloud(pairing)
|
|
|
|
const pollRes = await app.request(`/api/licensing/pair/${pairing.code}/poll`, { headers })
|
|
expect(pollRes.status).toBe(200)
|
|
const pollBody = (await pollRes.json()) as { status: string; edition?: string }
|
|
expect(pollBody.status).toBe('approved')
|
|
expect(pollBody.edition).toBe('pro')
|
|
|
|
let state = await loadBindingState(db, { cloudBaseUrl: CLOUD_BASE_URL, currentHost: 'localhost' })
|
|
expect(state.bound).toBe(true)
|
|
expect(hasFeature('open_registration', state)).toBe(true)
|
|
|
|
const res = await app.request('/api/licensing/binding', {
|
|
method: 'DELETE',
|
|
headers,
|
|
})
|
|
expect(res.status).toBe(200)
|
|
|
|
// Verify features are revoked after unbind
|
|
state = await loadBindingState(db, { cloudBaseUrl: CLOUD_BASE_URL, currentHost: 'localhost' })
|
|
expect(state.bound).toBe(false)
|
|
expect(hasFeature('open_registration', state)).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ─── Phase 6: Pairing → Poll → Approval → Feature activation (mocked cloud) ─
|
|
|
|
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 () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
// Step 1: Initiate pairing (mock cloud response)
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
code: 'E2E-TST',
|
|
pairingUrl: 'https://cloud.zpan.space/pair?code=E2E-TST',
|
|
expiresAt: new Date(Date.now() + 900_000).toISOString(),
|
|
}),
|
|
{ status: 201, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
)
|
|
|
|
const pairRes = await app.request('/api/licensing/pair', {
|
|
method: 'POST',
|
|
headers,
|
|
})
|
|
expect(pairRes.status).toBe(200)
|
|
const pairBody = (await pairRes.json()) as { code: string }
|
|
expect(pairBody.code).toBe('E2E-TST')
|
|
|
|
// Step 2: Poll — pending
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ status: 'pending' }), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
)
|
|
|
|
const pendingRes = await app.request('/api/licensing/pair/E2E-TST/poll', { headers })
|
|
expect(pendingRes.status).toBe(200)
|
|
expect(((await pendingRes.json()) as { status: string }).status).toBe('pending')
|
|
|
|
const instanceId = await getOrCreateInstanceId(db)
|
|
const cert = signLicenseAssertion({
|
|
subject: 'e2e-binding',
|
|
instanceId,
|
|
authorizedHosts: ['localhost'],
|
|
})
|
|
|
|
// Step 3: Poll — approved (signed certificate from pairing)
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
status: 'approved',
|
|
refreshToken: 'rt-e2e-secret',
|
|
certificate: cert,
|
|
binding: {
|
|
id: 'e2e-binding',
|
|
storeId: 'store-e2e',
|
|
instanceId,
|
|
authorizedHosts: ['localhost'],
|
|
},
|
|
account: { id: 'user-123', email: 'user@example.com' },
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
)
|
|
|
|
const approvedRes = await app.request('/api/licensing/pair/E2E-TST/poll', { headers })
|
|
expect(approvedRes.status).toBe(200)
|
|
const approvedBody = (await approvedRes.json()) as {
|
|
status: string
|
|
edition?: string
|
|
}
|
|
expect(approvedBody.status).toBe('approved')
|
|
expect(approvedBody.edition).toBe('pro')
|
|
|
|
// Step 4: Verify binding stored in DB
|
|
const state2 = await loadLicenseState(db)
|
|
expect(state2.refreshToken).toBe('rt-e2e-secret')
|
|
expect(state2.cachedCert).toBeTruthy()
|
|
|
|
// Step 5: Verify features are now active
|
|
const state = await loadBindingState(db)
|
|
expect(state.bound).toBe(true)
|
|
expect(state.active).toBe(true)
|
|
expect(state.edition).toBe('pro')
|
|
expect(hasFeature('open_registration', state)).toBe(true)
|
|
expect(hasFeature('white_label', state)).toBe(true)
|
|
expect(hasFeature('teams_unlimited', state)).toBe(true)
|
|
expect(hasFeature('storages_unlimited', state)).toBe(true)
|
|
|
|
// Step 6: Open registration is now allowed
|
|
const openRegRes = await app.request('/api/system/options/auth_signup_mode', {
|
|
method: 'PUT',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ value: SignupMode.OPEN }),
|
|
})
|
|
expect(openRegRes.status).toBeLessThan(300) // 200 or 201
|
|
|
|
// Step 7: Unbind — features revoked
|
|
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 204 }))
|
|
|
|
const unbindRes = await app.request('/api/licensing/binding', {
|
|
method: 'DELETE',
|
|
headers,
|
|
})
|
|
expect(unbindRes.status).toBe(200)
|
|
|
|
const stateAfter = await loadBindingState(db)
|
|
expect(stateAfter.bound).toBe(false)
|
|
expect(hasFeature('open_registration', stateAfter)).toBe(false)
|
|
|
|
// Step 8: Open registration blocked again
|
|
const blockedRes = await app.request('/api/system/options/auth_signup_mode', {
|
|
method: 'PUT',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ value: SignupMode.OPEN }),
|
|
})
|
|
expect(blockedRes.status).toBe(402)
|
|
})
|
|
})
|
|
|
|
// ─── Phase 7: PASETO verification with real public key ───────────────────────
|
|
|
|
describe('E2E: PASETO cert verification chain', () => {
|
|
it('verifyCertificate rejects certs signed with unknown key', async () => {
|
|
const { db } = await createTestApp()
|
|
|
|
// A fake PASETO token that looks structurally valid but signed with wrong key
|
|
const fakePaseto =
|
|
'v4.public.eyJhY2NvdW50X2lkIjoiYTEiLCJpbnN0YW5jZV9pZCI6InRlc3QtaW5zdGFuY2UiLCJwbGFuIjoicHJvIiwiZmVhdHVyZXMiOlsid2hpdGVfbGFiZWwiXSwiZXhwaXJlc19hdCI6IjIwOTktMDEtMDFUMDA6MDA6MDBaIiwiaXNzdWVkX2F0IjoiMjAyNi0wMS0wMVQwMDowMDowMFoifQ.fakesignaturebytes'
|
|
|
|
await createLicenseBinding(db, {
|
|
cloudBindingId: 'fake-binding',
|
|
cloudStoreId: 'store-fake',
|
|
instanceId: 'test-instance',
|
|
cloudAccountId: 'test-account',
|
|
refreshToken: 'test-token',
|
|
cachedCert: fakePaseto,
|
|
cachedExpiresAt: nowSec() + 3600,
|
|
lastRefreshAt: nowSec(),
|
|
})
|
|
|
|
const state = await loadBindingState(db)
|
|
|
|
// Should be bound but inactive (PASETO verification fails)
|
|
expect(state.bound).toBe(true)
|
|
expect(state.active).toBe(false)
|
|
expect(state.edition).toBeUndefined()
|
|
expect(hasFeature('white_label', state)).toBe(false)
|
|
})
|
|
|
|
it('PUBLIC_KEYS contains the production cloud key', () => {
|
|
expect(PUBLIC_KEYS.length).toBeGreaterThanOrEqual(1)
|
|
expect(PUBLIC_KEYS[0]).toMatch(/^k4\.public\./)
|
|
})
|
|
})
|