From 13e16bfc833aa8a5dc041753bd68200cd65ac96e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:23:54 +0100 Subject: [PATCH] test(e2e): provision offline signed organization entitlements Keep a loopback ephemeral issuer alive for source-built managed browser runs and activate each organisation through the authenticated API. Verify installation bindings without billing-state injection or signature bypass, and retain a narrow CI provisioning proof separately from quarantine acceptance. Change-source: pulse-maintainer --- .github/workflows/test-e2e.yml | 53 ++++++++++++ .../subsystems/deployment-installability.md | 26 ++++++ .../scripts/OFFLINE_ENTITLEMENTS.md | 51 ++++++++++++ .../scripts/entitlement-bootstrap.mjs | 5 ++ .../scripts/entitlement-bootstrap.test.mjs | 8 ++ .../scripts/managed-local-backend.test.mjs | 28 +++++++ .../scripts/offline-license-issuer.mjs | 81 +++++++++++++++++++ .../scripts/offline-license-issuer.test.mjs | 56 +++++++++++++ .../scripts/with-offline-entitlements.mjs | 46 +++++++++++ .../with-offline-entitlements.test.mjs | 36 +++++++++ .../integration/tests/03-multi-tenant.spec.ts | 7 ++ tests/integration/tests/helpers.ts | 21 +++++ 12 files changed, 418 insertions(+) create mode 100644 tests/integration/scripts/OFFLINE_ENTITLEMENTS.md create mode 100644 tests/integration/scripts/offline-license-issuer.mjs create mode 100644 tests/integration/scripts/offline-license-issuer.test.mjs create mode 100644 tests/integration/scripts/with-offline-entitlements.mjs create mode 100644 tests/integration/scripts/with-offline-entitlements.test.mjs diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index f2aec42b3..aa11ab784 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -72,6 +72,59 @@ jobs: working-directory: tests/integration run: node --test scripts/report-stable-e2e-failures.test.mjs + offline-org-provisioning: + name: Offline Organization provisioning + # Fixture acceptance only, not quarantine promotion or private RBAC proof. + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: | + tests/integration/package-lock.json + frontend-modern/package-lock.json + internal/cloudcp/portal/frontend/package-lock.json + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - name: Install locked dependencies + run: | + npm ci --prefix tests/integration + npm ci --prefix frontend-modern + npm ci --prefix internal/cloudcp/portal/frontend + cd tests/integration + npx playwright install --with-deps chromium + - name: Validate offline issuer boundaries + working-directory: tests/integration + run: >- + node --test scripts/offline-license-issuer.test.mjs + scripts/with-offline-entitlements.test.mjs + scripts/entitlement-bootstrap.test.mjs + - name: Prove authenticated default and created-org activation + working-directory: tests/integration + env: + PULSE_E2E_USE_LOCAL_BACKEND: 'true' + PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: 'true' + run: >- + node scripts/with-offline-entitlements.mjs node scripts/run-playwright.mjs + --config=playwright.multi-tenant-diagnostic.config.ts + --grep 'Scenario 1:|create, update, member manage' --workers=1 --retries=0 + - name: Upload offline provisioning failure report + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: offline-org-provisioning-report + path: tests/integration/playwright-report/multi-tenant-diagnostic/ + retention-days: 3 + e2e: name: Playwright Core E2E (shard ${{ matrix.shard }}/8) needs: tier-selection diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 23ff141fd..00e9e4bd4 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5318,3 +5318,29 @@ are distinct. Cached events and collection overhead limit timing inference. synthetic Go pass/skip/fail output, producer exit retention, malformed input, allowlisted targeting and unavailable resource evidence. This is not product qualification; see `docs/RELEASE_RESOURCE_EVIDENCE.md`. + +### Offline signed Organization provisioning + +The isolated Community browser harness may provision signed entitlements only +against its run-local loopback issuer and freshly source-built non-release +backend. The managed backend must preserve the supplied public trust root and +issuer URL while keeping license signature bypass and mock mode disabled. +The issuer retains its private key in memory, binds status/refresh credentials +to each installation fingerprint, and remains available until backend cleanup. +A new issuer requires a fresh activation context; production fallback and +legacy-token exchange are not part of this fixture contract. + +The shared Organization helper activates default and every created organisation +through the authenticated, explicitly scoped API, not by writing billing state +or copying activation persistence. It asserts advertised multi-tenancy, absent +Community private RBAC and non-demo security state. Without an offline fixture +key, existing activation behaviour is unchanged. The separate provisioning CI +job does not promote quarantined tests or establish private-runtime acceptance. + +Verification: `tests/integration/scripts/managed-local-backend.test.mjs` checks +that the actual managed environment preserves the ephemeral issuer's trust and +strict-validation settings and uses the source-built Community target. Issuer +binding and lifecycle regressions are in `offline-license-issuer.test.mjs` and +`with-offline-entitlements.test.mjs`; the two scoped Chromium provisioning +scenarios verify the authenticated API/browser path. These are fixture proofs, +not release qualification or evidence that other Organization failures cleared. diff --git a/tests/integration/scripts/OFFLINE_ENTITLEMENTS.md b/tests/integration/scripts/OFFLINE_ENTITLEMENTS.md new file mode 100644 index 000000000..0512c863e --- /dev/null +++ b/tests/integration/scripts/OFFLINE_ENTITLEMENTS.md @@ -0,0 +1,51 @@ +# Offline Organization provisioning + +This fixture is only for a source-built, non-release Community backend on the +same host as Playwright. It is not an installation workaround or a replacement +for private runtime implementations. No production account, licensing service, +admin endpoint or signing material is used. + +After the normal locked installs in `tests/integration`, `frontend-modern` and +`internal/cloudcp/portal/frontend`, and Chromium installation, run from +`tests/integration`: + +```sh +PULSE_E2E_USE_LOCAL_BACKEND=true node scripts/with-offline-entitlements.mjs \ + node scripts/run-playwright.mjs \ + --config=playwright.multi-tenant-diagnostic.config.ts \ + --grep 'Scenario 1:|create, update, member manage' --workers=1 --retries=0 +``` + +The wrapper generates an ephemeral Ed25519 issuer, binds it to loopback and +keeps it alive for the complete managed backend/browser lifecycle. The backend +is freshly built at a unique temporary path. Its development trust root and +licensing URL point to the issuer; signature bypass and mock mode are false. +The issuer has no outbound requests, redirect responses or production fallback. +It implements only activation and authenticated status/refresh, rejects legacy +exchange, and binds independent installation credentials to fingerprints. +Repeated activation for one fingerprint is idempotent. Issuer state lives for +this run, including backend restarts; restarting the issuer requires a fresh +run, not reusing an old activation directory. Never export fixture keys, grant +JWTs, installation credentials or backend runtime-state files as artifacts. + +The Organization spec activates default through the authenticated API before +each scenario. `createOrg` independently activates each created organisation +with explicit scope headers. Capability assertions require `multi_tenant`, +exclude Community's private `rbac`, and check the security contract's +`sessionCapabilities.demoMode=false`. Offline activation suppresses the old +billing.json profile writer; there is no direct capability injection. +No offline key means the existing unentitled and other test paths are unchanged. + +The separate CI provisioning job runs two narrow scenarios and issuer boundary +tests. It does not change stable/probation/quarantine membership. Passing it is +not Organization sharing acceptance, private-runtime RBAC coverage, full-suite +acceptance or release qualification. To investigate another Organization +scenario, use its explicit grep in the same diagnostic config and retain its +result separately. The Web owner judges those user-interface outcomes. + +Local proof on 9 September 2026: two Chromium scenarios passed (3.8s test time) +on the final wrapper, with real authenticated activation and created-org scope; +13 focused Node cases passed for signatures, three distinct identities, +cross-identity/fingerprint/credential rejection, unsupported routes, ephemeral +keys, wrapper lifecycle and refusal of remote/release inputs. No hosted run or +release delivery is established by this proof. diff --git a/tests/integration/scripts/entitlement-bootstrap.mjs b/tests/integration/scripts/entitlement-bootstrap.mjs index 834059cfe..723036d80 100644 --- a/tests/integration/scripts/entitlement-bootstrap.mjs +++ b/tests/integration/scripts/entitlement-bootstrap.mjs @@ -136,6 +136,11 @@ export async function applyRequestedEntitlementProfile({ run = defaultRun, fsModule = fs, } = {}) { + // Signed offline activation is performed through the authenticated API, per org. + // Never inject billing capabilities alongside it. + if (trim(env.PULSE_E2E_OFFLINE_ACTIVATION_KEY)) { + return { applied: false, reason: 'signed_offline_activation' }; + } const request = resolveEntitlementProfile(env); if (request.profile === '') { return { applied: false, reason: 'no_profile_requested' }; diff --git a/tests/integration/scripts/entitlement-bootstrap.test.mjs b/tests/integration/scripts/entitlement-bootstrap.test.mjs index 3595bddbc..17595a064 100644 --- a/tests/integration/scripts/entitlement-bootstrap.test.mjs +++ b/tests/integration/scripts/entitlement-bootstrap.test.mjs @@ -117,3 +117,11 @@ test('applyRequestedEntitlementProfile fails for explicit live-instance runs wit /no entitlement write target/i, ); }); + +test('signed offline activation does not inject a billing profile', async () => { + const result = await applyRequestedEntitlementProfile({ + env: { PULSE_E2E_OFFLINE_ACTIVATION_KEY: 'ephemeral', PULSE_MULTI_TENANT_ENABLED: 'true' }, + run: () => { throw new Error('must not write billing state'); }, + }); + assert.deepEqual(result, { applied: false, reason: 'signed_offline_activation' }); +}); diff --git a/tests/integration/scripts/managed-local-backend.test.mjs b/tests/integration/scripts/managed-local-backend.test.mjs index 5dc83dc95..837bdb779 100644 --- a/tests/integration/scripts/managed-local-backend.test.mjs +++ b/tests/integration/scripts/managed-local-backend.test.mjs @@ -453,3 +453,31 @@ test('owned runner orchestration proves reaping, signal cleanup and concurrent i await run('python3', [path.join(scriptsDir, 'owned-run.test.py')], { timeout: 20000 }); await run(process.execPath, ['--test', path.join(scriptsDir, 'run-tests-interruption.test.mjs')], { timeout: 15000 }); }); + +test('managed offline provisioning preserves ephemeral trust and strict Community validation', async (t) => { + const { startOfflineLicenseIssuer } = await import('./offline-license-issuer.mjs'); + const issuer = await startOfflineLicenseIssuer(); + t.after(() => issuer.close()); + const env = { + PULSE_E2E_RUN_ID: 'offline-provisioning-contract', + PULSE_LICENSE_PUBLIC_KEY: issuer.publicKey, + PULSE_LICENSE_SERVER_URL: issuer.url, + PULSE_LICENSE_DEV_MODE: 'false', + PULSE_MOCK_MODE: 'false', + PULSE_MULTI_TENANT_ENABLED: 'true', + PULSE_E2E_OFFLINE_ACTIVATION_KEY: issuer.activationKey, + }; + const state = buildManagedLocalBackendState(env); + const backend = buildManagedLocalBackendEnv(state, env); + assert.equal(state.backendVariant, 'core'); + assert.deepEqual(state.binaryBuildArgs, ['build', '-o', '__OUTPUT__', './cmd/pulse']); + for (const key of Object.keys(env).filter(key => key !== 'PULSE_E2E_RUN_ID')) { + assert.equal(backend[key], env[key], `${key} must reach the actual managed backend unchanged`); + } + assert.equal(new URL(backend.PULSE_LICENSE_SERVER_URL).hostname, '127.0.0.1'); + assert.equal(Buffer.from(backend.PULSE_LICENSE_PUBLIC_KEY, 'base64').length, 32); + assert.equal(backend.PULSE_DATA_DIR, state.dataDir); + // Development orchestration remains independent of license validation bypass. + assert.equal(backend.PULSE_DEV, 'true'); + assert.equal(backend.PULSE_LICENSE_DEV_MODE, 'false'); +}); diff --git a/tests/integration/scripts/offline-license-issuer.mjs b/tests/integration/scripts/offline-license-issuer.mjs new file mode 100644 index 000000000..305d82e83 --- /dev/null +++ b/tests/integration/scripts/offline-license-issuer.mjs @@ -0,0 +1,81 @@ +// Isolated non-release E2E issuer. No network clients or production credentials. +import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'; +import http from 'node:http'; + +const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url'); +export async function startOfflineLicenseIssuer() { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const activationKey = `ppk_live_e2e_${randomUUID()}`; + const installations = new Map(); + const fingerprints = new Map(); + const grant = installation => { + const now = Math.floor(Date.now() / 1000); + const jti = `grt_${randomUUID()}`; + const claims = { iss: 'pulse-license', aud: 'pulse-relay', sub: installation.id, + lid: installation.license, iid: installation.id, lv: 1, st: 'active', + tier: 'msp', plan: 'msp_starter', iat: now, nbf: now, exp: now + 3600, + jti, email: 'e2e@example.invalid' }; + const input = `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode(claims)}`; + return { jwt: `${input}.${sign(null, Buffer.from(input), privateKey).toString('base64url')}`, + jti, expires_at: new Date(claims.exp * 1000).toISOString() }; + }; + const server = http.createServer(async (req, res) => { + const reply = (status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); + }; + if (req.method !== 'POST' || !['/v1/activate', '/v1/grants/status', '/v1/grants/refresh'].includes(req.url)) { + reply(404, { error: 'unsupported fixture route' }); return; + } + try { + let body = ''; + for await (const chunk of req) { + body += chunk; + if (Buffer.byteLength(body) > 16384) { reply(413, { error: 'request too large' }); return; } + } + const data = JSON.parse(body); + if (!data || typeof data.instance_fingerprint !== 'string' || !data.instance_fingerprint.trim() || data.instance_fingerprint.length > 256) { + reply(400, { error: 'missing fixture fingerprint' }); return; + } + if (req.url === '/v1/activate') { + if (data.activation_key !== activationKey || data.runtime?.build !== 'community') { + reply(401, { error: 'invalid fixture activation' }); return; + } + let installation = fingerprints.get(data.instance_fingerprint); + if (!installation) { + if (installations.size >= 1024) { reply(429, { error: 'fixture capacity reached' }); return; } + installation = { id: `inst_${randomUUID()}`, license: `lic_${randomUUID()}`, + token: `pit_live_e2e_${randomUUID()}`, fingerprint: data.instance_fingerprint }; + installations.set(installation.id, installation); + fingerprints.set(installation.fingerprint, installation); + } + reply(201, { license: { license_id: installation.license, state: 'active', tier: 'msp', license_version: 1 }, + installation: { installation_id: installation.id, installation_token: installation.token, status: 'active' }, + grant: grant(installation) }); + return; + } + const installation = installations.get(data.installation_id); + if (!installation || req.headers.authorization !== `Bearer ${installation.token}` || data.instance_fingerprint !== installation.fingerprint) { + reply(401, { error: 'invalid fixture installation binding' }); return; + } + reply(200, req.url === '/v1/grants/status' + ? { license_version: 1, refresh_required: false, server_time: new Date().toISOString(), status_policy: { recommended_check_after_sec: 300 } } + : { grant: grant(installation) }); + } catch { if (!res.headersSent) reply(400, { error: 'invalid fixture request' }); } + }); + server.requestTimeout = 5000; + server.headersTimeout = 5000; + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + return { + url: `http://127.0.0.1:${server.address().port}`, + publicKey: Buffer.from(publicKey.export({ format: 'jwk' }).x, 'base64url').toString('base64'), + activationKey, + close: () => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections(); + }), + }; +} diff --git a/tests/integration/scripts/offline-license-issuer.test.mjs b/tests/integration/scripts/offline-license-issuer.test.mjs new file mode 100644 index 000000000..2ad0719d3 --- /dev/null +++ b/tests/integration/scripts/offline-license-issuer.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { createPublicKey, verify } from 'node:crypto'; +import test from 'node:test'; +import { startOfflineLicenseIssuer } from './offline-license-issuer.mjs'; + +const post = (issuer, route, data, token = '') => fetch(issuer.url + route, { + method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(data), +}); + +test('offline issuer signs scoped grants and binds status/refresh to each installation', async t => { + const issuer = await startOfflineLicenseIssuer(); + t.after(() => issuer.close()); + const activate = fingerprint => post(issuer, '/v1/activate', { + activation_key: issuer.activationKey, instance_fingerprint: fingerprint, runtime: { build: 'community' }, + }); + const responses = await Promise.all(['default', 'org-a', 'org-b'].map(activate)); + assert.ok(responses.every(r => r.status === 201)); + const states = await Promise.all(responses.map(r => r.json())); + assert.equal(new Set(states.map(s => s.installation.installation_id)).size, 3); + assert.equal(new Set(states.map(s => s.installation.installation_token)).size, 3); + const pub = createPublicKey({ format: 'jwk', key: { kty: 'OKP', crv: 'Ed25519', + x: Buffer.from(issuer.publicKey, 'base64').toString('base64url') } }); + for (const [i, state] of states.entries()) { + const [head, body, signature] = state.grant.jwt.split('.'); + assert.ok(verify(null, Buffer.from(`${head}.${body}`), pub, Buffer.from(signature, 'base64url'))); + const claims = JSON.parse(Buffer.from(body, 'base64url')); + assert.equal(claims.iid, state.installation.installation_id); + assert.equal(claims.lid, state.license.license_id); + assert.equal(claims.st, 'active'); assert.equal(claims.tier, 'msp'); + assert.equal(claims.feat, undefined); // Runtime derives features; no capability injection. + const data = { installation_id: claims.iid, instance_fingerprint: ['default', 'org-a', 'org-b'][i] }; + for (const route of ['/v1/grants/status', '/v1/grants/refresh']) { + assert.equal((await post(issuer, route, data, state.installation.installation_token)).status, 200); + assert.equal((await post(issuer, route, data, 'wrong')).status, 401); + assert.equal((await post(issuer, route, { ...data, instance_fingerprint: 'wrong' }, state.installation.installation_token)).status, 401); + assert.equal((await post(issuer, route, data, states[(i + 1) % 3].installation.installation_token)).status, 401); + } + } + const repeated = await (await activate('org-a')).json(); + assert.deepEqual(repeated.installation, states[1].installation); +}); + +test('offline issuer rejects unsupported routes, bad activation and malformed input', async t => { + const issuer = await startOfflineLicenseIssuer(); t.after(() => issuer.close()); + const data = { activation_key: issuer.activationKey, instance_fingerprint: 'default', runtime: { build: 'community' } }; + for (const mutation of [{ activation_key: 'wrong' }, { runtime: { build: 'enterprise' } }, { instance_fingerprint: '' }]) { + assert.ok((await post(issuer, '/v1/activate', { ...data, ...mutation })).status >= 400); + } + assert.equal((await post(issuer, '/v1/licenses/exchange', data)).status, 404); + assert.equal((await fetch(issuer.url + '/v1/activate')).status, 404); + assert.equal((await fetch(issuer.url + '/v1/activate', { method: 'POST', body: '{' })).status, 400); + assert.equal((await post(issuer, '/v1/activate', { ...data, extra: 'x'.repeat(17000) })).status, 413); + const other = await startOfflineLicenseIssuer(); t.after(() => other.close()); + assert.notEqual(other.publicKey, issuer.publicKey); + assert.notEqual(other.activationKey, issuer.activationKey); +}); diff --git a/tests/integration/scripts/with-offline-entitlements.mjs b/tests/integration/scripts/with-offline-entitlements.mjs new file mode 100644 index 000000000..322eff214 --- /dev/null +++ b/tests/integration/scripts/with-offline-entitlements.mjs @@ -0,0 +1,46 @@ +// Run the complete managed-local E2E lifecycle with an in-memory issuer. +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { stopManagedLocalBackend } from './managed-local-backend.mjs'; +import { startOfflineLicenseIssuer } from './offline-license-issuer.mjs'; + +const [command, ...args] = process.argv.slice(2); +if (!command) throw new Error('Usage: node scripts/with-offline-entitlements.mjs [args...]'); +if (process.env.PULSE_E2E_USE_LOCAL_BACKEND !== 'true') { + throw new Error('Offline issuer requires PULSE_E2E_USE_LOCAL_BACKEND=true; not Docker, remote or release binaries'); +} +if (process.env.PULSE_E2E_LOCAL_BACKEND_BINARY || process.env.PULSE_E2E_LOCAL_BACKEND_VARIANT) { + throw new Error('Use the source-built Community managed backend, not a supplied binary/runtime'); +} +for (const key of ['PULSE_BASE_URL', 'PULSE_E2E_USE_HOT_DEV', 'PULSE_E2E_SKIP_DOCKER', 'PULSE_E2E_RUNTIME_STATE_PATH', 'GOFLAGS']) { + if (process.env[key]) throw new Error(`Offline managed lifecycle does not accept ${key}`); +} +const binaryRoot = await mkdtemp(path.join(tmpdir(), 'pulse-offline-e2e-')); +const issuer = await startOfflineLicenseIssuer(); +const runtimeEnv = { ...process.env, PULSE_E2E_RUNTIME_STATE_PATH: path.join(binaryRoot, 'runtime.json') }; +try { + const child = spawn(command, args, { stdio: 'inherit', env: { ...runtimeEnv, + PULSE_E2E_LOCAL_BACKEND_BINARY: path.join(binaryRoot, 'pulse'), + PULSE_E2E_LOCAL_BACKEND_HOST: '127.0.0.1', + PULSE_LICENSE_PUBLIC_KEY: issuer.publicKey, PULSE_LICENSE_SERVER_URL: issuer.url, + PULSE_LICENSE_DEV_MODE: 'false', PULSE_MOCK_MODE: 'false', PULSE_MULTI_TENANT_ENABLED: 'true', + PULSE_E2E_OFFLINE_ACTIVATION_KEY: issuer.activationKey, + } }); + const forward = signal => child.kill(signal); + const term = () => forward('SIGTERM'); + const interrupt = () => forward('SIGINT'); + process.on('SIGTERM', term); process.on('SIGINT', interrupt); + try { + process.exitCode = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve(code ?? (signal === 'SIGINT' ? 130 : 143))); + }); + } finally { + process.off('SIGTERM', term); process.off('SIGINT', interrupt); + } +} finally { + try { await stopManagedLocalBackend({ env: runtimeEnv }); } + finally { await issuer.close(); await rm(binaryRoot, { recursive: true, force: true }); } +} diff --git a/tests/integration/scripts/with-offline-entitlements.test.mjs b/tests/integration/scripts/with-offline-entitlements.test.mjs new file mode 100644 index 000000000..c9354f4da --- /dev/null +++ b/tests/integration/scripts/with-offline-entitlements.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; +const script = new URL('./with-offline-entitlements.mjs', import.meta.url).pathname; +function run(args, extra = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [script, ...args], { + env: { PATH: process.env.PATH, HOME: process.env.HOME, PULSE_E2E_USE_LOCAL_BACKEND: 'true', ...extra }, + }); + let output = ''; + child.stdout.on('data', data => { output += data; }); + child.stderr.on('data', data => { output += data; }); + child.on('error', reject); + child.on('exit', code => resolve({ code, output })); + }); +} +test('wrapper keeps issuer alive for child and closes it after child failure', async () => { + const result = await run([process.execPath, '-e', ` + const assert = require('node:assert/strict'); + assert.equal(process.env.PULSE_LICENSE_DEV_MODE, 'false'); + assert.equal(process.env.PULSE_MOCK_MODE, 'false'); + fetch(process.env.PULSE_LICENSE_SERVER_URL + '/v1/activate', { + method: 'POST', body: JSON.stringify({activation_key: process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY, + instance_fingerprint: 'test', runtime: {build: 'community'}}) + }).then(r => { assert.equal(r.status, 201); console.log(process.env.PULSE_LICENSE_SERVER_URL); process.exitCode = 7; }); + `]); + assert.equal(result.code, 7, result.output); + const url = result.output.trim().split('\n')[0]; + await assert.rejects(fetch(url)); +}); +test('wrapper refuses remote, supplied binaries and release build flags before spawning', async () => { + for (const extra of [{ PULSE_BASE_URL: 'https://example.invalid' }, { PULSE_E2E_LOCAL_BACKEND_BINARY: '/tmp/release' }, { GOFLAGS: '-tags=release' }, { PULSE_E2E_USE_LOCAL_BACKEND: 'false' }]) { + const result = await run([process.execPath, '-e', "console.log('CHILD RAN')"], extra); + assert.notEqual(result.code, 0); assert.ok(!result.output.includes('\nCHILD RAN\n')); + } +}); diff --git a/tests/integration/tests/03-multi-tenant.spec.ts b/tests/integration/tests/03-multi-tenant.spec.ts index 44dd70f93..2e13826c7 100644 --- a/tests/integration/tests/03-multi-tenant.spec.ts +++ b/tests/integration/tests/03-multi-tenant.spec.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { test as base, expect } from '@playwright/test'; import { apiRequest, + activateOfflineOrganization, createAuthenticatedStorageState, createOrg, deleteOrg, @@ -105,6 +106,12 @@ const expectStatusIn = (status: number, allowed: number[], context: string) => { }; test.describe('Multi-tenant E2E flows', () => { + test.beforeEach(async ({ page }) => { + if (process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY) { + await ensureSessionAuthenticated(page); + await activateOfflineOrganization(page); + } + }); test('Scenario 1: feature flag visibility', async ({ page }) => { await ensureAuthenticated(page); diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index 7f44dfec1..8a0a04f78 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -1699,6 +1699,26 @@ const toOrgID = (displayName: string) => { return `${base}-${suffix}`.slice(0, 64); }; +// Only the isolated issuer wrapper supplies this ephemeral key. Production and +// unentitled suites keep their normal activation state. +export async function activateOfflineOrganization(page: Page, orgId = "default"): Promise { + const key = process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY; + if (!key) return; + const headers = { "X-Pulse-Org-ID": orgId, "X-Org-ID": orgId }; + const activation = await apiRequest(page, "/api/license/activate", { + method: "POST", headers, data: { license_key: key }, + }); + if (!activation.ok()) throw new Error(`Offline org activation failed: ${activation.status()}`); + const response = await apiRequest(page, "/api/license/runtime-capabilities", { headers }); + if (!response.ok()) throw new Error(`Offline capability lookup failed: ${response.status()}`); + const payload = await response.json(); + const security = await apiRequest(page, "/api/security/status", { headers }); + expect(security.ok()).toBe(true); + expect((await security.json()).sessionCapabilities.demoMode).toBe(false); + expect(payload.capabilities).toContain("multi_tenant"); + expect(payload.capabilities).not.toContain("rbac"); +} + export async function createOrg( page: Page, displayName: string, @@ -1718,6 +1738,7 @@ export async function createOrg( throw new Error("Failed to create org: response missing org id"); } + await activateOfflineOrganization(page, payload.id); return { id: payload.id }; }