From ddeb49ded65ee88b4dd4a30772a60f1fc6e0776e Mon Sep 17 00:00:00 2001 From: saltbo Date: Sun, 10 May 2026 01:18:03 -0400 Subject: [PATCH] test(cloud): verify staging license unbind --- .github/workflows/ci.yml | 4 + e2e/cloud-store.spec.ts | 8 +- .../licensing/e2e-cloud-integration.test.ts | 80 +++++++++++++++++-- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6716f60d..198e8bcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [master] +env: + E2E_CLOUD_PRO_EMAIL: ${{ secrets.E2E_CLOUD_PRO_EMAIL }} + E2E_CLOUD_PRO_PASSWORD: ${{ secrets.E2E_CLOUD_PRO_PASSWORD }} + jobs: check: name: Typecheck & Test diff --git a/e2e/cloud-store.spec.ts b/e2e/cloud-store.spec.ts index 4b33edfc..e2ae4369 100644 --- a/e2e/cloud-store.spec.ts +++ b/e2e/cloud-store.spec.ts @@ -1,8 +1,6 @@ import { type Browser, expect, type Page, request as playwrightRequest, test } from '@playwright/test' import { signInAsAdmin, signUpAndGoToFiles } from './helpers' -const CLOUD_PRO_EMAIL = process.env.E2E_CLOUD_PRO_EMAIL ?? 'zpan-e2e-pro@zpan.test' -const CLOUD_PRO_PASSWORD = process.env.E2E_CLOUD_PRO_PASSWORD ?? 'ZPanStagingE2E!2026' const LOCALHOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/ type BindingState = { @@ -158,11 +156,15 @@ async function enableCloudStore(page: Page) { } async function approvePairingInCloud(pairing: PairingInfo) { + 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 cloudRequest = await playwrightRequest.newContext({ baseURL: cloudOrigin }) try { const signIn = await cloudRequest.post('/api/auth/sign-in/email', { - data: { email: CLOUD_PRO_EMAIL, password: CLOUD_PRO_PASSWORD }, + data: { email, password }, }) expect(signIn.status()).toBe(200) diff --git a/server/licensing/e2e-cloud-integration.test.ts b/server/licensing/e2e-cloud-integration.test.ts index 6195a4e0..ba3db1c7 100644 --- a/server/licensing/e2e-cloud-integration.test.ts +++ b/server/licensing/e2e-cloud-integration.test.ts @@ -22,7 +22,13 @@ 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 { + 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' @@ -55,6 +61,54 @@ function signLicenseAssertion(overrides: Record = {}): string { }) } +async function approvePairingInCloud(pairing: PairingResponse): Promise { + 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', () => { @@ -230,17 +284,25 @@ describe('E2E: Feature gates — expired certificate', () => { // ─── Phase 5: Unbind → features revoked ────────────────────────────────────── describe('E2E: Unbind flow', () => { - it('DELETE /api/licensing/binding removes binding and revokes features', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) + 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) - // Verify features are active before unbind - let state = await loadBindingState(db) + 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) - // Unbind const res = await app.request('/api/licensing/binding', { method: 'DELETE', headers, @@ -248,7 +310,7 @@ describe('E2E: Unbind flow', () => { expect(res.status).toBe(200) // Verify features are revoked after unbind - state = await loadBindingState(db) + state = await loadBindingState(db, { cloudBaseUrl: CLOUD_BASE_URL, currentHost: 'localhost' }) expect(state.bound).toBe(false) expect(hasFeature('open_registration', state)).toBe(false) }) @@ -361,6 +423,8 @@ describe('E2E: Full pairing-to-activation flow (mocked cloud approval)', () => { 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,