mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Merge candidate 20260909T101349Z-delivery-trust
Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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' };
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 <command> [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 }); }
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user