From 14a109444c6d1cad420797ea30f279e77b6fc517 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 1/4] 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 }; } From 6cddb75fb1a45a617288aa0771c5d7e30e7b4ffa 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:42:06 +0100 Subject: [PATCH 2/4] fix(web): wrap entitled organization header on compact screens Activate the offline default context before mobile organization audits. Preserve visible session controls without page overflow, with real-backend Chromium and WebKit proof. Change-source: pulse-maintainer --- .../v6/internal/subsystems/ai-runtime.md | 10 +++++ .../v6/internal/subsystems/cloud-paid.md | 10 +++++ .../subsystems/frontend-primitives.md | 14 +++++++ frontend-modern/browser-verification.json | 39 ++++++++++--------- frontend-modern/src/AppLayout.tsx | 4 +- .../src/__tests__/App.architecture.test.ts | 14 +++++++ .../src/__tests__/AppLayout.test.tsx | 8 ++++ .../tests/05-settings-mobile-audit.spec.ts | 12 ++++++ 8 files changed, 91 insertions(+), 20 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 60ac40224..12300fe0e 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -8311,3 +8311,13 @@ Related command targets do not retarget an alert. The funded OpenRouter `openai/gpt-6-astra` route has explicitly reviewed, tiered cost estimates dated 2026-09-07. Unreviewed aliases stay unknown. Its history-explanation qualification is distinct from the earlier Gemini lab matrix and from wider rollout evidence. + +### Compact shell Assistant coexistence + +The mobile Assistant launcher remains in shared header flow. That header and +its controls may wrap at compact widths so an entitled Organization selector +does not displace session actions or the connection indicator beyond the page. +Keep desktop launcher placement and existing visibility decisions unchanged; +wrapping must not introduce entitlement reads or alter Assistant authority. +AppLayout component and architecture regressions preserve these boundaries; +real-backend phone geometry proof is retained by the Organization mobile audit. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 4bedecd99..1c13c6278 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3462,3 +3462,13 @@ A failed reconnect admission request retains the current tenant's last valid facet. The synchronous organisation-switch reset still clears outgoing admission before requesting the new tenant, including when that request fails. This is navigation continuity, not an authorization or entitlement decision. + +### Compact entitled Organization chrome + +An entitled self-hosted Organization selector must not push session controls +outside a compact viewport. The shared header may wrap on phones while retaining +desktop placement; this must not change organization scope, feature gates or +activation semantics. The offline mobile audit explicitly activates the default +context before it creates and activates isolated organizations, using the normal +authenticated API. Its route/overflow checks and AppLayout regression cover this +boundary without granting Community private RBAC or changing CI tier membership. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d3cf8d562..96ac5dcd5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7280,3 +7280,17 @@ must not clear a background history filter or invalidate its return-focus target Ordinary search shortcuts resume when the background becomes interactive again. When Assistant is already open, a desktop-to-phone transition must retain that destination rather than reopening the underlying history drawer above it. + +### Compact Organization header wrapping + +The shared header and its controls must wrap below the small-screen breakpoint +instead of forcing the document wider when an entitled Organization selector +joins the session controls. Keep the selector, kiosk/logout controls and +connection indicator available; do not hide overflow to conceal an inaccessible +action. Desktop grid placement remains unchanged. + +Proof: `AppLayout.test.tsx` pins wrapping and retained logout semantics. The +signed-offline `05-settings-mobile-audit.spec.ts` exercises real Organization, +Access and Sharing routes at 320px and 390px, with app-shell width and full-scroll +assertions; header screenshots retain the compact layout. This is independent +of private RBAC implementation and hosted probation acceptance. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index da66077c5..b0f550971 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,39 +1,42 @@ { "version": 1, - "base_sha": "6f3547bc5fbb10ddf313a8cd2f184b6359173a59", - "verified_at": "2026-09-08T22:29:31.643744Z", + "base_sha": "14a109444c6d1cad420797ea30f279e77b6fc517", + "verified_at": "2026-09-09T10:42:06.278902Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Alerts/useWebhookConfigState.ts" + "frontend-modern/src/AppLayout.tsx" ], "content_sha256": { - "frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "14f0dceb069fe52b9a47100c50998ae53a2c5c8a29a524e342f7458546cb4a19" + "frontend-modern/src/AppLayout.tsx": "71cbc527fae0dbae3891174bb8a140d90f50917405d6c15a3b0d8ae7dcb31644" }, "routes": [ - "/qualification (real WebhookConfigForm and hook; synthetic callbacks)" + "/settings/organization", + "/settings/organization/access", + "/settings/organization/sharing" ], "viewports": [ { - "width": 1440, - "height": 900 - }, - { - "width": 900, - "height": 900 + "width": 320, + "height": 568 }, { "width": 390, - "height": 900 + "height": 844 + }, + { + "width": 1280, + "height": 720 } ], "states": [ - "Unsaved manually entered Pushover aliases", - "Test callback payload", - "Save callback payload" + "Signed entitled default and created organizations; non-demo Community backend", + "Access invitation and outgoing sharing fixture", + "Desktop incoming share pending then accepted" ], "interactions": [ - "Click Test then Add Webhook at each width; assert both canonical token/user payloads, no page errors or horizontal overflow." + "Chromium and mobile WebKit: switch organization; audit three routes at both phone widths; reach shell bottom and assert page/shell width within one pixel", + "Desktop Chromium Scenario 6: verify owner identity relationships, editor/viewer roles and pending approval; click Accept and verify accepted state via API" ], - "notes": "Real component with Vite dev transform in Chromium, not production build or installed/provider delivery. Phone screenshot visually inspected; synthetic credentials only. Screenshots /tmp/pulse-webhook-parity.", - "command": "pulse-heavy-run -- node scripts/check-webhook-test-save-parity.mjs" + "command": "pulse-heavy-run -- bash -c \"cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=true node scripts/with-offline-entitlements.mjs node scripts/run-playwright.mjs tests/05-settings-mobile-audit.spec.ts --project=chromium --project=mobile-safari --grep settings/organization --workers=1 --retries=0\"; separate diagnostic config Scenario 6 desktop run", + "notes": "Exact actual mobile grep: settings/organization(/(sharing|access))?$ . Six mobile route tests passed (53.0s); desktop sharing scenario passed (3.8s). Source-built real Community backend, ephemeral loopback signing issuer, no mock mode or signature bypass. Phone Chromium/WebKit and desktop acceptance screenshots inspected. Evidence retained in Web20260909T103517Z outcome directory browser-proof, org-fixed.log and scenario6-desktop.log. Not private RBAC, hosted probation clearance or release qualification." } diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index c00ce6a70..33c7fb981 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -741,7 +741,7 @@ export function AppLayout(props: AppLayoutProps) { />
{renderAssistantLauncher(AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS)} diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 00ba82307..11c8cf9ab 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -571,6 +571,20 @@ describe('App architecture', () => { expect(retiredRouteNavigations).toEqual([]); }); + it('keeps compact organization chrome presentational and its offline audit explicitly entitled', () => { + expect(appLayoutSource).toContain(''); + expect(appLayoutSource).toContain('selectedOrgId={props.activeOrgID()}'); + expect(appLayoutSource).toContain('onChange={props.onSwitchOrg}'); + expect(appLayoutSource).not.toContain('/api/license/activate'); + const audit = readFileSync(join(integrationTestsDir, '05-settings-mobile-audit.spec.ts'), 'utf8'); + const preparation = audit.slice(audit.indexOf('const prepareOrganizationAuditFixture'), audit.indexOf('const test = base.extend')); + const activation = preparation.indexOf('await activateOfflineOrganization(page)'); + expect(activation).toBeGreaterThan(preparation.indexOf('await ensureSessionAuthenticated(page)')); + expect(activation).toBeLessThan(preparation.indexOf('await createOrg(page')); + expect(activation).toBeGreaterThan(0); + expect(preparation).toContain('process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY'); + }); + it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => { expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)'); expect(appLayoutSource).toContain( diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 6d25667ee..0073a4ab3 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -510,6 +510,14 @@ describe('AppLayout navigation icons', () => { expect(launcher.closest('.header-controls')).toBeInTheDocument(); }); + it('allows mobile header controls to wrap without hiding organization or session actions', () => { + setViewportWidth(320); + const { container } = renderLayout(); + expect(container.querySelector('.header')).toHaveClass('flex-wrap', 'sm:flex-nowrap'); + expect(container.querySelector('.header-controls')).toHaveClass('max-w-full', 'flex-wrap', 'sm:flex-nowrap'); + expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument(); + }); + it('preserves the Assistant edge launcher on desktop', () => { setViewportWidth(1440); renderLayout(); diff --git a/tests/integration/tests/05-settings-mobile-audit.spec.ts b/tests/integration/tests/05-settings-mobile-audit.spec.ts index 1d2fe20e8..28fb41dee 100644 --- a/tests/integration/tests/05-settings-mobile-audit.spec.ts +++ b/tests/integration/tests/05-settings-mobile-audit.spec.ts @@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url"; import { test as base, expect } from "@playwright/test"; import { apiRequest, + activateOfflineOrganization, + ensureSessionAuthenticated, createAuthenticatedStorageState, createOrg, } from "./helpers"; @@ -98,6 +100,11 @@ const prepareOrganizationAuditFixture = async ( if (!(ORGANIZATION_SETTINGS_ROUTES as readonly string[]).includes(route)) return []; + if (process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY) { + await ensureSessionAuthenticated(page); + await activateOfflineOrganization(page); + } + await page.goto("/settings/infrastructure", { waitUntil: "domcontentloaded", }); @@ -255,6 +262,11 @@ test.describe("Settings mobile optimization audit", () => { audit.pageWidth, `Mobile overflow on ${route} at ${viewport.width}px (viewport=${audit.viewportWidth}, page=${audit.pageWidth}, offenders=${JSON.stringify(audit.offenders)})`, ).toBeLessThanOrEqual(audit.viewportWidth + 1); + if ((ORGANIZATION_SETTINGS_ROUTES as readonly string[]).includes(route)) { + // Retain the header as well as the bottom-of-content width proof. + await page.locator(".app-scroll-shell").evaluate((element) => { element.scrollTop = 0; }); + await page.screenshot({ path: test.info().outputPath(`organization-${viewport.width}.png`) }); + } } } finally { if (createdOrgIDs.length > 0) { From db0c4fd3bb0a0fad94fb85782bc43629fef73f43 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:10:03 +0100 Subject: [PATCH 3/4] fix(alerts): notify on health assessment escalation A running custom sensor updated its canonical incident from warning to critical without dispatching the new severity (#1801). Enable upward transitions in the shared health adapter while keeping unchanged observations and downgrades quiet. Use the existing hourly budget and dispatch suppression rather than bypassing policy. Pin CheckHost callback delivery, acknowledgement, snooze, inactive and flapping suppression, and shared ZFS rate exhaustion with focused regressions. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 17 ++++ internal/alerts/canonical_stateful_test.go | 44 +++++++++ internal/alerts/health_assessment.go | 7 +- internal/alerts/host_unraid_lifecycle_test.go | 98 +++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 4f3717b24..49dc1123c 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -813,6 +813,23 @@ local command or REST URL. `TestHostCustomSensorAlertLifecycle` and `internal/alerts/host_unraid_lifecycle_test.go` pin creation, recovery, opt-out, and cleanup. +Health-assessment warning→critical transitions dispatch the updated incident +through the normal acknowledgement, snooze, activation and flapping policy; +unchanged severity and critical→warning transitions do not dispatch. The shared +health-assessment caller opts into the configured per-incident hourly limit for +initial, refired and escalation notifications, so repeated severity oscillations +cannot bypass that budget. Incident severity still updates when delivery is +suppressed. This applies to all callers of the shared assessment adapter +(custom sensors, host storage/RAID and storage ZFS pool/device health), not other +stateful alert families. No delivery-policy bypass or separate sensor callback +is introduced. +`TestHostCustomSensorEscalationDelivery` in +`internal/alerts/host_unraid_lifecycle_test.go` pins the running-host callback, +stable incident identity and suppression/no-noise cases; +`TestHealthAssessmentEscalationDelivery` in +`internal/alerts/canonical_stateful_test.go` pins shared ZFS escalation and +hourly-budget exhaustion across warning/critical oscillation. + The alert resource-incident panel (`frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx`) dropped its "Open in Infrastructure / Workloads / Storage / Recovery" diff --git a/internal/alerts/canonical_stateful_test.go b/internal/alerts/canonical_stateful_test.go index 7920c9563..8bf50a00e 100644 --- a/internal/alerts/canonical_stateful_test.go +++ b/internal/alerts/canonical_stateful_test.go @@ -279,3 +279,47 @@ func TestStoragePolicyAliasesLegacyIdentity(t *testing.T) { }) } } + +// The shared assessment path also owns ZFS health, not only custom sensors. +func TestHealthAssessmentEscalationDelivery(t *testing.T) { + m := newTestManager(t) + cfg := m.GetConfig() + cfg.Enabled = true + cfg.ActivationState = ActivationActive + cfg.FlappingEnabled = false + cfg.Schedule.MaxAlertsHour = 2 + m.UpdateConfig(cfg) + var delivered []AlertLevel + m.SetAlertCallback(func(a *Alert) { delivered = append(delivered, a.Level) }) + resourceID := "storage-1/zfs-pool:tank" + params := canonicalHealthAssessmentAlertParams{ + SpecID: resourceID + "-health", Signal: "zfs_pool", Codes: zfsPoolAssessmentCodes, + AlertID: buildCanonicalStateID(resourceID, resourceID+"-health"), + AlertType: "zfs-pool-state", SpecResourceID: resourceID, ResourceID: resourceID, + ResourceName: "tank", ResourceType: unifiedresources.ResourceTypeStorage, + } + observe := func(severity storagehealth.RiskLevel) { + t.Helper() + params.Reasons = []storagehealth.Reason{{Code: "zfs_pool_state", Severity: severity, Summary: "pool health"}} + if _, ok := m.syncCanonicalHealthAssessmentAlert(params); !ok { + t.Fatal("assessment rejected") + } + } + observe(storagehealth.RiskWarning) + observe(storagehealth.RiskWarning) + observe(storagehealth.RiskCritical) + observe(storagehealth.RiskCritical) + observe(storagehealth.RiskWarning) + if len(delivered) != 2 || delivered[0] != AlertLevelWarning || delivered[1] != AlertLevelCritical { + t.Fatalf("initial/escalated deliveries = %v", delivered) + } + // The two admitted notifications exhaust the configured hourly budget; + // oscillating back to critical must not bypass it. + observe(storagehealth.RiskCritical) + if len(delivered) != 2 { + t.Fatalf("rate limit bypassed: %v", delivered) + } + if a := testRequireActiveAlert(t, m, params.AlertID); a.Level != AlertLevelCritical { + t.Fatalf("rate-limited incident failed to update: %v", a.Level) + } +} diff --git a/internal/alerts/health_assessment.go b/internal/alerts/health_assessment.go index 852c27efc..b25cddcc1 100644 --- a/internal/alerts/health_assessment.go +++ b/internal/alerts/health_assessment.go @@ -187,6 +187,7 @@ func (m *Manager) syncCanonicalHealthAssessmentAlert(params canonicalHealthAsses return alertspecs.EvaluationResult{}, false } + severity := storageHealthAssessmentSeverity(params.Reasons) now := time.Now() return m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{ Spec: spec, @@ -194,7 +195,7 @@ func (m *Manager) syncCanonicalHealthAssessmentAlert(params canonicalHealthAsses ObservedAt: now, HealthAssessment: &alertspecs.HealthAssessmentEvidence{ Signal: params.Signal, - Severity: storageHealthAssessmentSeverity(params.Reasons), + Severity: severity, Codes: storageHealthReasonCodes(params.Reasons), }, }, @@ -209,5 +210,9 @@ func (m *Manager) syncCanonicalHealthAssessmentAlert(params canonicalHealthAsses AddToRecent: true, AddToHistory: true, MessageBuilder: params.MessageBuilder, + // Health assessments have warning/critical firing levels. Only upward + // transitions notify; unchanged observations and downgrades stay quiet. + NotifyOnSeverityChange: severity == alertspecs.AlertSeverityCritical, + RateLimit: true, }) } diff --git a/internal/alerts/host_unraid_lifecycle_test.go b/internal/alerts/host_unraid_lifecycle_test.go index 14b244e6d..3388516e3 100644 --- a/internal/alerts/host_unraid_lifecycle_test.go +++ b/internal/alerts/host_unraid_lifecycle_test.go @@ -207,3 +207,101 @@ func hasAlertType(alerts []Alert, alertType string) bool { } return false } + +func TestHostCustomSensorEscalationDelivery(t *testing.T) { + for _, policy := range []string{"ready", "acknowledged", "snoozed", "rate-limited", "flapping", "inactive"} { + t.Run(policy, func(t *testing.T) { + m := newTestManager(t) + cfg := m.GetConfig() + cfg.Enabled = true + cfg.ActivationState = ActivationActive + cfg.FlappingEnabled = false + cfg.Schedule.MaxAlertsHour = 0 + m.UpdateConfig(cfg) + var delivered []AlertLevel + m.SetAlertCallback(func(a *Alert) { + if a.Type == "custom-sensor" { + delivered = append(delivered, a.Level) + } + }) + host := models.Host{ID: "sensor-host", Hostname: "sensor-host", Sensors: models.HostSensorSummary{ + Custom: []models.HostCustomSensorMetric{{ID: "probe", Name: "Probe", Status: "warning", ObservedAt: time.Now()}}, + }} + m.CheckHost(host) + if len(delivered) != 1 || delivered[0] != AlertLevelWarning { + t.Fatalf("initial delivery = %v", delivered) + } + var id string + for _, a := range m.GetActiveAlerts() { + if a.Type == "custom-sensor" { + id = a.ID + } + } + if id == "" { + t.Fatal("missing custom sensor incident") + } + switch policy { + case "acknowledged": + if err := m.AcknowledgeAlert(id, "tester"); err != nil { + t.Fatal(err) + } + case "snoozed": + if err := m.SnoozeAlert(id, "tester", time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + case "rate-limited": + a := testRequireActiveAlert(t, m, id) + m.mu.Lock() + m.config.Schedule.MaxAlertsHour = 1 + m.alertRateLimit[canonicalTrackingKeyForAlert(a)] = []time.Time{time.Now()} + m.mu.Unlock() + case "flapping": + a := testRequireActiveAlert(t, m, id) + m.mu.Lock() + m.config.FlappingEnabled = true + m.suppressedUntil[canonicalTrackingKeyForAlert(a)] = time.Now().Add(time.Hour) + m.mu.Unlock() + case "inactive": + cfg.ActivationState = ActivationPending + m.UpdateConfig(cfg) + } + // Repeated warning observations must not resend. + m.CheckHost(host) + host.Sensors.Custom[0].Status = "critical" + m.CheckHost(host) + want := 1 + if policy == "ready" { + want = 2 + } + if len(delivered) != want { + t.Fatalf("warning->critical deliveries = %v, want %d callbacks", delivered, want) + } + if policy == "ready" && delivered[1] != AlertLevelCritical { + t.Fatalf("escalation = %v", delivered) + } + active := m.GetActiveAlerts() + found := false + for _, a := range active { + if a.Type == "custom-sensor" { + found = true + if a.ID != id || a.Level != AlertLevelCritical { + t.Fatalf("updated incident = %+v", a) + } + if policy == "acknowledged" && !a.Acknowledged { + t.Fatal("acknowledgement lost") + } + } + } + if !found { + t.Fatal("critical incident missing") + } + m.CheckHost(host) + host.Sensors.Custom[0].Status = "warning" + m.CheckHost(host) + m.CheckHost(host) + if len(delivered) != want { + t.Fatalf("unchanged/downgrade noise: %v", delivered) + } + }) + } +} From cbf995df5e80534f653f78d3752a9b0595d16385 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:17:03 +0100 Subject: [PATCH 4/4] style(web): format app architecture and layout tests The whole-tree frontend formatting gate blocks PR #2005 on these two tests. Apply the locked Prettier output without changing assertions or production code; the formatting check and all 56 focused tests pass. Contract-Neutral: formatting-only test changes; runtime behaviour and assertions unchanged. Change-source: pulse-maintainer --- .../src/__tests__/App.architecture.test.ts | 14 +++++++++++--- frontend-modern/src/__tests__/AppLayout.test.tsx | 6 +++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 11c8cf9ab..1c7578ad0 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -576,10 +576,18 @@ describe('App architecture', () => { expect(appLayoutSource).toContain('selectedOrgId={props.activeOrgID()}'); expect(appLayoutSource).toContain('onChange={props.onSwitchOrg}'); expect(appLayoutSource).not.toContain('/api/license/activate'); - const audit = readFileSync(join(integrationTestsDir, '05-settings-mobile-audit.spec.ts'), 'utf8'); - const preparation = audit.slice(audit.indexOf('const prepareOrganizationAuditFixture'), audit.indexOf('const test = base.extend')); + const audit = readFileSync( + join(integrationTestsDir, '05-settings-mobile-audit.spec.ts'), + 'utf8', + ); + const preparation = audit.slice( + audit.indexOf('const prepareOrganizationAuditFixture'), + audit.indexOf('const test = base.extend'), + ); const activation = preparation.indexOf('await activateOfflineOrganization(page)'); - expect(activation).toBeGreaterThan(preparation.indexOf('await ensureSessionAuthenticated(page)')); + expect(activation).toBeGreaterThan( + preparation.indexOf('await ensureSessionAuthenticated(page)'), + ); expect(activation).toBeLessThan(preparation.indexOf('await createOrg(page')); expect(activation).toBeGreaterThan(0); expect(preparation).toContain('process.env.PULSE_E2E_OFFLINE_ACTIVATION_KEY'); diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 0073a4ab3..67110a4f0 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -514,7 +514,11 @@ describe('AppLayout navigation icons', () => { setViewportWidth(320); const { container } = renderLayout(); expect(container.querySelector('.header')).toHaveClass('flex-wrap', 'sm:flex-nowrap'); - expect(container.querySelector('.header-controls')).toHaveClass('max-w-full', 'flex-wrap', 'sm:flex-nowrap'); + expect(container.querySelector('.header-controls')).toHaveClass( + 'max-w-full', + 'flex-wrap', + 'sm:flex-nowrap', + ); expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument(); });