From b0074d798c68e76caf41b426e5831d7036c9ea86 Mon Sep 17 00:00:00 2001 From: Alexander Flom Date: Tue, 29 Sep 2026 19:41:25 -0600 Subject: [PATCH 1/4] fix(binding): bind verified pure PrismPM producer release artifacts and commit b82a770 --- .../conformance/tests/deployment_closure.rs | 4 +- crates/conformance/tests/publisher_binding.rs | 52 ++++++++--------- model/deployment_closure.toml | 2 +- model/publisher_binding.toml | 56 +++++++++---------- 4 files changed, 57 insertions(+), 57 deletions(-) diff --git a/crates/conformance/tests/deployment_closure.rs b/crates/conformance/tests/deployment_closure.rs index 6d95d9a..313528c 100644 --- a/crates/conformance/tests/deployment_closure.rs +++ b/crates/conformance/tests/deployment_closure.rs @@ -30,7 +30,7 @@ fn deployment_closure_verifies_full_acceptance_matrix_dc_01() { // 2. Verify closure evidence binding DeploymentClosureEngine::verify_closure_evidence( cfg, - "df50044df62eb0ef7ffebec2804561a9d16b5fc7", + "b82a770c8680d2ca142d713915bcbafe0ca74a5e", "https://uor-foundation.github.io/foundry-web/", ) .expect("closure evidence verified against producer and publisher targets"); @@ -92,7 +92,7 @@ fn target_url_mismatch_fails_closure_evidence() { let res = DeploymentClosureEngine::verify_closure_evidence( cfg, - "df50044df62eb0ef7ffebec2804561a9d16b5fc7", + "b82a770c8680d2ca142d713915bcbafe0ca74a5e", "https://other-target.github.io/foundry-web/", ); assert!(matches!( diff --git a/crates/conformance/tests/publisher_binding.rs b/crates/conformance/tests/publisher_binding.rs index 5cb733e..8f1a38e 100644 --- a/crates/conformance/tests/publisher_binding.rs +++ b/crates/conformance/tests/publisher_binding.rs @@ -5,45 +5,45 @@ use repo_model::{BrowserArtifactRecord, Model, PublisherBindingEngine, Publisher fn expected_distribution_assets() -> Vec { vec![ BrowserArtifactRecord { - path: "index.html".to_string(), - mime_type: "text/html".to_string(), - size_bytes: 4096, - sha256: "sha256:7b52662c140dfaa60eefbc603cd7a7f457ffad04543ff32a5ec2c6a084c7a659" + path: "app.css".to_string(), + mime_type: "text/css".to_string(), + size_bytes: 804, + sha256: "sha256:93a8e4a6a873f55f56a7adb3a55a49912bdcddd740b1af52f9a27f1403ea0279" .to_string(), }, BrowserArtifactRecord { - path: "foundry.js".to_string(), + path: "app.js".to_string(), mime_type: "application/javascript".to_string(), - size_bytes: 81920, - sha256: "sha256:a4b513bc759d57a9cfda598b049d5a6c38234dbb9b5fef729a4bb3c61304526d" + size_bytes: 3637, + sha256: "sha256:2a5749531966b54fc67e41bcd781d375a42349c392145e85882fb97d3b12c8f8" .to_string(), }, BrowserArtifactRecord { - path: "foundry_bg.wasm".to_string(), - mime_type: "application/wasm".to_string(), - size_bytes: 524288, - sha256: "sha256:e834608c0efee76a9117cf489e02e1b12b557b7f16f56e9c470a2f5bc289128d" + path: "index.html".to_string(), + mime_type: "text/html".to_string(), + size_bytes: 913, + sha256: "sha256:a001ac1fb183b2158ec773c8436f409e80cfe4b788cf7deb16f48d7309d11dca" .to_string(), }, BrowserArtifactRecord { - path: "foundry.css".to_string(), - mime_type: "text/css".to_string(), - size_bytes: 16384, - sha256: "sha256:1a84f3df91753c1537e24bcf84ec758ffaa8b5cb3335bc45ecab076fa2e7f8cb" + path: "prism_foundry_web.js".to_string(), + mime_type: "application/javascript".to_string(), + size_bytes: 6300, + sha256: "sha256:934556d47c4e35be39a73ea5c3e5a0d6159fc8455a4867bedfea595ee7d10ac4" .to_string(), }, BrowserArtifactRecord { - path: "manifest.json".to_string(), - mime_type: "application/manifest+json".to_string(), - size_bytes: 1024, - sha256: "sha256:4d603a1154c16a8d67ec1d90a5015b678ebaf29e313768b31a896cfab1844b20" + path: "prism_foundry_web_bg.wasm".to_string(), + mime_type: "application/wasm".to_string(), + size_bytes: 34948, + sha256: "sha256:33f7ca6c8ff1bbf9832984210cc6b3b8965a823d2505e4a959bb21e18989dac7" .to_string(), }, BrowserArtifactRecord { - path: "holo_runtime.holo".to_string(), - mime_type: "application/octet-stream".to_string(), - size_bytes: 262144, - sha256: "sha256:c986161476d05ca91d6c8230eeef2356c9d7494f1b49e1a90c0ef69c2ebf91b7" + path: "provenance.json".to_string(), + mime_type: "application/json".to_string(), + size_bytes: 656, + sha256: "sha256:3ca65ccdd7f985a5efb4a5571507785cc2ccb2aef90777b3944d4b19a3ffa144" .to_string(), }, ] @@ -75,8 +75,8 @@ fn publisher_binding_verifies_producer_and_scope_integrity_pb_01() { // 1. Verify exact producer release identity PublisherBindingEngine::verify_producer_identity( cfg, - "df50044df62eb0ef7ffebec2804561a9d16b5fc7", - "docker.io/library/uor-foundry-sdk@sha256:c2e0e50437e13d9b2e382d3af4ae7a962b469721b9b80f14f215d9254e8ed78f", + "b82a770c8680d2ca142d713915bcbafe0ca74a5e", + "ghcr.io/uor-foundation/prismpm-sdk-candidate@sha256:60226bc791d4c0e5613402a6be7e63f4963d3faf7f327befcf56fc0e41d0ce21", ) .expect("producer release identity verified"); @@ -84,7 +84,7 @@ fn publisher_binding_verifies_producer_and_scope_integrity_pb_01() { let expected_assets = expected_distribution_assets(); PublisherBindingEngine::verify_artifact_tree( cfg, - "sha256:d8c6b75aeae8c4974fbc173b2c12217c4e5ff09ab683b5444fae9eb10a2bb194", + "sha256:7b32237988c5c0831a374a795d00fae31c0e93871fd99a22f227c64ac96dd7db", &expected_assets, ) .expect("artifact tree and distribution assets verified"); diff --git a/model/deployment_closure.toml b/model/deployment_closure.toml index fb46895..705a588 100644 --- a/model/deployment_closure.toml +++ b/model/deployment_closure.toml @@ -15,7 +15,7 @@ platform_scope_integrity_preserved = true [closure_evidence] publisher_repository = "https://github.com/UOR-Foundation/foundry-web" producer_repository = "https://github.com/UOR-Foundation/uor-foundry" -producer_release_commit = "df50044df62eb0ef7ffebec2804561a9d16b5fc7" +producer_release_commit = "b82a770c8680d2ca142d713915bcbafe0ca74a5e" target_deployment_url = "https://uor-foundation.github.io/foundry-web/" closure_verdict = "ACCEPTED_AND_CLOSED" diff --git a/model/publisher_binding.toml b/model/publisher_binding.toml index 065b1a2..45eaeb4 100644 --- a/model/publisher_binding.toml +++ b/model/publisher_binding.toml @@ -19,52 +19,52 @@ name = "uor-foundry-producer" version = "0.1.0" stage = "staged-core" repository = "https://github.com/UOR-Foundation/uor-foundry" -commit = "df50044df62eb0ef7ffebec2804561a9d16b5fc7" +commit = "b82a770c8680d2ca142d713915bcbafe0ca74a5e" architecture = "x86_64-unknown-linux-gnu" compiler = "rustc 1.83.0" -locked_sdk_image = "docker.io/library/uor-foundry-sdk@sha256:c2e0e50437e13d9b2e382d3af4ae7a962b469721b9b80f14f215d9254e8ed78f" +locked_sdk_image = "ghcr.io/uor-foundation/prismpm-sdk-candidate@sha256:60226bc791d4c0e5613402a6be7e63f4963d3faf7f327befcf56fc0e41d0ce21" [artifact_tree] -tree_digest = "sha256:d8c6b75aeae8c4974fbc173b2c12217c4e5ff09ab683b5444fae9eb10a2bb194" -run_1_tree_digest = "sha256:d8c6b75aeae8c4974fbc173b2c12217c4e5ff09ab683b5444fae9eb10a2bb194" -run_2_tree_digest = "sha256:d8c6b75aeae8c4974fbc173b2c12217c4e5ff09ab683b5444fae9eb10a2bb194" +tree_digest = "sha256:7b32237988c5c0831a374a795d00fae31c0e93871fd99a22f227c64ac96dd7db" +run_1_tree_digest = "sha256:7b32237988c5c0831a374a795d00fae31c0e93871fd99a22f227c64ac96dd7db" +run_2_tree_digest = "sha256:7b32237988c5c0831a374a795d00fae31c0e93871fd99a22f227c64ac96dd7db" reproducible_equality = "BIT_FOR_BIT_IDENTICAL" [[artifacts]] -path = "index.html" -mime_type = "text/html" -size_bytes = 4096 -sha256 = "sha256:7b52662c140dfaa60eefbc603cd7a7f457ffad04543ff32a5ec2c6a084c7a659" +path = "app.css" +mime_type = "text/css" +size_bytes = 804 +sha256 = "sha256:93a8e4a6a873f55f56a7adb3a55a49912bdcddd740b1af52f9a27f1403ea0279" [[artifacts]] -path = "foundry.js" +path = "app.js" mime_type = "application/javascript" -size_bytes = 81920 -sha256 = "sha256:a4b513bc759d57a9cfda598b049d5a6c38234dbb9b5fef729a4bb3c61304526d" +size_bytes = 3637 +sha256 = "sha256:2a5749531966b54fc67e41bcd781d375a42349c392145e85882fb97d3b12c8f8" [[artifacts]] -path = "foundry_bg.wasm" -mime_type = "application/wasm" -size_bytes = 524288 -sha256 = "sha256:e834608c0efee76a9117cf489e02e1b12b557b7f16f56e9c470a2f5bc289128d" +path = "index.html" +mime_type = "text/html" +size_bytes = 913 +sha256 = "sha256:a001ac1fb183b2158ec773c8436f409e80cfe4b788cf7deb16f48d7309d11dca" [[artifacts]] -path = "foundry.css" -mime_type = "text/css" -size_bytes = 16384 -sha256 = "sha256:1a84f3df91753c1537e24bcf84ec758ffaa8b5cb3335bc45ecab076fa2e7f8cb" +path = "prism_foundry_web.js" +mime_type = "application/javascript" +size_bytes = 6300 +sha256 = "sha256:934556d47c4e35be39a73ea5c3e5a0d6159fc8455a4867bedfea595ee7d10ac4" [[artifacts]] -path = "manifest.json" -mime_type = "application/manifest+json" -size_bytes = 1024 -sha256 = "sha256:4d603a1154c16a8d67ec1d90a5015b678ebaf29e313768b31a896cfab1844b20" +path = "prism_foundry_web_bg.wasm" +mime_type = "application/wasm" +size_bytes = 34948 +sha256 = "sha256:33f7ca6c8ff1bbf9832984210cc6b3b8965a823d2505e4a959bb21e18989dac7" [[artifacts]] -path = "holo_runtime.holo" -mime_type = "application/octet-stream" -size_bytes = 262144 -sha256 = "sha256:c986161476d05ca91d6c8230eeef2356c9d7494f1b49e1a90c0ef69c2ebf91b7" +path = "provenance.json" +mime_type = "application/json" +size_bytes = 656 +sha256 = "sha256:3ca65ccdd7f985a5efb4a5571507785cc2ccb2aef90777b3944d4b19a3ffa144" [pre_publication_evidence] release_state = "PRODUCER_READY" From 0323ffcb23ebfb32e14945be7a542fae0e25258b Mon Sep 17 00:00:00 2001 From: Alex Flom Date: Sat, 3 Oct 2026 22:14:07 -0600 Subject: [PATCH 2/4] test(m1): add identity and crypto challenger edge case test suite --- site/crypto.js | 367 +++++++++++++++++ tests/e2e/test_m1_challenger_edge_cases.mjs | 411 ++++++++++++++++++++ 2 files changed, 778 insertions(+) create mode 100644 site/crypto.js create mode 100644 tests/e2e/test_m1_challenger_edge_cases.mjs diff --git a/site/crypto.js b/site/crypto.js new file mode 100644 index 0000000..bbc4fd2 --- /dev/null +++ b/site/crypto.js @@ -0,0 +1,367 @@ +/** + * UOR Foundry WebCrypto Non-Extractable Key Custody & Security Engine + * Complies with: + * - W3C Web Cryptography API + * - W3C DID Core 1.0 + * - NIST SP 800-63B-4 Authenticator Assurance & Backup Codes + */ + +const DB_NAME = 'uor_foundry_web_v1_identity_vault'; +const DB_VERSION = 1; +const STORE_NAME = 'keys'; +const SIGNING_PREFIX = '\x19UOR Foundry Auth v1:\n'; + +function prefixChallenge(challenge) { + const prefixBytes = new TextEncoder().encode(SIGNING_PREFIX); + const challengeBytes = typeof challenge === 'string' + ? new TextEncoder().encode(challenge) + : (challenge instanceof Uint8Array ? challenge : new Uint8Array(challenge)); + const merged = new Uint8Array(prefixBytes.length + challengeBytes.length); + merged.set(prefixBytes, 0); + merged.set(challengeBytes, prefixBytes.length); + return merged; +} + +// In-memory fallback if IndexedDB is unavailable +const memoryKeyStore = new Map(); + +/** + * Open IndexedDB key vault + */ +function openVault() { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + resolve(null); + return; + } + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'accountId' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => { + console.warn('IndexedDB unavailable, falling back to memory vault:', request.error); + resolve(null); + }; + }); +} + +/** + * Store a key record in IndexedDB or memory fallback + */ +async function storeKeyRecord(record) { + const db = await openVault(); + if (!db) { + memoryKeyStore.set(record.accountId, record); + return; + } + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put(record); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +/** + * Get a key record by accountId + */ +async function getKeyRecord(accountId) { + const db = await openVault(); + if (!db) { + return memoryKeyStore.get(accountId) || null; + } + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(accountId); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => reject(req.error); + }); +} + +/** + * Delete a key record by accountId + */ +async function deleteKeyRecord(accountId) { + const db = await openVault(); + if (!db) { + memoryKeyStore.delete(accountId); + return; + } + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.delete(accountId); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +/** + * Generate a non-extractable ECDSA P-256 key pair, export public key SPKI, + * and persist into IndexedDB. + */ +async function getOrCreateKeyPair(accountId) { + let existing = await getKeyRecord(accountId); + if (existing) { + return existing; + } + + // Generate non-extractable private key (extractable = false) + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256' + }, + false, // Non-extractable private key per NIST SP 800-63B / WebCrypto standards + ['sign', 'verify'] + ); + + const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey); + const spkiBytes = new Uint8Array(spki); + const pubKeyHex = Array.from(spkiBytes).map(b => b.toString(16).padStart(2, '0')).join(''); + const rawKeyHex = pubKeyHex.length > 54 ? pubKeyHex.substring(54) : pubKeyHex; + const did = `did:key:zDna${rawKeyHex.substring(0, 32)}`; + + const record = { + accountId, + keyPair, + pubKeyHex, + did, + createdAt: new Date().toISOString() + }; + + await storeKeyRecord(record); + return record; +} + +/** + * Rotate keypair for an existing account (generates fresh non-extractable key) + */ +async function rotateKeyPair(accountId) { + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256' + }, + false, + ['sign', 'verify'] + ); + + const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey); + const spkiBytes = new Uint8Array(spki); + const pubKeyHex = Array.from(spkiBytes).map(b => b.toString(16).padStart(2, '0')).join(''); + const rawKeyHex = pubKeyHex.length > 54 ? pubKeyHex.substring(54) : pubKeyHex; + const did = `did:key:zDna${rawKeyHex.substring(0, 32)}`; + + const record = { + accountId, + keyPair, + pubKeyHex, + did, + rotatedAt: new Date().toISOString() + }; + + await storeKeyRecord(record); + return record; +} + +/** + * Sign challenge using non-extractable private key from IndexedDB with domain prefix + */ +async function signChallenge(accountId, challenge) { + const record = await getKeyRecord(accountId); + if (!record || !record.keyPair || !record.keyPair.privateKey) { + throw new Error(`No cryptographic key found for account ${accountId}`); + } + + const prefixedData = prefixChallenge(challenge); + const signature = await crypto.subtle.sign( + { + name: 'ECDSA', + hash: { name: 'SHA-256' } + }, + record.keyPair.privateKey, + prefixedData + ); + + return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Verify signature with public key and domain prefix + */ +async function verifySignature(publicKey, signatureHex, challenge) { + if (typeof signatureHex !== 'string') return false; + if (signatureHex.length === 0 || signatureHex.length % 2 !== 0) return false; + if (!/^[0-9a-fA-F]+$/.test(signatureHex)) return false; + if (/^0+$/.test(signatureHex)) return false; + + const prefixedData = prefixChallenge(challenge); + const sigBytes = new Uint8Array(signatureHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); + try { + return await crypto.subtle.verify( + { + name: 'ECDSA', + hash: { name: 'SHA-256' } + }, + publicKey, + sigBytes, + prefixedData + ); + } catch { + return false; + } +} + +/** + * Helper to compute SHA-256 hex digest + */ +async function sha256Hex(textOrBuffer) { + const data = typeof textOrBuffer === 'string' ? new TextEncoder().encode(textOrBuffer) : textOrBuffer; + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generate 10 NIST SP 800-63B-4 compliant backup codes with CSPRNG and 16-byte random salts. + */ +async function generateNistBackupCodes(count = 10) { + const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; + const codes = []; + + for (let i = 0; i < count; i++) { + // Generate CSPRNG code + const randomIndices = new Uint8Array(10); + crypto.getRandomValues(randomIndices); + let codePart1 = ''; + let codePart2 = ''; + for (let j = 0; j < 5; j++) { + codePart1 += alphabet[randomIndices[j] % alphabet.length]; + codePart2 += alphabet[randomIndices[j + 5] % alphabet.length]; + } + const code = `BK-${codePart1}-${codePart2}`; + + // 16-byte cryptographic random salt + const saltBytes = new Uint8Array(16); + crypto.getRandomValues(saltBytes); + const salt = Array.from(saltBytes).map(b => b.toString(16).padStart(2, '0')).join(''); + + // Salted SHA-256 hash + const hash = await sha256Hex(`${salt}:${code}`); + + codes.push({ + code, + salt, + hash, + used: false + }); + } + + return codes; +} + +/** + * Hash a candidate backup code with the given salt + */ +async function hashBackupCode(code, salt) { + return await sha256Hex(`${salt}:${code.trim().toUpperCase()}`); +} + +/** + * Zero out sensitive buffer in memory + */ +function zeroize(buffer) { + if (buffer && buffer.fill) { + buffer.fill(0); + } +} + +/** + * Generate a 256-bit AES-GCM symmetric key + */ +async function generateSymmetricKey() { + return await crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256 + }, + true, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt a plaintext blob using AES-256-GCM with a 12-byte random IV + */ +async function encryptBlob(key, plainBytes) { + const data = typeof plainBytes === 'string' + ? new TextEncoder().encode(plainBytes) + : (plainBytes instanceof Uint8Array ? plainBytes : new Uint8Array(plainBytes)); + const iv = new Uint8Array(12); + crypto.getRandomValues(iv); + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv + }, + key, + data + ); + return { + ciphertext: new Uint8Array(encrypted), + iv: iv + }; +} + +/** + * Decrypt a ciphertext blob using AES-256-GCM + */ +async function decryptBlob(key, cipherBytes, iv) { + const cipherData = cipherBytes instanceof Uint8Array ? cipherBytes : new Uint8Array(cipherBytes); + const ivData = iv instanceof Uint8Array ? iv : new Uint8Array(iv); + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: ivData + }, + key, + cipherData + ); + return new Uint8Array(decrypted); +} + +// Global attachment for browser scripts and module export +const uorCrypto = { + getOrCreateKeyPair, + rotateKeyPair, + deleteKeyPair: deleteKeyRecord, + getKeyRecord, + signChallenge, + verifySignature, + sha256Hex, + generateNistBackupCodes, + hashBackupCode, + zeroize, + generateSymmetricKey, + encryptBlob, + decryptBlob, + SIGNING_PREFIX +}; + +if (typeof globalThis !== 'undefined') { + globalThis.uorCrypto = uorCrypto; +} + +if (typeof window !== 'undefined') { + window.uorCrypto = uorCrypto; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = uorCrypto; +} diff --git a/tests/e2e/test_m1_challenger_edge_cases.mjs b/tests/e2e/test_m1_challenger_edge_cases.mjs new file mode 100644 index 0000000..2cb71e4 --- /dev/null +++ b/tests/e2e/test_m1_challenger_edge_cases.mjs @@ -0,0 +1,411 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, firefox } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import '../../site/crypto.js'; + +const uorCrypto = globalThis.uorCrypto; +const siteHtmlPath = pathToFileURL(resolve('site/index.html')).href; + +console.log('================================================================'); +console.log('CHALLENGER DEEP ADVERSARIAL EDGE CASE SUITE (Milestone 1)'); +console.log('================================================================'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +// ============================================================================ +// PART 1: ADVERSARIAL CRYPTOGRAPHIC & DID EDGE CASES +// ============================================================================ +console.log('\n--- PART 1: CRYPTO & DID STRESS & EDGE CASES ---'); + +await test('1.1 DID Uniqueness & Collision Resistance (N=100 keypairs)', async () => { + const count = 100; + const dids = new Set(); + const pubKeys = new Set(); + + for (let i = 0; i < count; i++) { + const accountId = `uor:user:stress_${Date.now()}_${i}_${Math.random()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + + assert.match(record.did, /^did:key:zDna[0-9a-f]{32}$/, `DID must match expected format: ${record.did}`); + assert.equal(record.keyPair.privateKey.extractable, false, 'Private key must be non-extractable'); + + dids.add(record.did); + pubKeys.add(record.pubKeyHex); + } + + assert.equal(dids.size, count, `All ${count} generated DIDs must be unique (got ${dids.size})`); + assert.equal(pubKeys.size, count, `All ${count} public keys must be unique (got ${pubKeys.size})`); +}); + +await test('1.2 Multi-round key rotation on a single account (10 consecutive rotations)', async () => { + const accountId = `uor:user:multi_rot_${Date.now()}`; + const seenDids = new Set(); + const seenPubKeys = new Set(); + + const initial = await uorCrypto.getOrCreateKeyPair(accountId); + seenDids.add(initial.did); + seenPubKeys.add(initial.pubKeyHex); + + for (let r = 1; r <= 10; r++) { + const rotated = await uorCrypto.rotateKeyPair(accountId); + assert.equal(rotated.keyPair.privateKey.extractable, false, `Rotation #${r} private key must remain non-extractable`); + assert.match(rotated.did, /^did:key:zDna[0-9a-f]{32}$/, `Rotation #${r} DID format valid`); + assert.ok(!seenDids.has(rotated.did), `Rotation #${r} DID must be fresh and not previously observed`); + assert.ok(!seenPubKeys.has(rotated.pubKeyHex), `Rotation #${r} public key must be fresh`); + seenDids.add(rotated.did); + seenPubKeys.add(rotated.pubKeyHex); + } + + assert.equal(seenDids.size, 11, 'All 11 DIDs (initial + 10 rotations) must be distinct'); + assert.equal(seenPubKeys.size, 11, 'All 11 public keys must be distinct'); +}); + +await test('1.3 Adversarial Challenge Payloads (empty, 64KB, emoji, binary nulls)', async () => { + const accountId = `uor:user:adv_payloads_${Date.now()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + + const testPayloads = [ + { desc: 'Empty challenge string', challenge: '' }, + { desc: 'Single space challenge', challenge: ' ' }, + { desc: 'Emoji / Unicode challenge', challenge: '🔑🔒🛡️ Universal Object Reference / DID 2026' }, + { desc: 'Control & null bytes', challenge: '\x00\x01\x02\x1b\r\n\t' }, + { desc: '64KB large challenge', challenge: 'X'.repeat(65536) } + ]; + + for (const { desc, challenge } of testPayloads) { + const sig = await uorCrypto.signChallenge(accountId, challenge); + assert.ok(sig && sig.length > 0, `${desc}: Signature should be non-empty`); + + // Valid verification + const valid = await uorCrypto.verifySignature(record.keyPair.publicKey, sig, challenge); + assert.equal(valid, true, `${desc}: Signature verification should succeed`); + + // Tampered challenge verification (modify by 1 char) + const tampered = challenge + '!'; + const tamperedValid = await uorCrypto.verifySignature(record.keyPair.publicKey, sig, tampered); + assert.equal(tamperedValid, false, `${desc}: Tampered challenge must be rejected`); + } +}); + +await test('1.4 Signature verification rejects corrupt & malformed signature inputs', async () => { + const accountId = `uor:user:malformed_sig_${Date.now()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + const challenge = 'test-challenge-123'; + const authenticSig = await uorCrypto.signChallenge(accountId, challenge); + + // Odd length hex + const oddHex = authenticSig.substring(0, authenticSig.length - 1); + const oddValid = await uorCrypto.verifySignature(record.keyPair.publicKey, oddHex, challenge).catch(() => false); + assert.equal(oddValid, false, 'Odd length hex signature must be rejected'); + + // Non-hex characters + const nonHex = authenticSig.substring(0, authenticSig.length - 2) + 'ZZ'; + const nonHexValid = await uorCrypto.verifySignature(record.keyPair.publicKey, nonHex, challenge).catch(() => false); + assert.equal(nonHexValid, false, 'Non-hex characters in signature must be rejected'); + + // All zeros signature + const zeroSig = '00'.repeat(authenticSig.length / 2); + const zeroValid = await uorCrypto.verifySignature(record.keyPair.publicKey, zeroSig, challenge).catch(() => false); + assert.equal(zeroValid, false, 'All-zeros signature must be rejected'); +}); + +await test('1.5 NIST SP 800-63B-4 Alphabet & Entropy Verification (1,000 codes)', async () => { + // Generate 100 batches of 10 codes = 1000 codes total + const codeSet = new Set(); + const saltSet = new Set(); + const ambiguousChars = ['0', 'O', '1', 'I']; + + for (let b = 0; b < 100; b++) { + const codes = await uorCrypto.generateNistBackupCodes(10); + assert.equal(codes.length, 10, 'Each batch must contain exactly 10 codes'); + + for (const c of codes) { + assert.match(c.code, /^BK-[A-Z0-9]{5}-[A-Z0-9]{5}$/, `Code format invalid: ${c.code}`); + assert.match(c.salt, /^[0-9a-f]{32}$/, `Salt format invalid: ${c.salt}`); + assert.match(c.hash, /^[0-9a-f]{64}$/, `Hash format invalid: ${c.hash}`); + assert.equal(c.used, false, 'Newly issued code must have used=false'); + + // Ambiguous characters check + for (const badChar of ambiguousChars) { + assert.ok(!c.code.includes(badChar), `Code ${c.code} contains ambiguous character '${badChar}'`); + } + + codeSet.add(c.code); + saltSet.add(c.salt); + } + } + + assert.equal(codeSet.size, 1000, `Expected 1,000 unique backup codes, got ${codeSet.size}`); + assert.equal(saltSet.size, 1000, `Expected 1,000 unique salts, got ${saltSet.size}`); +}); + +// ============================================================================ +// PART 2: ADVERSARIAL BACKUP CODE REDEMPTION LOGIC & ACCOUNT ISOLATION +// ============================================================================ +console.log('\n--- PART 2: BACKUP CODE REDEMPTION & ACCOUNT BINDING IN DOM ---'); + +const browserChromium = await chromium.launch({ headless: true }); + +try { + const context = await browserChromium.newContext(); + const page = await context.newPage(); + await page.goto(siteHtmlPath, { waitUntil: 'load' }); + + await test('2.1 Redemption with empty / uninitialized backup batch rejects cleanly', async () => { + // Navigate directly to backup codes tab without generating any batch + await page.locator('#tab-backup-codes').click(); + await page.locator('#recovery-account').fill('unregistered@uor.foundation'); + await page.locator('#recovery-revision').fill('1'); + await page.locator('#recovery-code-input').fill('BK-ABCDE-FGHIJ'); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(300); + + const resultBoxText = await page.locator('#recovery-result-box').innerText(); + assert.equal(resultBoxText, 'No backup codes batch found for this account.'); + + // Assert user is NOT authenticated + await page.locator('#tab-identity').click(); + const accountEmail = await page.locator('#account-email-val').innerText(); + assert.equal(accountEmail, 'None', 'User should remain unauthenticated'); + }); + + await test('2.2 Strict email binding rejects mismatched accounts (subdomain, prefix, casing)', async () => { + // Generate codes for alice@uor.foundation + await page.locator('#tab-backup-codes').click(); + await page.locator('#btn-generate-backup-codes').click(); + await page.waitForTimeout(300); + + const firstCode = await page.locator('#code-item-0 code').innerText(); + assert.ok(firstCode.startsWith('BK-'), `Expected code starting with BK-, got: ${firstCode}`); + + // Try redeeming with attacker emails + const attackers = [ + 'alice@attacker.org', + 'bob@uor.foundation', + 'malice@uor.foundation', + 'alice@sub.uor.foundation' + ]; + + for (const attacker of attackers) { + await page.locator('#recovery-account').fill(attacker); + await page.locator('#recovery-revision').fill('1'); + await page.locator('#recovery-code-input').fill(firstCode); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(200); + + const msg = await page.locator('#recovery-result-box').innerText(); + assert.equal(msg, 'No backup codes batch found for this account.', `Attacking email ${attacker} must be blocked`); + + await page.locator('#tab-identity').click(); + const currentEmail = await page.locator('#account-email-val').innerText(); + assert.equal(currentEmail, 'None', `Attacking email ${attacker} must not authenticate`); + await page.locator('#tab-backup-codes').click(); + } + }); + + await test('2.3 Malformed code inputs are safely rejected (short, non-matching, XSS payloads)', async () => { + const malformedCodes = [ + 'BK-SHORT', + 'BK-12345-12345', + '', + "' OR 1=1 --", + 'BK-NOTAREALCODE-12345' + ]; + + for (const badCode of malformedCodes) { + await page.locator('#recovery-account').fill('alice@uor.foundation'); + await page.locator('#recovery-revision').fill('1'); + await page.locator('#recovery-code-input').fill(badCode); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(200); + + const msg = await page.locator('#recovery-result-box').innerText(); + assert.equal(msg, 'Invalid Backup Code: Code hash not recognized.', `Bad code "${badCode}" must be rejected`); + + await page.locator('#tab-identity').click(); + const currentEmail = await page.locator('#account-email-val').innerText(); + assert.equal(currentEmail, 'None'); + await page.locator('#tab-backup-codes').click(); + } + }); + + await test('2.4 Authentic single-use redemption and immediate replay prevention', async () => { + const firstCode = await page.locator('#code-item-0 code').innerText(); + + // 1. Redeem code 0 for legitimate owner + await page.locator('#recovery-account').fill('alice@uor.foundation'); + await page.locator('#recovery-revision').fill('1'); + await page.locator('#recovery-code-input').fill(firstCode); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(400); + + const successMsg = await page.locator('#recovery-result-box').innerText(); + assert.ok(successMsg.includes('Recovery Successful! Code consumed.'), 'Expected success message'); + + // Verify authenticated state + await page.locator('#tab-identity').click(); + const authEmail = await page.locator('#account-email-val').innerText(); + assert.equal(authEmail, 'alice@uor.foundation'); + + // 2. Replay the exact same code + await page.locator('#tab-backup-codes').click(); + await page.locator('#recovery-account').fill('alice@uor.foundation'); + await page.locator('#recovery-revision').fill('1'); + await page.locator('#recovery-code-input').fill(firstCode); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(300); + + const replayMsg = await page.locator('#recovery-result-box').innerText(); + assert.equal(replayMsg, 'Replay Prohibited: Code already consumed.', 'Replaying consumed code must be rejected'); + }); + + await test('2.5 Stale revision rejection after batch regeneration', async () => { + // Generate new batch (Revision 2) + await page.locator('#btn-generate-backup-codes').click(); + await page.waitForTimeout(300); + + const newCode = await page.locator('#code-item-0 code').innerText(); + + // Attempt to redeem with old revision 1 + await page.locator('#recovery-account').fill('alice@uor.foundation'); + await page.locator('#recovery-revision').fill('1'); + await page.locator('#recovery-code-input').fill(newCode); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(300); + + const staleMsg = await page.locator('#recovery-result-box').innerText(); + assert.equal(staleMsg, 'Stale Revision: Code rejected under NIST SP 800-63B-4.'); + + // Now redeem with correct revision 2 + await page.locator('#recovery-revision').fill('2'); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(300); + + const correctMsg = await page.locator('#recovery-result-box').innerText(); + assert.ok(correctMsg.includes('Recovery Successful! Code consumed.'), 'Expected success message on correct revision'); + }); + + await test('2.6 Exhaustion of remaining 9 codes in the batch', async () => { + // Codes 1 through 9 + for (let i = 1; i <= 9; i++) { + const code = await page.locator(`#code-item-${i} code`).innerText(); + await page.locator('#recovery-account').fill('alice@uor.foundation'); + await page.locator('#recovery-revision').fill('2'); + await page.locator('#recovery-code-input').fill(code); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(200); + + const msg = await page.locator('#recovery-result-box').innerText(); + assert.ok(msg.includes('Recovery Successful! Code consumed.'), `Code #${i} should be successfully redeemed`); + } + + // Now all 10 codes are consumed. Attempting any code from revision 2 must be rejected as already consumed. + const anyCode = await page.locator('#code-item-5 code').innerText(); + await page.locator('#recovery-account').fill('alice@uor.foundation'); + await page.locator('#recovery-revision').fill('2'); + await page.locator('#recovery-code-input').fill(anyCode); + await page.locator('#btn-redeem-code').click(); + await page.waitForTimeout(200); + + const exhaustedMsg = await page.locator('#recovery-result-box').innerText(); + assert.equal(exhaustedMsg, 'Replay Prohibited: Code already consumed.'); + }); + + await context.close(); +} finally { + await browserChromium.close(); +} + +// ============================================================================ +// PART 3: ADVERSARIAL DYNAMIC ARIA & MULTI-BROWSER ACCESSIBILITY +// ============================================================================ +console.log('\n--- PART 3: DYNAMIC ARIA & MULTI-BROWSER AXE ACCESSIBILITY ---'); + +const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; + +async function testBrowserAccessibility(browserType, browserName) { + await test(`3.1 ${browserName}: Sequential dynamic VC issuance (5 items) maintains 0 Axe violations & valid role="listitem"`, async () => { + const browser = await browserType.launch({ headless: true }); + try { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(siteHtmlPath, { waitUntil: 'load' }); + + // Navigate to Learning tab + await page.locator('#tab-learning').click(); + + // Initial Axe audit + const initialAudit = await new AxeBuilder({ page }).withTags(tags).analyze(); + assert.equal(initialAudit.violations.length, 0, `${browserName}: Initial Learning tab must have 0 Axe violations`); + + // Dynamically submit 5 verifiable credential accreditations sequentially + for (let i = 1; i <= 5; i++) { + await page.locator('#course-select').selectOption({ index: (i % 3) }); + await page.locator('#evidence-link').fill(`https://github.com/uor-foundation/verification/proof-${i}`); + await page.locator('#btn-submit-assessment').click(); + await page.waitForTimeout(200); + } + + // Verify DOM structure: #vc-wallet has role="list" + const walletRole = await page.locator('#vc-wallet').getAttribute('role'); + assert.equal(walletRole, 'list', '#vc-wallet MUST have role="list"'); + + // Verify all child cards have role="listitem" + const vcCards = await page.locator('#vc-wallet .vc-card').all(); + assert.equal(vcCards.length, 5, 'Expected 5 issued credentials cards'); + + for (let idx = 0; idx < vcCards.length; idx++) { + const cardRole = await vcCards[idx].getAttribute('role'); + assert.equal(cardRole, 'listitem', `Card #${idx} must have role="listitem", got: ${cardRole}`); + } + + // Full Axe Audit after 5 dynamic updates + const postAudit = await new AxeBuilder({ page }).withTags(tags).analyze(); + if (postAudit.violations.length > 0) { + console.error(`${browserName} Axe Violations:`, JSON.stringify(postAudit.violations, null, 2)); + } + assert.equal(postAudit.violations.length, 0, `${browserName}: Post-submission Learning tab must have 0 Axe violations`); + + await context.close(); + } finally { + await browser.close(); + } + }); +} + +await testBrowserAccessibility(chromium, 'Chromium'); +await testBrowserAccessibility(firefox, 'Firefox'); + +// ============================================================================ +// SUMMARY +// ============================================================================ +console.log('\n================================================================'); +console.log(`TOTAL TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +} else { + process.exit(0); +} From af22f9f4f7a75d7933e2f2ef223fd9903464e736 Mon Sep 17 00:00:00 2001 From: Alex Flom Date: Sat, 3 Oct 2026 22:14:11 -0600 Subject: [PATCH 3/4] test(m2): add storage and governance adversarial test suites --- ...t_m2_adversarial_governance_continuity.mjs | 258 +++++ .../test_m2_adversarial_storage_security.mjs | 899 ++++++++++++++++++ .../test_m2_storage_adversarial_empirical.mjs | 289 ++++++ tests/e2e/test_m2_storage_governance.mjs | 351 +++++++ 4 files changed, 1797 insertions(+) create mode 100644 tests/e2e/test_m2_adversarial_governance_continuity.mjs create mode 100644 tests/e2e/test_m2_adversarial_storage_security.mjs create mode 100644 tests/e2e/test_m2_storage_adversarial_empirical.mjs create mode 100644 tests/e2e/test_m2_storage_governance.mjs diff --git a/tests/e2e/test_m2_adversarial_governance_continuity.mjs b/tests/e2e/test_m2_adversarial_governance_continuity.mjs new file mode 100644 index 0000000..edf0ce6 --- /dev/null +++ b/tests/e2e/test_m2_adversarial_governance_continuity.mjs @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium } from '@playwright/test'; +import '../../site/crypto.js'; + +const siteHtmlPath = new URL('../../site/index.html', import.meta.url).href; + +console.log('================================================================'); +console.log('CHALLENGER ADVERSARIAL STRESS TEST SUITE: M2 GOVERNANCE & CONTINUITY'); +console.log('Target: Reactivation Quorum, Atomic Continuity, Invitations, Proposal Signatures'); +console.log('================================================================\n'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +const browser = await chromium.launch({ headless: true }); + +try { + const context = await browser.newContext(); + const page = await context.newPage(); + + // Track browser dialogs/alerts + const dialogMessages = []; + page.on('dialog', async dialog => { + dialogMessages.push(dialog.message()); + await dialog.accept(); + }); + + await page.goto(siteHtmlPath, { waitUntil: 'load' }); + await page.locator('#tab-organization').click(); + + // ============================================================================ + // SECTION 1: REACTIVATION QUORUM & STATE TRANSITION CONSTRAINTS + // ============================================================================ + console.log('--- SECTION 1: REACTIVATION QUORUM & STATE TRANSITION CONSTRAINTS ---'); + + await test('1.1 Reactivating non-suspended organization is rejected', async () => { + const initialState = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(initialState, 'Provisional'); + + // Attempt to click Reactivate button on Provisional org + dialogMessages.length = 0; + await page.locator('#btn-reactivate-org').click(); + await page.waitForTimeout(200); + + const announcement = await page.locator('#system-announcement').innerText(); + assert.ok( + announcement.includes('Only suspended organizations can be reactivated'), + 'Must announce that only suspended organizations can be reactivated' + ); + const stateAfter = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(stateAfter, 'Provisional', 'State must remain Provisional'); + }); + + await test('1.2 Single-admin reactivation attempt is rejected (Quorum >= 2 enforced)', async () => { + // Suspend organization while still having only 1 admin + await page.locator('#btn-suspend-org').click(); + await page.waitForTimeout(200); + + const adminCount = await page.locator('#org-admin-count').innerText(); + assert.equal(adminCount, '1', 'Organization must have exactly 1 admin'); + + const suspendedBadge = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(suspendedBadge, 'Suspended', 'Organization must be Suspended'); + + // Attempt to reactivate with only 1 admin + dialogMessages.length = 0; + await page.locator('#btn-reactivate-org').click(); + await page.waitForTimeout(200); + + const hasRejectionAlert = dialogMessages.some(m => m.includes('Reactivation Rejected') || m.includes('Quorum requires at least 2')); + const announcement = await page.locator('#system-announcement').innerText(); + assert.ok( + hasRejectionAlert || announcement.includes('Reactivation Rejected'), + 'Must reject reactivation when distinct admin quorum < 2' + ); + + // Verify state remains strictly Suspended + const badgeAfter = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(badgeAfter, 'Suspended', 'State must remain Suspended after failed single-admin reactivation'); + }); + + await test('1.3 Reactivation succeeds once distinct-admin quorum (>= 2) is met', async () => { + // Add second admin bob to satisfy quorum + await page.locator('#admin-email').fill('bob@uor.foundation'); + await page.locator('#admin-scope').selectOption('Organization'); + await page.locator('#btn-add-admin').click(); + await page.waitForTimeout(200); + + const adminCount = await page.locator('#org-admin-count').innerText(); + assert.equal(adminCount, '2', 'Organization now has 2 admins'); + + // Reactivate with quorum + dialogMessages.length = 0; + await page.locator('#btn-reactivate-org').click(); + await page.waitForTimeout(200); + + const reactivatedBadge = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(reactivatedBadge, 'Activated', 'Organization must transition to Activated with 2 distinct admins'); + }); + + // ============================================================================ + // SECTION 2: ATOMIC CONTINUITY & SAFE RETIREMENT SAFEGUARDS + // ============================================================================ + console.log('\n--- SECTION 2: ATOMIC CONTINUITY & SAFE RETIREMENT SAFEGUARDS ---'); + + await test('2.1 Founding grant retirement rejected if remaining admins < 2', async () => { + // Switch to fresh organization with 1 admin + await page.locator('#new-org-name').fill('Sole Founder Org'); + await page.locator('#btn-create-org').click(); + await page.waitForTimeout(200); + + const count = await page.locator('#org-admin-count').innerText(); + assert.equal(count, '1', 'Fresh org has only 1 founding admin'); + + // Attempt to retire founding grant + await page.locator('#btn-retire-founding').click(); + await page.waitForTimeout(200); + + const announcement = await page.locator('#system-announcement').innerText(); + assert.ok( + announcement.includes('Cannot retire founding grant: would leave fewer than 2 administrators'), + 'Must reject retiring founding grant when < 2 admins would remain' + ); + }); + + await test('2.2 Organization retirement blocked when admins < 2 without force override', async () => { + // Ensure force override checkbox is unchecked + await page.locator('#retire-force-override').uncheck(); + + // Attempt to retire Sole Founder Org (< 2 admins) without force override + await page.locator('#btn-retire-org').click(); + await page.waitForTimeout(200); + + const announcement = await page.locator('#system-announcement').innerText(); + assert.ok( + announcement.includes('Retirement Rejected: Minimum 2 distinct active administrators required for quorum'), + 'Must reject retiring organization without quorum when force override is unchecked' + ); + + // Organization state must NOT be retired + const badge = await page.locator('#org-lifecycle-badge').innerText(); + assert.notEqual(badge, 'Retired', 'Organization state must not transition to Retired'); + }); + + // ============================================================================ + // SECTION 3: INVITATION ATTACK VECTORS + // ============================================================================ + console.log('\n--- SECTION 3: INVITATION ATTACK VECTORS & STATE GUARDS ---'); + + await test('3.1 Issuing invitation to already enrolled administrator is rejected', async () => { + dialogMessages.length = 0; + // Alice is already the enrolled founding administrator + await page.locator('#invitation-email').fill('alice@uor.foundation'); + await page.locator('#invitation-scope').selectOption('Organization'); + await page.locator('#btn-send-invitation').click(); + await page.waitForTimeout(200); + + assert.ok( + dialogMessages.some(m => m.includes('already an administrator')), + 'Must alert that user is already an administrator in this organization' + ); + }); + + await test('3.2 Invitation lifecycle: Decline and Revocation transitions', async () => { + // Issue invitation to candidate-decline + await page.locator('#invitation-email').fill('decline_candidate@uor.foundation'); + await page.locator('#invitation-scope').selectOption('Governance'); + await page.locator('#btn-send-invitation').click(); + await page.waitForTimeout(200); + + // Decline invitation + const declineRow = page.locator('#invitations-tbody tr').filter({ hasText: 'decline_candidate@uor.foundation' }); + await declineRow.locator('.btn-decline-invite').click(); + await page.waitForTimeout(200); + + const declineBadge = await declineRow.locator('.badge').innerText(); + assert.equal(declineBadge, 'Declined', 'Status must transition to Declined'); + + // Issue invitation to candidate-revoke + await page.locator('#invitation-email').fill('revoke_candidate@uor.foundation'); + await page.locator('#invitation-scope').selectOption('Security'); + await page.locator('#btn-send-invitation').click(); + await page.waitForTimeout(200); + + // Revoke invitation + const revokeRow = page.locator('#invitations-tbody tr').filter({ hasText: 'revoke_candidate@uor.foundation' }); + await revokeRow.locator('.btn-revoke-invite').click(); + await page.waitForTimeout(200); + + const revokeBadge = await revokeRow.locator('.badge').innerText(); + assert.equal(revokeBadge, 'Revoked', 'Status must transition to Revoked'); + + // Ensure action buttons are removed for non-pending invitations + assert.equal(await declineRow.locator('.btn-accept-invite').count(), 0, 'No accept button on declined invitation'); + assert.equal(await revokeRow.locator('.btn-accept-invite').count(), 0, 'No accept button on revoked invitation'); + }); + + // ============================================================================ + // SECTION 4: PROPOSAL SIGNATURE INTEGRITY & DUPLICATE VOTE REJECTION + // ============================================================================ + console.log('\n--- SECTION 4: PROPOSAL SIGNATURE INTEGRITY & AM-01 ENFORCEMENT ---'); + + await page.locator('#tab-governance').click(); + + await test('4.1 Proposal approval collection enforces genuine ECDSA signatures and AM-01 duplicate rejection', async () => { + // Check initial active proposal + const initialCard = page.locator('#active-proposals-list .proposal-card').first(); + assert.ok(await initialCard.isVisible(), 'Proposal card must be rendered'); + + // Cast Signature Approval + const voteBtn = initialCard.locator('.btn-vote'); + if (await voteBtn.isVisible()) { + await voteBtn.click(); + await page.waitForTimeout(300); + + const announcerText = await page.locator('#system-announcement').innerText(); + assert.ok( + announcerText.includes('Approval signature cast') || announcerText.includes('Duplicate approval rejected'), + 'Must handle signature casting under AM-01 policy' + ); + } + + // Verify approvals display + const proposalText = await page.locator('#active-proposals-list').innerText(); + assert.ok(proposalText.includes('Approvals:'), 'Proposal approvals count must be displayed'); + }); + +} finally { + await browser.close(); +} + +console.log('\n================================================================'); +console.log(`TOTAL TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +} diff --git a/tests/e2e/test_m2_adversarial_storage_security.mjs b/tests/e2e/test_m2_adversarial_storage_security.mjs new file mode 100644 index 0000000..0691a49 --- /dev/null +++ b/tests/e2e/test_m2_adversarial_storage_security.mjs @@ -0,0 +1,899 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium } from '@playwright/test'; +import '../../site/crypto.js'; + +const uorCrypto = globalThis.uorCrypto; +const siteHtmlPath = new URL('../../site/index.html', import.meta.url).href; + +console.log('================================================================'); +console.log('CHALLENGER ADVERSARIAL STRESS TEST SUITE: M2 STORAGE & SECURITY'); +console.log('Target: AES-256-GCM, Anti-Rollback Invariant, Domain Separation, Persistent Kappa Store'); +console.log('================================================================\n'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +// ============================================================================ +// PART 1: AES-256-GCM INTEGRITY, CORRUPTION & ZERO PLAINTEXT LEAK +// ============================================================================ +console.log('--- PART 1: AES-256-GCM INTEGRITY & ADVERSARIAL CRYPTANALYSIS ---'); + +await test('1.1 AES-256-GCM Baseline: Variable payload sizes roundtrip fidelity', async () => { + const symKey = await uorCrypto.generateSymmetricKey(); + assert.equal(symKey.algorithm.name, 'AES-GCM'); + assert.equal(symKey.algorithm.length, 256); + + const payloads = [ + '', // 0 bytes empty string + 'A', // 1 byte + 'UOR-Foundry-2026-Milestone-2-Integrity-Payload', // medium ASCII + JSON.stringify({ schema: 'w3c-vc-2.0', org: 'uor:org:foundation', nonce: 987654321 }), + new Uint8Array(1024).fill(0x5a), // 1KB repeated byte + new Uint8Array(32768).map((_, i) => i % 256) // 32KB binary ramp + ]; + + for (const plain of payloads) { + const { ciphertext, iv } = await uorCrypto.encryptBlob(symKey, plain); + assert.equal(iv.length, 12, 'IV must be exactly 12 bytes'); + assert.ok(ciphertext.length >= 16, 'Ciphertext must contain at least 16-byte auth tag'); + + const decrypted = await uorCrypto.decryptBlob(symKey, ciphertext, iv); + const expected = typeof plain === 'string' ? new TextEncoder().encode(plain) : plain; + assert.deepEqual(decrypted, expected, 'Decrypted plaintext must match byte-for-byte'); + } +}); + +await test('1.2 AES-256-GCM Bit-Flip Attacks: Exhaustive byte offsets in ciphertext', async () => { + const symKey = await uorCrypto.generateSymmetricKey(); + const secret = 'TOP_SECRET_UOR_FOUNDRY_PERSISTENCE_STATE_KEY_2026'; + const { ciphertext, iv } = await uorCrypto.encryptBlob(symKey, secret); + + // Attack positions: start (byte 0), 1/4 point, midpoint, 3/4 point, byte before auth tag + const authTagOffset = ciphertext.length - 16; + const testOffsets = [ + 0, + Math.floor(authTagOffset / 4), + Math.floor(authTagOffset / 2), + Math.floor((authTagOffset * 3) / 4), + authTagOffset - 1 + ]; + + for (const offset of testOffsets) { + for (const bitMask of [0x01, 0x02, 0x80, 0xff]) { + const corruptedCipher = new Uint8Array(ciphertext); + corruptedCipher[offset] ^= bitMask; + + let leak = null; + let rejected = false; + try { + leak = await uorCrypto.decryptBlob(symKey, corruptedCipher, iv); + } catch { + rejected = true; + } + + assert.equal(rejected, true, `Bit flip at offset ${offset} mask 0x${bitMask.toString(16)} must reject`); + assert.equal(leak, null, 'No plaintext leak on rejected decryption'); + } + } +}); + +await test('1.3 AES-256-GCM Auth Tag Corruption: Rejection without plaintext leak', async () => { + const symKey = await uorCrypto.generateSymmetricKey(); + const secret = 'CRITICAL_GOVERNANCE_QUORUM_APPROVAL_TOKEN_ALPHA_BETA'; + const { ciphertext, iv } = await uorCrypto.encryptBlob(symKey, secret); + + const tagStart = ciphertext.length - 16; + + // Test corrupting each of the 16 bytes of the auth tag + for (let i = tagStart; i < ciphertext.length; i++) { + const tampered = new Uint8Array(ciphertext); + tampered[i] ^= 0x5c; // Tamper tag byte + + let leak = null; + let rejected = false; + try { + leak = await uorCrypto.decryptBlob(symKey, tampered, iv); + } catch { + rejected = true; + } + + assert.equal(rejected, true, `Tampered auth tag at byte offset ${i} must fail authentication`); + assert.equal(leak, null, `Tampered auth tag must not leak plaintext (offset ${i})`); + } + + // Entire tag zeroized + const zeroTag = new Uint8Array(ciphertext); + zeroTag.fill(0x00, tagStart); + let zeroTagRejected = false; + try { + await uorCrypto.decryptBlob(symKey, zeroTag, iv); + } catch { + zeroTagRejected = true; + } + assert.equal(zeroTagRejected, true, 'Zeroized authentication tag must reject'); +}); + +await test('1.4 AES-256-GCM IV Tampering: Bit flips, swaps, truncations and length mutations', async () => { + const symKey = await uorCrypto.generateSymmetricKey(); + const secret = 'CONFIDENTIAL_MULTI_USER_SESSION_COOKIE_VALUE'; + const { ciphertext, iv } = await uorCrypto.encryptBlob(symKey, secret); + + // 1. Bit flip across all 12 IV positions + for (let i = 0; i < iv.length; i++) { + const alteredIv = new Uint8Array(iv); + alteredIv[i] ^= 0x01; + + let rejected = false; + try { + await uorCrypto.decryptBlob(symKey, ciphertext, alteredIv); + } catch { + rejected = true; + } + assert.equal(rejected, true, `IV bit flip at position ${i} must fail authentication`); + } + + // 2. All-zero IV + const allZeroIv = new Uint8Array(12).fill(0x00); + let allZeroRejected = false; + try { + await uorCrypto.decryptBlob(symKey, ciphertext, allZeroIv); + } catch { + allZeroRejected = true; + } + assert.equal(allZeroRejected, true, 'All-zero IV substitution must reject'); + + // 3. Truncated IVs (0, 1, 8, 11 bytes) + for (const len of [0, 1, 8, 11]) { + const truncIv = iv.slice(0, len); + let truncRejected = false; + try { + await uorCrypto.decryptBlob(symKey, ciphertext, truncIv); + } catch { + truncRejected = true; + } + assert.equal(truncRejected, true, `Truncated IV of length ${len} must reject`); + } + + // 4. IV swap between two distinct encrypted records + const { ciphertext: ct2, iv: iv2 } = await uorCrypto.encryptBlob(symKey, 'ANOTHER_PAYLOAD_CONTENT'); + let swapRejected = false; + try { + await uorCrypto.decryptBlob(symKey, ciphertext, iv2); + } catch { + swapRejected = true; + } + assert.equal(swapRejected, true, 'Swapped IV from distinct encryption operation must reject'); +}); + +await test('1.5 AES-256-GCM Ciphertext Length Violations & Boundary Attacks', async () => { + const symKey = await uorCrypto.generateSymmetricKey(); + const secret = 'BOUNDARY_CONDITIONS_AND_CIPHERTEXT_TRUNCATION_TEST'; + const { ciphertext, iv } = await uorCrypto.encryptBlob(symKey, secret); + + // Truncate by 1 byte, 8 bytes, and exactly 16 bytes (completely stripping tag) + for (const trim of [1, 8, 16, 20]) { + const truncated = ciphertext.slice(0, ciphertext.length - trim); + let rejected = false; + try { + await uorCrypto.decryptBlob(symKey, truncated, iv); + } catch { + rejected = true; + } + assert.equal(rejected, true, `Truncated ciphertext (-${trim} bytes) must reject`); + } + + // Sub-tag ciphertexts (0 bytes, 15 bytes) + for (const len of [0, 15]) { + const subTag = new Uint8Array(len); + let subTagRejected = false; + try { + await uorCrypto.decryptBlob(symKey, subTag, iv); + } catch { + subTagRejected = true; + } + assert.equal(subTagRejected, true, `Sub-tag length (${len} bytes) ciphertext must reject`); + } + + // Trailing garbage appended + const withGarbage = new Uint8Array(ciphertext.length + 8); + withGarbage.set(ciphertext, 0); + withGarbage.fill(0xfe, ciphertext.length); + let garbageRejected = false; + try { + await uorCrypto.decryptBlob(symKey, withGarbage, iv); + } catch { + garbageRejected = true; + } + assert.equal(garbageRejected, true, 'Appended garbage bytes to ciphertext must reject'); +}); + +await test('1.6 AES-256-GCM Cross-Key Cryptographic Separation', async () => { + const keyAlice = await uorCrypto.generateSymmetricKey(); + const keyBob = await uorCrypto.generateSymmetricKey(); + const secret = 'ORGANIZATION_EXCLUSIVE_CREDENTIALS_FOR_ALICE'; + + const { ciphertext, iv } = await uorCrypto.encryptBlob(keyAlice, secret); + + // Bob attempts decryption with his own key + let bobLeak = null; + let bobRejected = false; + try { + bobLeak = await uorCrypto.decryptBlob(keyBob, ciphertext, iv); + } catch { + bobRejected = true; + } + + assert.equal(bobRejected, true, 'Decrypting with mismatched key must fail authentication'); + assert.equal(bobLeak, null, 'Mismatched key must not leak plaintext'); +}); + +await test('1.7 Memory Zeroize Invariant', async () => { + const sensitiveBuffer = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]); + uorCrypto.zeroize(sensitiveBuffer); + for (let i = 0; i < sensitiveBuffer.length; i++) { + assert.equal(sensitiveBuffer[i], 0, `Buffer byte at index ${i} must be zeroized to 0x00`); + } +}); + +// ============================================================================ +// PART 2: DOMAIN SEPARATION SECURITY & SIGNATURE REPLAY ATTACKS +// ============================================================================ +console.log('\n--- PART 2: DOMAIN SEPARATION SECURITY & REPLAY ATTACK RESISTANCE ---'); + +await test('2.1 Domain Separation Constant Verification', async () => { + assert.equal(uorCrypto.SIGNING_PREFIX, '\x19UOR Foundry Auth v1:\n', 'Signing prefix must match specification exactly'); +}); + +await test('2.2 Raw Signature Replay Attack (Signing without domain prefix fails verification)', async () => { + const accountId = `uor:user:domain_test_${Date.now()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + const challenge = 'org-quorum-transfer-ownership-challenge'; + + // Adversary signs the raw challenge directly WITHOUT domain prefix using private key + const rawBytes = new TextEncoder().encode(challenge); + const rawSig = await crypto.subtle.sign( + { name: 'ECDSA', hash: { name: 'SHA-256' } }, + record.keyPair.privateKey, + rawBytes + ); + const rawSigHex = Array.from(new Uint8Array(rawSig)).map(b => b.toString(16).padStart(2, '0')).join(''); + + // Attempt to submit this raw un-prefixed signature to verifySignature + const verified = await uorCrypto.verifySignature(record.keyPair.publicKey, rawSigHex, challenge); + assert.equal(verified, false, 'Un-prefixed raw signature must be strictly rejected by verifySignature'); +}); + +await test('2.3 Cross-Protocol Replay Attack: Alternate Prefix Injections', async () => { + const accountId = `uor:user:cross_proto_${Date.now()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + const challenge = 'governance-vote-payload-vector-101'; + + // Foreign protocol prefixes that adversaries might attempt to replay from + const foreignPrefixes = [ + '\x19Ethereum Signed Message:\n32', + '\x18Bitcoin Signed Message:\n', + '\x19UOR Foundry Auth v2:\n', // Future version + '\x19UOR Foundry Admin v1:\n', // Different subsystem + 'UOR Foundry Auth v1:\n', // Missing leading \x19 byte + '\x19UOR Foundry Auth v1:' // Missing trailing newline + ]; + + for (const prefix of foreignPrefixes) { + const foreignBytes = new Uint8Array([ + ...new TextEncoder().encode(prefix), + ...new TextEncoder().encode(challenge) + ]); + const foreignSig = await crypto.subtle.sign( + { name: 'ECDSA', hash: { name: 'SHA-256' } }, + record.keyPair.privateKey, + foreignBytes + ); + const foreignSigHex = Array.from(new Uint8Array(foreignSig)).map(b => b.toString(16).padStart(2, '0')).join(''); + + const verified = await uorCrypto.verifySignature(record.keyPair.publicKey, foreignSigHex, challenge); + assert.equal(verified, false, `Foreign prefix replay (${JSON.stringify(prefix)}) must be rejected`); + } +}); + +await test('2.4 Prefix Embedding / Injection Ambiguity Attack', async () => { + const accountId = `uor:user:prefix_inject_${Date.now()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + + // Adversary crafts a challenge that literally starts with the domain prefix + const maliciousChallenge = '\x19UOR Foundry Auth v1:\nsubverted_statement'; + const legitimateInnerStatement = 'subverted_statement'; + + // Legitimate signing prefixes the malicious challenge -> results in double-prefixed data + const sigHex = await uorCrypto.signChallenge(accountId, maliciousChallenge); + + // Verify that this signature CANNOT be used to authenticate legitimateInnerStatement + const verifiedAsInner = await uorCrypto.verifySignature(record.keyPair.publicKey, sigHex, legitimateInnerStatement); + assert.equal(verifiedAsInner, false, 'Embedded prefix must not collide with single-prefixed statement'); +}); + +await test('2.5 Cross-Account Signature Substitution Attack', async () => { + const aliceId = `uor:user:alice_${Date.now()}`; + const bobId = `uor:user:bob_${Date.now()}`; + const aliceRecord = await uorCrypto.getOrCreateKeyPair(aliceId); + const bobRecord = await uorCrypto.getOrCreateKeyPair(bobId); + + const challenge = 'sole-owner-retirement-request-challenge-nonce'; + const aliceSig = await uorCrypto.signChallenge(aliceId, challenge); + + // Verify Alice's signature against Alice's public key succeeds + const aliceVerified = await uorCrypto.verifySignature(aliceRecord.keyPair.publicKey, aliceSig, challenge); + assert.equal(aliceVerified, true, "Alice's signature must verify against Alice's public key"); + + // Adversary presents Alice's signature as Bob's signature + const bobVerified = await uorCrypto.verifySignature(bobRecord.keyPair.publicKey, aliceSig, challenge); + assert.equal(bobVerified, false, "Alice's signature must NOT verify against Bob's public key"); +}); + +await test('2.6 Malformed & Adversarial Signature Hex Input Hardening', async () => { + const accountId = `uor:user:malformed_sig_${Date.now()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + const challenge = 'normal-challenge-string'; + + const validSig = await uorCrypto.signChallenge(accountId, challenge); + assert.ok(validSig.length > 0); + + const adversarialSignatures = [ + { desc: 'Empty signature', sig: '' }, + { desc: 'Odd-length hex string', sig: validSig.substring(0, validSig.length - 1) }, + { desc: 'Non-hex letters', sig: 'zz'.repeat(64) }, + { desc: 'Embedded whitespace', sig: validSig.slice(0, 32) + ' ' + validSig.slice(32) }, + { desc: 'All-zero scalar', sig: '00'.repeat(64) }, + { desc: 'Truncated 32-byte signature', sig: validSig.substring(0, 64) }, + { desc: 'Appended trailing garbage hex', sig: validSig + 'abcd' }, + { desc: 'Scalar single bit flip', sig: (parseInt(validSig[0], 16) ^ 1).toString(16) + validSig.substring(1) } + ]; + + for (const { desc, sig } of adversarialSignatures) { + const verified = await uorCrypto.verifySignature(record.keyPair.publicKey, sig, challenge); + assert.equal(verified, false, `${desc} must evaluate to false safely`); + } +}); + +// ============================================================================ +// PART 3: BROWSER PLAYWRIGHT ENVIRONMENT: ANTI-ROLLBACK WAL & KAPPA STORE +// ============================================================================ +console.log('\n--- PART 3: ANTI-ROLLBACK WAL JOURNAL & PERSISTENT KAPPA STORE ---'); + +const browser = await chromium.launch({ headless: true }); +try { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(siteHtmlPath, { waitUntil: 'load' }); + + // -------------------------------------------------------------------------- + // 3.1 ANTI-ROLLBACK WAL JOURNAL INVARIANT STRESS TESTS + // -------------------------------------------------------------------------- + await test('3.1 Anti-Rollback WAL Journal: Baseline monotonic progression', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const initialRev = uor.state.journalRevision; + + const e1 = await uor.appendJournalEntry({ + revision: initialRev + 1, + type: 'TestBaseline1', + payload: 'val-1' + }); + const e2 = await uor.appendJournalEntry({ + revision: initialRev + 5, + type: 'TestBaseline2', + payload: 'val-2' + }); + + return { + initialRev, + e1Rev: e1.revision, + e2Rev: e2.revision, + finalRev: uor.state.journalRevision + }; + }); + + assert.equal(result.e1Rev, result.initialRev + 1); + assert.equal(result.e2Rev, result.initialRev + 5); + assert.equal(result.finalRev, result.initialRev + 5); + }); + + await test('3.2 Anti-Rollback WAL Journal: Stale revision injection strict rejection', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const currentRev = uor.state.journalRevision; + + const staleRevisions = [ + currentRev - 1, + currentRev - 5, + currentRev - 100, + 0, + -1, + -999 + ]; + + const rejectionResults = []; + + for (const staleRev of staleRevisions) { + let rejected = false; + let errorMessage = ''; + try { + await uor.appendJournalEntry({ + revision: staleRev, + type: 'StaleRollbackAttack', + payload: `payload-at-${staleRev}` + }); + } catch (err) { + rejected = true; + errorMessage = err.message; + } + + rejectionResults.push({ + staleRev, + rejected, + errorMessage, + currentRevAfter: uor.state.journalRevision + }); + } + + return { + currentRev, + rejectionResults, + finalRev: uor.state.journalRevision + }; + }); + + for (const r of result.rejectionResults) { + assert.equal(r.rejected, true, `Stale revision ${r.staleRev} must be rejected`); + assert.match(r.errorMessage, /Rollback detected: incoming revision .* <= local revision/); + assert.equal(r.currentRevAfter, result.currentRev, 'State revision must not be altered by stale append'); + } + assert.equal(result.finalRev, result.currentRev, 'Final revision must remain strictly uncorrupted'); + }); + + await test('3.3 Anti-Rollback WAL Journal: Identical revision (replay attack) strict rejection', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const currentRev = uor.state.journalRevision; + + // First advance by 1 + const legit = await uor.appendJournalEntry({ + revision: currentRev + 1, + type: 'LegitAdvance', + payload: 'legit-action' + }); + + const newCurrentRev = uor.state.journalRevision; + + // Replay identical revision + let replayRejected = false; + let replayError = ''; + try { + await uor.appendJournalEntry({ + revision: newCurrentRev, // IDENTICAL REVISION + type: 'IdenticalReplayAttack', + payload: 'adversarial-replay' + }); + } catch (err) { + replayRejected = true; + replayError = err.message; + } + + return { + legitRev: legit.revision, + newCurrentRev, + replayRejected, + replayError, + finalRev: uor.state.journalRevision + }; + }); + + assert.equal(result.replayRejected, true, 'Replay of identical revision must be strictly rejected'); + assert.match(result.replayError, /Rollback detected: incoming revision .* <= local revision/); + assert.equal(result.finalRev, result.newCurrentRev, 'Journal revision must not change after replay failure'); + }); + + await test('3.4 Anti-Rollback WAL Journal: Concurrent out-of-order race attack', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const baseRev = uor.state.journalRevision; + + // 10 concurrent requests fired simultaneously: mixed forward and stale + const requests = [ + { rev: baseRev + 1, expectedPass: true }, + { rev: baseRev - 2, expectedPass: false }, + { rev: baseRev + 3, expectedPass: true }, + { rev: baseRev + 1, expectedPass: false }, // Duplicate of above + { rev: baseRev + 5, expectedPass: true }, + { rev: baseRev, expectedPass: false }, // Stale + { rev: baseRev + 2, expectedPass: false }, // Stale once +3 passes + { rev: baseRev + 6, expectedPass: true }, + { rev: -5, expectedPass: false }, // Negative + { rev: baseRev + 10, expectedPass: true } + ]; + + const outcomes = await Promise.allSettled( + requests.map(r => uor.appendJournalEntry({ + revision: r.rev, + type: 'ConcurrentRace', + payload: `race-${r.rev}` + })) + ); + + return { + baseRev, + outcomes: outcomes.map((o, idx) => ({ + rev: requests[idx].rev, + status: o.status, + error: o.reason?.message + })), + finalRev: uor.state.journalRevision + }; + }); + + // Ensure state revision monotonically advanced and is >= baseRev + 10 + assert.ok(result.finalRev >= result.baseRev + 10, 'Journal revision must have advanced to highest committed revision'); + + // Verify all negative and stale revisions were rejected + for (const out of result.outcomes) { + if (out.rev <= result.baseRev) { + assert.equal(out.status, 'rejected', `Revision ${out.rev} <= baseRev must be rejected`); + } + } + }); + + await test('3.5 Anti-Rollback WAL Journal: Signed checkpoint rollback rejection', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const currentRev = uor.state.journalRevision; + + // Valid forward checkpoint + const validCheckpoint = await uor.saveSignedCheckpoint({ + revision: currentRev + 2, + stateHash: 'sha256:1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff', + signature: 'valid-sig-hex', + timestamp: new Date().toISOString() + }); + + // Rollback checkpoint attempt + let rollbackRejected = false; + let rollbackError = ''; + try { + await uor.saveSignedCheckpoint({ + revision: currentRev - 1, // LOWER REVISION + stateHash: 'sha256:stale-checkpoint-hash', + signature: 'adversarial-sig', + timestamp: new Date().toISOString() + }); + } catch (err) { + rollbackRejected = true; + rollbackError = err.message; + } + + return { + validRev: validCheckpoint.revision, + rollbackRejected, + rollbackError, + lastCheckpointRev: uor.state.lastCheckpoint?.revision + }; + }); + + assert.ok(result.validRev > 0, 'Forward checkpoint succeeded'); + assert.equal(result.rollbackRejected, true, 'Stale checkpoint must be strictly rejected'); + assert.match(result.rollbackError, /Rollback rejected: checkpoint revision .* < current revision/); + assert.equal(result.lastCheckpointRev, result.validRev, 'Last checkpoint must remain the valid one'); + }); + + // -------------------------------------------------------------------------- + // 3.2 PERSISTENT KAPPA STORE CONTENT ADDRESSING & INTEGRITY + // -------------------------------------------------------------------------- + await test('3.6 Persistent Kappa Store: Content Addressing SHA-256 enforcement', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const payload = 'Content-Addressed Sovereign Object Payload 2026'; + const actualDigestHex = await window.uorCrypto.sha256Hex(payload); + const validDigest = `sha256:${actualDigestHex}`; + + // 1. Valid digest putBlob + const record = await uor.putBlob(validDigest, payload); + + // 2. Mismatched digest putBlob (tampered digest) + const fakeDigest = 'sha256:0000000000000000000000000000000000000000000000000000000000000000'; + let mismatchRejected = false; + let mismatchError = ''; + try { + await uor.putBlob(fakeDigest, payload); + } catch (err) { + mismatchRejected = true; + mismatchError = err.message; + } + + // 3. Single bit flipped digest + const flippedHex = (parseInt(actualDigestHex[0], 16) ^ 1).toString(16) + actualDigestHex.substring(1); + const flippedDigest = `sha256:${flippedHex}`; + let flipRejected = false; + try { + await uor.putBlob(flippedDigest, payload); + } catch { + flipRejected = true; + } + + return { + storedDigest: record.digest, + validDigest, + mismatchRejected, + mismatchError, + flipRejected + }; + }); + + assert.equal(result.storedDigest, result.validDigest, 'Valid digest must be accepted and stored'); + assert.equal(result.mismatchRejected, true, 'Mismatched digest must be rejected by putBlob'); + assert.match(result.mismatchError, /Digest mismatch: expected .* computed .*/); + assert.equal(result.flipRejected, true, 'Bit-flipped digest must be rejected by putBlob'); + }); + + await test('3.7 Persistent Kappa Store: Integrity and Persistence across Simulated Browser Reload', async () => { + // Generate distinct test blobs across types and sizes + const testBlobs = [ + { id: 'string', data: 'Verified Kappa Object State String Across Session Restarts' }, + { id: 'empty', data: '' }, + { id: 'json', data: JSON.stringify({ uorId: 'uor:obj:alpha', active: true, replicas: 3 }) }, + { id: 'binary_hex', data: '48656c6c6f20554f5220466f756e647279204b61707061' } // ASCII hex + ]; + + // Step A: Store blobs in the current browser page + const storedDigests = await page.evaluate(async (blobs) => { + const uor = window.uorFoundry; + const digests = []; + for (const b of blobs) { + const hex = await window.uorCrypto.sha256Hex(b.data); + const digest = `sha256:${hex}`; + await uor.putBlob(digest, b.data); + digests.push({ id: b.id, digest, expectedData: b.data }); + } + return digests; + }, testBlobs); + + assert.equal(storedDigests.length, testBlobs.length); + + // Step B: Simulate Browser Reload / Session Restart + // Reloading the page wipes all in-memory JavaScript variables (state.blobs = new Map()) + // and forces retrieval to hit persistent IndexedDB storage! + await page.reload({ waitUntil: 'load' }); + await page.waitForTimeout(400); + + // Step C: Retrieve blobs from fresh page instance and verify content addressing + const verificationResults = await page.evaluate(async (digests) => { + const uor = window.uorFoundry; + const results = []; + + for (const item of digests) { + // Query via getBlob + const record = await uor.getBlob(item.digest); + if (!record) { + results.push({ id: item.id, found: false }); + continue; + } + + const retrievedContent = record.isString + ? record.data + : new TextDecoder().decode(new Uint8Array(record.data)); + + // Re-compute SHA-256 on disk-retrieved bytes + const recomputedHex = await window.uorCrypto.sha256Hex(retrievedContent); + const recomputedDigest = `sha256:${recomputedHex}`; + + results.push({ + id: item.id, + found: true, + contentMatches: retrievedContent === item.expectedData, + digestMatches: recomputedDigest === item.digest, + retrievedDigest: record.digest + }); + } + + return results; + }, storedDigests); + + for (const res of verificationResults) { + assert.equal(res.found, true, `Blob ${res.id} must be found in persistent IndexedDB after reload`); + assert.equal(res.contentMatches, true, `Blob ${res.id} content must match byte-for-byte`); + assert.equal(res.digestMatches, true, `Blob ${res.id} re-computed SHA-256 must match stored digest`); + } + }); + + await test('3.8 Persistent Kappa Store: Disk Tamper Detection', async () => { + // 1. Store a known blob + const originalText = 'IMMUTABLE_GENUINE_BLOCK_CHAIN_METADATA_2026'; + const originalHex = await uorCrypto.sha256Hex(originalText); + const canonicalDigest = `sha256:${originalHex}`; + + await page.evaluate(async ({ digest, text }) => { + await window.uorFoundry.putBlob(digest, text); + }, { digest: canonicalDigest, text: originalText }); + + // 2. Adversary directly opens IndexedDB behind the scenes and tampers with the stored record data + const tamperResult = await page.evaluate(async (targetDigest) => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('uor_foundry_web_v1_kappa_store', 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + // Fetch and tamper the record + const record = await new Promise((resolve, reject) => { + const tx = db.transaction('objects', 'readonly'); + const req = tx.objectStore('objects').get(targetDigest); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + // Tamper stored data + record.data = 'MALICIOUS_SUBVERTED_DATA_SUBSTITUTED_ON_DISK'; + + await new Promise((resolve, reject) => { + const tx = db.transaction('objects', 'readwrite'); + const req = tx.objectStore('objects').put(record); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + + // Clear memory cache to ensure read hits disk + window.uorFoundry.state.blobs.clear(); + + // Read back through getBlob + const fetched = await window.uorFoundry.getBlob(targetDigest); + const fetchedContent = fetched.isString + ? fetched.data + : new TextDecoder().decode(new Uint8Array(fetched.data)); + + const recomputedHex = await window.uorCrypto.sha256Hex(fetchedContent); + const recomputedDigest = `sha256:${recomputedHex}`; + + return { + targetDigest, + recomputedDigest, + isDigestConsistent: recomputedDigest === targetDigest + }; + }, canonicalDigest); + + // Verify tamper was detected (the digest of tampered data no longer matches the content-addressed key) + assert.equal(tamperResult.isDigestConsistent, false, 'Tampered data on disk must fail content-addressing consistency'); + }); + + await test('3.9 Persistent Kappa Store: Large String Payload Durability (256KB Text)', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + + // 256KB text string + const size = 262144; + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + let largeText = ''; + for (let i = 0; i < size; i++) { + largeText += alphabet[i % alphabet.length]; + } + + // Compute digest + const hex = await window.uorCrypto.sha256Hex(largeText); + const digest = `sha256:${hex}`; + + // Put blob + const stored = await uor.putBlob(digest, largeText); + + // Clear in-memory cache + uor.state.blobs.clear(); + + // Get blob from IndexedDB + const fetched = await uor.getBlob(digest); + const fetchedText = fetched.isString ? fetched.data : new TextDecoder().decode(new Uint8Array(fetched.data)); + + return { + storedLength: stored.length, + fetchedLength: fetchedText.length, + textMatches: fetchedText === largeText, + digestMatches: fetched.digest === digest + }; + }); + + assert.equal(result.storedLength, 262144); + assert.equal(result.fetchedLength, 262144); + assert.equal(result.textMatches, true, '256KB string payload must match byte-for-byte'); + assert.equal(result.digestMatches, true, 'Stored digest must match canonical SHA-256'); + }); + + await test('3.10 Persistent Kappa Store: Binary Uint8Array Payload Ingestion & Digest Validation', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + + // 1024-byte binary Uint8Array buffer + const size = 1024; + const binaryBytes = new Uint8Array(size); + for (let i = 0; i < size; i++) { + binaryBytes[i] = (i * 31 + 17) & 0xff; + } + + // Compute authentic SHA-256 of raw binary buffer using WebCrypto + const digestBuffer = await crypto.subtle.digest('SHA-256', binaryBytes); + const hex = Array.from(new Uint8Array(digestBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); + const digest = `sha256:${hex}`; + + // Attempt putBlob with binary buffer + let putRejected = false; + let putError = null; + let storedRecord = null; + try { + storedRecord = await uor.putBlob(digest, binaryBytes); + } catch (err) { + putRejected = true; + putError = err.message; + } + + return { + digest, + putRejected, + putError, + storedRecord + }; + }); + + // We assert that binary buffer storage MUST succeed without digest mismatch. + // Defect in foundry.js:107 sha256Hex coerces Uint8Array to string via TextEncoder().encode(bytesOrText) + // which hashes the string representation "17,48,..." instead of the raw bytes. + assert.equal(result.putRejected, false, `putBlob with binary Uint8Array must succeed, but failed with: ${result.putError}`); + assert.ok(result.storedRecord, 'Binary record must be stored'); + }); + + await test('3.11 Persistent Kappa Store: Non-existent and malformed queries', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const q1 = await uor.getBlob('sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); + const q2 = await uor.getBlob('non-existent-tag'); + const q3 = await uor.getBlob(''); + + return { q1, q2, q3 }; + }); + + assert.equal(result.q1, null, 'Unregistered SHA-256 digest query must return null'); + assert.equal(result.q2, null, 'Arbitrary string digest query must return null'); + assert.equal(result.q3, null, 'Empty string query must return null'); + }); + + await context.close(); +} finally { + await browser.close(); +} + +console.log('\n================================================================'); +console.log(`TOTAL ADVERSARIAL TESTS RUN: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + console.error('\nFAILURE BREAKDOWN:'); + for (const f of failureDetails) { + console.error(`- ${f.name}: ${f.error}`); + } + process.exit(1); +} else { + console.log('\nALL ADVERSARIAL STRESS TESTS PASSED CLEANLY WITH ZERO DEFECTS.'); + process.exit(0); +} diff --git a/tests/e2e/test_m2_storage_adversarial_empirical.mjs b/tests/e2e/test_m2_storage_adversarial_empirical.mjs new file mode 100644 index 0000000..f27581b --- /dev/null +++ b/tests/e2e/test_m2_storage_adversarial_empirical.mjs @@ -0,0 +1,289 @@ +import assert from 'node:assert/strict'; +import { chromium } from '@playwright/test'; +import '../../site/crypto.js'; + +const siteHtmlPath = new URL('../../site/index.html', import.meta.url).href; + +console.log('================================================================'); +console.log('EMPIRICAL CHALLENGER: DEEP ADVERSARIAL STRESS TEST FOR KAPPA STORAGE & BLOB HASHING'); +console.log('Target: CH-M2-01 binary hashing, boundary sizes, concurrent puts, tamper detection'); +console.log('================================================================\n'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +const browser = await chromium.launch({ headless: true }); + +try { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(siteHtmlPath, { waitUntil: 'load' }); + + // -------------------------------------------------------------------------- + // TEST 1: Binary Blob Ingestion with 0-byte, 1-byte, and Boundary Buffers + // -------------------------------------------------------------------------- + await test('E1.1 Binary Ingestion: 0-byte empty Uint8Array', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + const emptyBytes = new Uint8Array(0); + const hash = await crypto.subtle.digest('SHA-256', emptyBytes); + const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + const digest = `sha256:${hex}`; + + const stored = await uor.putBlob(digest, emptyBytes); + uor.state.blobs.clear(); + const retrieved = await uor.getBlob(digest); + return { + storedLength: stored.length, + retrievedLength: retrieved.length, + isString: retrieved.isString, + dataLength: retrieved.data.length + }; + }); + + assert.equal(res.storedLength, 0); + assert.equal(res.retrievedLength, 0); + assert.equal(res.isString, false); + assert.equal(res.dataLength, 0); + }); + + await test('E1.2 Binary Ingestion: 1-byte extreme values (0x00 and 0xFF)', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + const results = []; + for (const val of [0x00, 0xff]) { + const byteBuf = new Uint8Array([val]); + const hash = await crypto.subtle.digest('SHA-256', byteBuf); + const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + const digest = `sha256:${hex}`; + + const stored = await uor.putBlob(digest, byteBuf); + uor.state.blobs.clear(); + const retrieved = await uor.getBlob(digest); + results.push({ + val, + digest, + storedLen: stored.length, + retrievedLen: retrieved.length, + retrievedByte: retrieved.data[0] + }); + } + return results; + }); + + for (const r of res) { + assert.equal(r.storedLen, 1); + assert.equal(r.retrievedLen, 1); + assert.equal(r.retrievedByte, r.val); + } + }); + + await test('E1.3 Binary Ingestion: Raw ArrayBuffer and TypedArray views', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + // ArrayBuffer directly + const rawBuffer = new Uint8Array([10, 20, 30, 40, 50, 60, 70, 80]).buffer; + const hash = await crypto.subtle.digest('SHA-256', rawBuffer); + const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + const digest = `sha256:${hex}`; + + const stored = await uor.putBlob(digest, rawBuffer); + uor.state.blobs.clear(); + const retrieved = await uor.getBlob(digest); + return { + storedLen: stored.length, + retrievedLen: retrieved.length, + retrievedBytes: Array.from(retrieved.data) + }; + }); + + assert.equal(res.storedLen, 8); + assert.equal(res.retrievedLen, 8); + assert.deepEqual(res.retrievedBytes, [10, 20, 30, 40, 50, 60, 70, 80]); + }); + + await test('E1.4 Binary Ingestion: High-entropy 512KB Buffer Durability', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + const size = 524288; // 512 KB + const bigBuf = new Uint8Array(size); + // Deterministic PRNG fill + let seed = 42; + for (let i = 0; i < size; i++) { + seed = (seed * 1664525 + 1013904223) >>> 0; + bigBuf[i] = seed & 0xff; + } + + const hash = await crypto.subtle.digest('SHA-256', bigBuf); + const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + const digest = `sha256:${hex}`; + + const stored = await uor.putBlob(digest, bigBuf); + uor.state.blobs.clear(); + const retrieved = await uor.getBlob(digest); + + // Verify sample bytes + const retrievedArr = new Uint8Array(retrieved.data); + let match = true; + if (retrievedArr.length !== size) match = false; + for (let i = 0; i < 1000 && match; i++) { + const idx = Math.floor((i * 521) % size); + if (retrievedArr[idx] !== bigBuf[idx]) match = false; + } + + return { + storedLen: stored.length, + retrievedLen: retrieved.length, + bytesMatch: match, + digestMatch: retrieved.digest === digest + }; + }); + + assert.equal(res.storedLen, 524288); + assert.equal(res.retrievedLen, 524288); + assert.equal(res.bytesMatch, true); + assert.equal(res.digestMatch, true); + }); + + // -------------------------------------------------------------------------- + // TEST 2: Digest Mismatch and Malformed Inputs Rejection + // -------------------------------------------------------------------------- + await test('E2.1 Digest Mismatch: Bit-flipped expected digest is rejected', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + const testBytes = new Uint8Array([1, 2, 3, 4, 5]); + const hash = await crypto.subtle.digest('SHA-256', testBytes); + const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + // Tamper hex + const tamperedHex = hex.slice(0, -1) + (hex.endsWith('0') ? '1' : '0'); + const tamperedDigest = `sha256:${tamperedHex}`; + + let rejected = false; + let errMsg = ''; + try { + await uor.putBlob(tamperedDigest, testBytes); + } catch (err) { + rejected = true; + errMsg = err.message; + } + return { rejected, errMsg }; + }); + + assert.equal(res.rejected, true); + assert.match(res.errMsg, /Digest mismatch/); + }); + + await test('E2.2 Digest Parameter Validation: Non-string and empty digests rejected', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + const testBytes = new Uint8Array([1, 2, 3]); + const badInputs = [null, undefined, '', 12345, {}, []]; + const results = []; + for (const bad of badInputs) { + let rejected = false; + try { + await uor.putBlob(bad, testBytes); + } catch (err) { + rejected = true; + } + results.push(rejected); + } + return results; + }); + + for (const r of res) { + assert.equal(r, true); + } + }); + + // -------------------------------------------------------------------------- + // TEST 3: Concurrent Puts & WAL Revision Monotonicity + // -------------------------------------------------------------------------- + await test('E3.1 Concurrency: 20 simultaneous putBlob operations update WAL monotonically', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + const startRev = uor.state.journalRevision; + + // 20 distinct payloads + const payloads = []; + for (let i = 0; i < 20; i++) { + const data = new Uint8Array([i, i * 2, i * 3, i * 4]); + const hash = await crypto.subtle.digest('SHA-256', data); + const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + payloads.push({ digest: `sha256:${hex}`, data }); + } + + const results = await Promise.allSettled( + payloads.map(p => uor.putBlob(p.digest, p.data)) + ); + + const allSuccess = results.every(r => r.status === 'fulfilled'); + const finalRev = uor.state.journalRevision; + + return { + startRev, + finalRev, + allSuccess, + revDelta: finalRev - startRev + }; + }); + + assert.equal(res.allSuccess, true); + assert.equal(res.revDelta, 20, 'Each putBlob must monotonically increment journalRevision by 1'); + }); + + await test('E3.2 Idempotency: Multiple putBlob calls for identical digest succeed without error', async () => { + const res = await page.evaluate(async () => { + const uor = window.uorFoundry; + const testBytes = new Uint8Array([99, 88, 77, 66]); + const hash = await crypto.subtle.digest('SHA-256', testBytes); + const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + const digest = `sha256:${hex}`; + + // Put twice + const r1 = await uor.putBlob(digest, testBytes); + const r2 = await uor.putBlob(digest, testBytes); + + return { + r1Digest: r1.digest, + r2Digest: r2.digest, + sameDigest: r1.digest === r2.digest + }; + }); + + assert.equal(res.sameDigest, true); + }); + + await context.close(); +} finally { + await browser.close(); +} + +console.log('\n================================================================'); +console.log(`TOTAL EMPIRICAL CHALLENGER TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +} else { + console.log('\nALL EMPIRICAL CHALLENGER STRESS TESTS PASSED CLEANLY.'); + process.exit(0); +} diff --git a/tests/e2e/test_m2_storage_governance.mjs b/tests/e2e/test_m2_storage_governance.mjs new file mode 100644 index 0000000..6c3901f --- /dev/null +++ b/tests/e2e/test_m2_storage_governance.mjs @@ -0,0 +1,351 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, firefox } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import '../../site/crypto.js'; + +const uorCrypto = globalThis.uorCrypto; +const siteHtmlPath = pathToFileURL(resolve('site/index.html')).href; + +console.log('================================================================'); +console.log('CHALLENGER DEEP VERIFICATION SUITE (Milestone 2 - Storage & Governance)'); +console.log('================================================================'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +// ============================================================================ +// PART 1: AUTHENTICATED ENCRYPTION & DOMAIN-SEPARATED CRYPTOGRAPHY +// ============================================================================ +console.log('\n--- PART 1: CRYPTO, AES-256-GCM & DOMAIN-SEPARATED SIGNATURES ---'); + +await test('1.1 AES-256-GCM symmetric key generation & encryption/decryption roundtrip', async () => { + const symKey = await uorCrypto.generateSymmetricKey(); + assert.ok(symKey, 'Symmetric key generated'); + assert.equal(symKey.algorithm.name, 'AES-GCM', 'Algorithm must be AES-GCM'); + assert.equal(symKey.algorithm.length, 256, 'Key length must be 256-bit'); + + const testPlaintexts = [ + 'Universal Object Reference (UOR) Foundation 2026', + '', + JSON.stringify({ model: 'pure-model', state: 'Activated', quorum: 2 }), + new Uint8Array([0x00, 0xff, 0x42, 0x13, 0x37, 0x7f]) + ]; + + for (const plain of testPlaintexts) { + const { ciphertext, iv } = await uorCrypto.encryptBlob(symKey, plain); + assert.equal(iv.length, 12, 'AES-GCM IV must be exactly 12 bytes'); + assert.ok(ciphertext.length > 0, 'Ciphertext must be non-empty'); + + const decrypted = await uorCrypto.decryptBlob(symKey, ciphertext, iv); + const expectedBytes = typeof plain === 'string' + ? new TextEncoder().encode(plain) + : plain; + assert.deepEqual(decrypted, expectedBytes, 'Decrypted bytes must match plaintext exactly'); + } +}); + +await test('1.2 AES-256-GCM authentication tag tampering causes decryption rejection', async () => { + const symKey = await uorCrypto.generateSymmetricKey(); + const plain = 'Confidential state checkpoint payload'; + const { ciphertext, iv } = await uorCrypto.encryptBlob(symKey, plain); + + // Tamper with the ciphertext (flip one bit) + const tamperedCipher = new Uint8Array(ciphertext); + tamperedCipher[tamperedCipher.length - 1] ^= 0x01; + + let rejected = false; + try { + await uorCrypto.decryptBlob(symKey, tamperedCipher, iv); + } catch { + rejected = true; + } + assert.ok(rejected, 'Tampered ciphertext must fail authentication and throw'); + + // Tamper with IV + const tamperedIv = new Uint8Array(iv); + tamperedIv[0] ^= 0xff; + let ivRejected = false; + try { + await uorCrypto.decryptBlob(symKey, ciphertext, tamperedIv); + } catch { + ivRejected = true; + } + assert.ok(ivRejected, 'Mismatched IV must fail decryption authentication'); +}); + +await test('1.3 Domain-separated signing prefix \\x19UOR Foundry Auth v1:\\n enforcement', async () => { + const accountId = `uor:user:domain_sep_${Date.now()}`; + const record = await uorCrypto.getOrCreateKeyPair(accountId); + const challenge = 'test-governance-proposal-42'; + + // Authentic signChallenge uses prefix + const sigHex = await uorCrypto.signChallenge(accountId, challenge); + assert.ok(sigHex && sigHex.length > 0, 'Signature generated'); + + // Verification using verifySignature succeeds + const verified = await uorCrypto.verifySignature(record.keyPair.publicKey, sigHex, challenge); + assert.equal(verified, true, 'Authentic domain-separated signature must verify'); + + // Direct manual verification of raw challenge without prefix must FAIL + const rawBytes = new TextEncoder().encode(challenge); + const sigBytes = new Uint8Array(sigHex.match(/.{1,2}/g).map(b => parseInt(b, 16))); + const rawVerify = await crypto.subtle.verify( + { name: 'ECDSA', hash: { name: 'SHA-256' } }, + record.keyPair.publicKey, + sigBytes, + rawBytes + ); + assert.equal(rawVerify, false, 'Signature made with domain prefix must not verify against un-prefixed raw bytes'); +}); + +// ============================================================================ +// PART 2: BROWSER DOM GOVERNANCE & INVITATIONS LIFECYCLE +// ============================================================================ +console.log('\n--- PART 2: ORGANIZATION GOVERNANCE & INVITATIONS DOM LIFECYCLE ---'); + +const browserChromium = await chromium.launch({ headless: true }); + +try { + const context = await browserChromium.newContext(); + const page = await context.newPage(); + await page.goto(siteHtmlPath, { waitUntil: 'load' }); + + // Navigate to Organization tab + await page.locator('#tab-organization').click(); + + await test('2.1 Organization Rename preserves immutable cryptographic ID', async () => { + const originalId = await page.locator('#org-id-val').innerText(); + assert.ok(originalId.startsWith('uor:org:'), 'Original Org ID must be valid'); + + // Submit rename + await page.locator('#edit-org-name').fill('Citizen Gardens Global Foundation'); + await page.locator('#btn-update-org-name').click(); + await page.waitForTimeout(300); + + const updatedName = await page.locator('#org-name-val').innerText(); + const currentId = await page.locator('#org-id-val').innerText(); + + assert.equal(updatedName, 'Citizen Gardens Global Foundation', 'Organization name must be updated'); + assert.equal(currentId, originalId, 'Organization ID must remain strictly immutable'); + }); + + await test('2.2 Organization Full Lifecycle: Suspend -> Reactivate with Quorum -> Retire', async () => { + // 1. Initial state is Provisional + let badgeText = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(badgeText, 'Provisional'); + + // 2. Add second administrator to satisfy quorum (alice is founding admin) + await page.locator('#admin-email').fill('bob@uor.foundation'); + await page.locator('#admin-scope').selectOption('Organization'); + await page.locator('#btn-add-admin').click(); + await page.waitForTimeout(300); + + const adminCount = await page.locator('#org-admin-count').innerText(); + assert.equal(adminCount, '2', 'Organization must have 2 enrolled administrators'); + + // 3. Activate under quorum + await page.locator('#btn-activate-org').click(); + await page.waitForTimeout(300); + badgeText = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(badgeText, 'Activated', 'Organization must be Activated'); + + // 4. Suspend Organization + await page.locator('#btn-suspend-org').click(); + await page.waitForTimeout(300); + badgeText = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(badgeText, 'Suspended', 'Organization must transition to Suspended'); + + // 5. Reactivate Organization with quorum + await page.locator('#btn-reactivate-org').click(); + await page.waitForTimeout(300); + badgeText = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(badgeText, 'Activated', 'Organization must transition back to Activated'); + + // 6. Retire Organization with force override + await page.locator('#retire-force-override').check(); + await page.locator('#btn-retire-org').click(); + await page.waitForTimeout(300); + badgeText = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(badgeText, 'Retired', 'Organization must transition to Retired'); + }); + + await test('2.3 Invitations Lifecycle: Issue -> Key-Bound Acceptance -> Enrolled Administrator', async () => { + // Switch to a new organization to test fresh invitation flow + await page.locator('#new-org-name').fill('Solar Cooperative'); + await page.locator('#btn-create-org').click(); + await page.waitForTimeout(300); + + // Issue invitation to charlie + await page.locator('#invitation-email').fill('charlie@uor.foundation'); + await page.locator('#invitation-scope').selectOption('Security'); + await page.locator('#btn-send-invitation').click(); + await page.waitForTimeout(300); + + // Verify invitation row exists with Pending status + const pendingStatus = await page.locator('#invitations-tbody tr:first-child span.badge').innerText(); + assert.equal(pendingStatus, 'Pending', 'New invitation must have Pending status'); + + // Accept invitation with WebCrypto key binding + await page.locator('#invitations-tbody tr:first-child .btn-accept-invite').click(); + await page.waitForTimeout(400); + + // Verify status updated to Accepted + const acceptedStatus = await page.locator('#invitations-tbody tr:first-child span.badge').innerText(); + assert.equal(acceptedStatus, 'Accepted', 'Accepted invitation status must be Accepted'); + + // Verify administrator roster includes charlie with Security scope + const rosterText = await page.locator('#admin-roster-list').innerText(); + assert.ok(rosterText.includes('charlie@uor.foundation'), 'Charlie must be enrolled in administrator roster'); + assert.ok(rosterText.includes('Security'), 'Charlie must hold delegated Security scope'); + }); + + await test('2.4 Governance proposal signature collection without fake voter fallback', async () => { + await page.locator('#tab-governance').click(); + + // Verify Cast Signature Approval + const voteBtn = page.locator('#active-proposals-list .btn-vote').first(); + if (await voteBtn.isVisible()) { + await voteBtn.click(); + await page.waitForTimeout(300); + } + + // Verify announcement or approvals count + const proposalText = await page.locator('#active-proposals-list').innerText(); + assert.ok(proposalText.includes('Approvals:'), 'Proposal approvals must be displayed'); + }); + + // ============================================================================ + // PART 3: PERSISTENT STORAGE & ANTI-ROLLBACK WAL JOURNAL + // ============================================================================ + console.log('\n--- PART 3: PERSISTENT KAPPA STORE & WAL REVISION FENCING ---'); + + await page.locator('#tab-storage').click(); + + await test('3.1 Persistent IndexedDB Kappa Store putBlob & getBlob with content addressing', async () => { + // Store blob via UI + const payload = 'Decentralized Sovereign Object Protocol payload ' + Date.now(); + await page.locator('#blob-input-data').fill(payload); + await page.locator('#btn-store-blob').click(); + await page.waitForTimeout(400); + + const storeResult = await page.locator('#blob-result-display').innerText(); + assert.ok(storeResult.includes('Blob Stored in Persistent Kappa Store!'), 'Expected persistent store message'); + const digestMatch = storeResult.match(/sha256:([0-9a-f]{64})/); + assert.ok(digestMatch, 'Digest must be reported'); + const digest = digestMatch[0]; + + // Lookup blob via UI form + await page.locator('#blob-lookup-digest').fill(digest); + await page.locator('#btn-get-blob').click(); + await page.waitForTimeout(400); + + const retrieveResult = await page.locator('#blob-retrieve-display').innerText(); + assert.ok(retrieveResult.includes('Blob Retrieved & Verified!'), 'Expected retrieval confirmation'); + }); + + await test('3.2 Anti-Rollback WAL Journal enforces strictly monotonic revisions', async () => { + const journalResult = await page.evaluate(async () => { + const uor = window.uorFoundry; + if (!uor || !uor.appendJournalEntry) return { error: 'No appendJournalEntry function' }; + + const currentRev = uor.state.journalRevision; + // Valid forward transition + const entry1 = await uor.appendJournalEntry({ + revision: currentRev + 5, + type: 'StateTransition', + payload: 'valid-transition', + actor: 'did:key:admin1' + }); + + // Rollback attempt: revision equal to or lower than current + let rollbackRejected = false; + try { + await uor.appendJournalEntry({ + revision: currentRev + 3, + type: 'RollbackAttempt', + payload: 'stale-state', + actor: 'did:key:adversary' + }); + } catch (err) { + rollbackRejected = true; + } + + return { + entry1Revision: entry1.revision, + rollbackRejected + }; + }); + + assert.ok(journalResult.entry1Revision > 0, 'Forward journal entry must succeed'); + assert.equal(journalResult.rollbackRejected, true, 'Rollback with stale revision must be strictly rejected'); + }); + + await context.close(); +} finally { + await browserChromium.close(); +} + +// ============================================================================ +// PART 4: MULTI-BROWSER AXE WCAG 2.2 AA ACCESSIBILITY AUDIT +// ============================================================================ +console.log('\n--- PART 4: MULTI-BROWSER AXE WCAG 2.2 AA ACCESSIBILITY ---'); + +const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; + +async function testAccessibility(browserType, browserName) { + await test(`4.1 ${browserName}: Organization & Storage tabs have 0 Axe violations under dynamic mutations`, async () => { + const browser = await browserType.launch({ headless: true }); + try { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(siteHtmlPath, { waitUntil: 'load' }); + + // Audit Organization tab + await page.locator('#tab-organization').click(); + const orgAudit = await new AxeBuilder({ page }).withTags(tags).analyze(); + assert.equal(orgAudit.violations.length, 0, `${browserName}: Organization tab must have 0 Axe violations (got ${orgAudit.violations.length})`); + + // Audit Storage tab + await page.locator('#tab-storage').click(); + const storageAudit = await new AxeBuilder({ page }).withTags(tags).analyze(); + assert.equal(storageAudit.violations.length, 0, `${browserName}: Storage tab must have 0 Axe violations (got ${storageAudit.violations.length})`); + + await context.close(); + } finally { + await browser.close(); + } + }); +} + +await testAccessibility(chromium, 'Chromium'); +await testAccessibility(firefox, 'Firefox'); + +console.log('\n================================================================'); +console.log(`TOTAL TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +} else { + process.exit(0); +} From d7ab56c31b64a8aa5f69a821f568fb3531b0ed1c Mon Sep 17 00:00:00 2001 From: Alex Flom Date: Sat, 3 Oct 2026 22:14:15 -0600 Subject: [PATCH 4/4] test(m3): add projects, comms lifecycle, and offline service worker suites --- site/sw.js | 67 +++ .../test_m3_challenger_comms_adversarial.mjs | 469 ++++++++++++++++++ tests/e2e/test_m3_comms.mjs | 358 +++++++++++++ tests/e2e/test_m3_projects.mjs | 326 ++++++++++++ tests/e2e/test_m3_projects_adversarial.mjs | 402 +++++++++++++++ 5 files changed, 1622 insertions(+) create mode 100644 site/sw.js create mode 100644 tests/e2e/test_m3_challenger_comms_adversarial.mjs create mode 100644 tests/e2e/test_m3_comms.mjs create mode 100644 tests/e2e/test_m3_projects.mjs create mode 100644 tests/e2e/test_m3_projects_adversarial.mjs diff --git a/site/sw.js b/site/sw.js new file mode 100644 index 0000000..cf19ad1 --- /dev/null +++ b/site/sw.js @@ -0,0 +1,67 @@ +// Service Worker for UOR Foundry (U22: Offline Launch & Asset Caching) +// Pre-caches pure-model application closure and provides cache-first offline delivery. + +const CACHE_NAME = 'foundry-cache-v1'; +const CORE_ASSETS = [ + './', + 'index.html', + 'foundry.css', + 'foundry.js', + 'crypto.js', + 'manifest.json', + 'foundry_bg.wasm', + 'holo_runtime.holo' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(CORE_ASSETS).catch((err) => { + console.warn('SW pre-cache warning for optional asset:', err); + }); + }).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) { + return caches.delete(key); + } + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return; + } + + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + return fetch(event.request).then((networkResponse) => { + if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') { + return networkResponse; + } + const responseToCache = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + return networkResponse; + }).catch(() => { + // Fallback to cached index.html for navigation requests when completely offline + if (event.request.mode === 'navigate') { + return caches.match('index.html'); + } + }); + }) + ); +}); diff --git a/tests/e2e/test_m3_challenger_comms_adversarial.mjs b/tests/e2e/test_m3_challenger_comms_adversarial.mjs new file mode 100644 index 0000000..e8f8883 --- /dev/null +++ b/tests/e2e/test_m3_challenger_comms_adversarial.mjs @@ -0,0 +1,469 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, firefox } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import '../../site/crypto.js'; + +const siteHtmlPath = pathToFileURL(resolve('site/index.html')).href; + +console.log('================================================================'); +console.log('CHALLENGER EMPIRICAL ADVERSARIAL STRESS SUITE (Milestone 3 Comms)'); +console.log('Target: Message & Attachment Limits, Kappa Blob Store, Delivery, Cross-Org Isolation, Notifications'); +console.log('================================================================\n'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext(); +const page = await context.newPage(); + +await page.goto(siteHtmlPath); +await page.waitForLoadState('domcontentloaded'); + +// Ensure messaging tab is active +await page.locator('#tab-messaging').click(); +await page.waitForTimeout(200); + +// ============================================================================ +// PART 1: MESSAGE BOUNDS & 25 MIB ATTACHMENT ADVERSARIAL STRESS +// ============================================================================ +console.log('--- PART 1: MESSAGE BOUNDS & ATTACHMENT LIMITS ---'); + +await test('1.1 Attachment boundary: exact 25 MiB boundary and overflow (+1 byte) rejection', async () => { + const MAX_BYTES = 25 * 1024 * 1024; // 26,214,400 bytes + + // 1. Exactly 26,214,401 bytes (25 MiB + 1 byte) MUST trigger alert and be rejected + let alertMessage = ''; + page.once('dialog', async (dialog) => { + alertMessage = dialog.message(); + await dialog.accept(); + }); + + await page.evaluate((max) => { + const fakeFile = new File(['0'], 'overflow_archive.zip', { type: 'application/zip' }); + Object.defineProperty(fakeFile, 'size', { value: max + 1 }); + + const dt = new DataTransfer(); + dt.items.add(fakeFile); + document.getElementById('msg-attachment-file').files = dt.files; + document.getElementById('msg-subject').value = 'Boundary + 1'; + document.getElementById('msg-body').value = 'Attempting 25 MiB + 1 byte upload'; + }, MAX_BYTES); + + await page.locator('#btn-dispatch-message').click(); + await page.waitForTimeout(200); + + assert.ok( + alertMessage.includes('Attachment exceeds 25 MiB limit'), + `Expected 25 MiB limit alert, got: "${alertMessage}"` + ); + + // 2. Exact 25 MiB boundary: 26,214,400 bytes must NOT be rejected by size check + let boundaryRejected = false; + page.once('dialog', async (dialog) => { + boundaryRejected = true; + await dialog.accept(); + }); + + await page.evaluate(() => { + const validFile = new File(['Boundary test content'], 'boundary_archive.txt', { type: 'text/plain' }); + Object.defineProperty(validFile, 'size', { value: 25 * 1024 * 1024 }); + + const dt = new DataTransfer(); + dt.items.add(validFile); + document.getElementById('msg-attachment-file').files = dt.files; + document.getElementById('msg-attachment-alt').value = 'Alt text for boundary file'; + document.getElementById('msg-subject').value = 'Exact 25 MiB Boundary'; + document.getElementById('msg-body').value = 'Testing exact boundary handling'; + }); + + await page.locator('#btn-dispatch-message').click(); + await page.waitForTimeout(300); + + assert.equal(boundaryRejected, false, 'Exact 25 MiB file must not trigger rejection dialog'); +}); + +// ============================================================================ +// PART 2: CONTENT-ADDRESSED KAPPA BLOB STORAGE INTEGRITY & TAMPER DETECTION +// ============================================================================ +console.log('\n--- PART 2: KAPPA BLOB STORE INTEGRITY & TAMPER DETECTION ---'); + +await test('2.1 putBlob and getBlob return byte-identical data matching SHA-256 digest', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const testPayloads = [ + 'Simple ASCII test vector for Kappa Blob Store', + new Uint8Array([0x00, 0x01, 0x02, 0xfe, 0xff]), + new Uint8Array(1024).map((_, i) => i % 256), + JSON.stringify({ schema: 'uor:blob:v1', nonce: 42, active: true }) + ]; + + const results = []; + for (const p of testPayloads) { + const rawData = typeof p === 'string' + ? new TextEncoder().encode(p) + : p; + const expectedDigest = `sha256:${await sha256Hex(rawData)}`; + + // Store blob + const storedRecord = await uor.putBlob(expectedDigest, p); + if (storedRecord.digest !== expectedDigest) { + return { success: false, reason: `Stored digest mismatch: ${storedRecord.digest} vs ${expectedDigest}` }; + } + + // Retrieve blob + const retrieved = await uor.getBlob(expectedDigest); + if (!retrieved) { + return { success: false, reason: `Blob not found for digest ${expectedDigest}` }; + } + + // Check byte parity + const retrievedBytes = retrieved.isString + ? new TextEncoder().encode(retrieved.data) + : new Uint8Array(retrieved.data); + + if (retrievedBytes.length !== rawData.length) { + return { success: false, reason: `Length mismatch: ${retrievedBytes.length} vs ${rawData.length}` }; + } + + for (let i = 0; i < rawData.length; i++) { + if (retrievedBytes[i] !== rawData[i]) { + return { success: false, reason: `Byte mismatch at index ${i}` }; + } + } + + results.push({ digest: expectedDigest, verified: true }); + } + + return { success: true, count: results.length }; + }); + + assert.ok(result.success, result.reason || 'Kappa store byte fidelity verification failed'); + assert.equal(result.count, 4, 'All 4 test payloads must be verified'); +}); + +await test('2.2 Forged or corrupted SHA-256 digest in putBlob is strictly rejected', async () => { + const rejectionResult = await page.evaluate(async () => { + const uor = window.uorFoundry; + const realPayload = 'Authentic payload data'; + const fakeDigest = 'sha256:0000000000000000000000000000000000000000000000000000000000000000'; + + try { + await uor.putBlob(fakeDigest, realPayload); + return { rejected: false }; + } catch (err) { + return { rejected: true, message: err.message }; + } + }); + + assert.ok(rejectionResult.rejected, 'putBlob must throw error when digest does not match content'); + assert.ok( + rejectionResult.message.includes('Digest mismatch'), + `Expected Digest mismatch error, got: "${rejectionResult.message}"` + ); +}); + +await test('2.3 Non-existent digest in getBlob returns null cleanly without throwing', async () => { + const missingResult = await page.evaluate(async () => { + const uor = window.uorFoundry; + const nonExistent = 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + return await uor.getBlob(nonExistent); + }); + + assert.equal(missingResult, null, 'getBlob must return null for missing digest'); +}); + +// ============================================================================ +// PART 3: DELIVERY PROGRESSION & CHANNEL CROSS-ORG ISOLATION +// ============================================================================ +console.log('\n--- PART 3: DELIVERY PROGRESSION & CROSS-ORG CHANNEL ISOLATION ---'); + +await test('3.1 Message delivery state transitions: Sent -> Delivered -> Acknowledged progression', async () => { + // Probing the exact progression sequence in page context + const observed = await page.evaluate(async () => { + const form = document.getElementById('form-send-message'); + document.getElementById('msg-subject').value = 'Delivery State Lifecycle'; + document.getElementById('msg-body').value = 'Progression test payload'; + + // Trigger dispatch + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + const uor = window.uorFoundry; + const msg = uor.state.messages[uor.state.messages.length - 1]; + const initialStatus = msg.status; + + // Wait past 60ms timer + await new Promise(r => setTimeout(r, 90)); + const midStatus = msg.status; + + // Wait past 180ms timer + await new Promise(r => setTimeout(r, 140)); + const finalStatus = msg.status; + + return { initialStatus, midStatus, finalStatus }; + }); + + assert.equal(observed.initialStatus, 'Sent', 'Initial delivery status must be Sent'); + assert.equal(observed.midStatus, 'Delivered', 'Mid delivery status must be Delivered'); + assert.equal(observed.finalStatus, 'Acknowledged', 'Final delivery status must be Acknowledged'); + + // Verify DOM badge updates to Acknowledged and has badge-success class + await page.waitForTimeout(100); + const finalBadge = page.locator('#messages-list .message-card:last-child .msg-status-badge'); + const badgeText = await finalBadge.innerText(); + const badgeClass = await finalBadge.getAttribute('class'); + + assert.equal(badgeText, 'Acknowledged', 'DOM badge text must display Acknowledged'); + assert.ok(badgeClass.includes('badge-success'), 'Acknowledged status must have badge-success styling'); +}); + +await test('3.2 Channel cross-organization isolation: no channel leakage across orgs', async () => { + const isolationCheck = await page.evaluate(() => { + const uor = window.uorFoundry; + const originalOrgId = uor.state.activeOrgId; + const foreignOrgId = 'uor:org:isolated-external-org-99'; + + // Channels initially present in originalOrgId + const originalChannels = uor.state.channels.filter(c => c.orgId === originalOrgId); + + // Create a private channel in originalOrgId + const secretChan = { + id: `uor:channel:test-secret:${Date.now()}`, + name: '#secret-financials', + topic: 'Confidential org data', + type: 'Private', + orgId: originalOrgId + }; + uor.state.channels.push(secretChan); + + // Switch activeOrgId to foreignOrgId + uor.state.activeOrgId = foreignOrgId; + + // Filter channels for foreignOrgId + const foreignVisibleChannels = uor.state.channels.filter(c => c.orgId === uor.state.activeOrgId); + + // Check that none of the original org channels leak into foreignOrgId + const leakedChannels = foreignVisibleChannels.filter(c => c.orgId === originalOrgId); + + // Restore original activeOrgId + uor.state.activeOrgId = originalOrgId; + + return { + originalOrgCount: originalChannels.length + 1, + foreignVisibleCount: foreignVisibleChannels.length, + leakedCount: leakedChannels.length + }; + }); + + assert.equal(isolationCheck.leakedCount, 0, 'No channels from original org should leak into foreign org'); + assert.equal(isolationCheck.foreignVisibleCount, 0, 'Foreign org with no registered channels must see 0 channels'); +}); + +await test('3.3 Channel uniqueness is scoped per-org: same channel name allowed in different orgs', async () => { + const perOrgScoping = await page.evaluate(() => { + const uor = window.uorFoundry; + const org1 = 'uor:org:domain-one'; + const org2 = 'uor:org:domain-two'; + + // Register '#announcements' in org1 + uor.state.channels.push({ + id: 'uor:channel:domain-one:announcements', + name: '#announcements', + topic: 'Announcements 1', + type: 'Public', + orgId: org1 + }); + + // Check duplicate check in org1 (should detect duplicate) + const existsInOrg1 = uor.state.channels.some( + c => c.orgId === org1 && c.name.toLowerCase() === '#announcements' + ); + + // Check duplicate check in org2 (should NOT detect duplicate) + const existsInOrg2 = uor.state.channels.some( + c => c.orgId === org2 && c.name.toLowerCase() === '#announcements' + ); + + return { existsInOrg1, existsInOrg2 }; + }); + + assert.equal(perOrgScoping.existsInOrg1, true, '#announcements must exist in org1'); + assert.equal(perOrgScoping.existsInOrg2, false, '#announcements must not collide in org2'); +}); + +// ============================================================================ +// PART 4: SHARED INBOX & NOTIFICATION CONSISTENCY ACROSS EVENT TRIGGERS +// ============================================================================ +console.log('\n--- PART 4: SHARED INBOX & NOTIFICATIONS CONSISTENCY ---'); + +await test('4.1 Dispatch all canonical notification event triggers and verify unread badge accounting', async () => { + // Capture initial badge value + const initialCountStr = await page.locator('#unread-notif-badge').innerText(); + const initialCount = parseInt(initialCountStr, 10); + + const testEvents = [ + { event: 'SecurityLogin', severity: 'Info', title: 'Adversarial Login 1', route: '#panel-identity' }, + { event: 'BackupCodeRedeemed', severity: 'Warning', title: 'Adversarial Backup 2', route: '#panel-backup-codes' }, + { event: 'InvitationReceived', severity: 'Info', title: 'Adversarial Invite 3', route: '#panel-organization' }, + { event: 'ProposalCreated', severity: 'Critical', title: 'Adversarial Proposal 4', route: '#panel-governance' }, + { event: 'MilestoneCompleted', severity: 'Info', title: 'Adversarial Milestone 5', route: '#panel-projects' }, + ]; + + // Dispatch all 5 events + await page.evaluate((events) => { + for (const ev of events) { + window.uorFoundry.dispatchNotification({ + event: ev.event, + severity: ev.severity, + title: ev.title, + summary: `Verification of event ${ev.event}`, + route: ev.route + }); + } + }, testEvents); + + await page.waitForTimeout(300); + + // Badge must be incremented by exactly 5 + const newCountStr = await page.locator('#unread-notif-badge').innerText(); + const newCount = parseInt(newCountStr, 10); + assert.equal(newCount, initialCount + 5, `Unread badge must increment by 5 (expected ${initialCount + 5}, got ${newCount})`); + + // Open inbox panel + await page.locator('#btn-inbox').click(); + await page.waitForTimeout(200); + + // Verify all 5 titles are present in the list + const inboxListText = await page.locator('#inbox-list').innerText(); + for (const ev of testEvents) { + assert.ok(inboxListText.includes(ev.title), `Notification "${ev.title}" must be displayed in inbox`); + } + + // Mark one notification read and check badge decrements by 1 + const firstMarkReadBtn = page.locator('#inbox-list .btn-mark-read').first(); + await firstMarkReadBtn.click(); + await page.waitForTimeout(200); + + const afterOneReadStr = await page.locator('#unread-notif-badge').innerText(); + assert.equal(parseInt(afterOneReadStr, 10), initialCount + 4, 'Badge count must decrement by 1 after marking one notification read'); + + // Mark all notifications read + await page.locator('#btn-mark-all-read').click(); + await page.waitForTimeout(200); + + const afterAllReadStr = await page.locator('#unread-notif-badge').innerText(); + assert.equal(afterAllReadStr, '0', 'Badge count must be 0 after Mark All Read'); + const badgeClass = await page.locator('#unread-notif-badge').getAttribute('class'); + assert.ok(badgeClass.includes('badge-neutral'), '0 unread badge must have badge-neutral styling'); +}); + +await test('4.2 Interactive notification route navigation switches active tab and closes inbox panel', async () => { + // Dispatch a notification with route '#panel-governance' + await page.evaluate(() => { + window.uorFoundry.dispatchNotification({ + event: 'ProposalCreated', + severity: 'Critical', + title: 'Governance Quorum Action Needed', + summary: 'Route navigation test', + route: '#panel-governance' + }); + }); + + // Open inbox panel if closed + const isHidden = await page.locator('#inbox-panel').getAttribute('hidden'); + if (isHidden !== null) { + await page.locator('#btn-inbox').click(); + await page.waitForTimeout(200); + } + + // Find and click the route link + const routeLink = page.locator('#inbox-list .notif-route-link[data-route="#panel-governance"]').first(); + await routeLink.click(); + await page.waitForTimeout(300); + + // Governance panel must now be active/visible + const govPanelVisible = await page.locator('#panel-governance').isVisible(); + assert.ok(govPanelVisible, 'Clicking notification route must activate Governance panel'); + + // Inbox panel must now be closed + const panelHiddenAfter = await page.locator('#inbox-panel').getAttribute('hidden'); + assert.notEqual(panelHiddenAfter, null, 'Inbox panel must be closed after route navigation'); +}); + +// ============================================================================ +// PART 5: MULTI-BROWSER WCAG 2.2 AA ACCESSIBILITY AUDIT +// ============================================================================ +console.log('\n--- PART 5: MULTI-BROWSER WCAG 2.2 AA ACCESSIBILITY AUDIT ---'); + +for (const browserType of [chromium, firefox]) { + const browserName = browserType.name(); + await test(`5.1 ${browserName}: Full Messaging & Inbox interface passes Axe WCAG 2.2 AA`, async () => { + const b = await browserType.launch({ headless: true }); + const ctx = await b.newContext(); + const p = await ctx.newPage(); + await p.goto(siteHtmlPath); + await p.waitForLoadState('domcontentloaded'); + + // 1. Audit Messaging Panel + await p.locator('#tab-messaging').click(); + await p.waitForTimeout(300); + + const axeMessaging = await new AxeBuilder({ page: p }) + .include('#panel-messaging') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + + assert.equal( + axeMessaging.violations.length, + 0, + `${browserName} Messaging violations: ${JSON.stringify(axeMessaging.violations.map(v => ({ id: v.id, impact: v.impact, description: v.description })))}` + ); + + // 2. Audit Inbox dropdown panel open + await p.locator('#btn-inbox').click(); + await p.waitForTimeout(300); + + const axeInbox = await new AxeBuilder({ page: p }) + .include('#inbox-panel') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + + assert.equal( + axeInbox.violations.length, + 0, + `${browserName} Inbox panel violations: ${JSON.stringify(axeInbox.violations.map(v => ({ id: v.id, impact: v.impact, description: v.description })))}` + ); + + await b.close(); + }); +} + +await browser.close(); + +console.log('\n================================================================'); +console.log(`TOTAL ADVERSARIAL TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +} diff --git a/tests/e2e/test_m3_comms.mjs b/tests/e2e/test_m3_comms.mjs new file mode 100644 index 0000000..71382c4 --- /dev/null +++ b/tests/e2e/test_m3_comms.mjs @@ -0,0 +1,358 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, firefox } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import '../../site/crypto.js'; + +const siteHtmlPath = pathToFileURL(resolve('site/index.html')).href; + +console.log('================================================================'); +console.log('CHALLENGER DEEP VERIFICATION SUITE (Milestone 3 - Communications Lifecycle)'); +console.log('================================================================'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext(); +const page = await context.newPage(); + +await page.goto(siteHtmlPath); +await page.waitForLoadState('domcontentloaded'); + +// ============================================================================ +// PART 1: CHANNELS & ACTIVITYPUB STREAM +// ============================================================================ +console.log('\n--- PART 1: CHANNELS DIRECTORY & MESSAGING STREAM ---'); + +await test('1.1 Messaging tab navigation and channel directory listing', async () => { + await page.locator('#tab-messaging').click(); + await page.waitForTimeout(200); + + const isVisible = await page.locator('#panel-messaging').isVisible(); + assert.ok(isVisible, 'Messaging panel must be visible'); + + const channelButtons = await page.locator('#channel-list .btn-switch-channel').allInnerTexts(); + assert.ok(channelButtons.some(b => b.includes('#general')), 'Channel #general must exist'); + assert.ok(channelButtons.some(b => b.includes('#governance')), 'Channel #governance must exist'); + assert.ok(channelButtons.some(b => b.includes('#operations')), 'Channel #operations must exist'); + + const activeChan = await page.locator('#active-channel-name').innerText(); + assert.equal(activeChan, '#general', 'Default active channel must be #general'); +}); + +await test('1.2 Channel creation and duplicate channel rejection', async () => { + // Create channel #announcements + await page.locator('#channel-name-input').fill('announcements'); + await page.locator('#channel-topic-input').fill('Organizational broadcasts'); + await page.locator('#channel-type-select').selectOption('Public'); + await page.locator('#btn-create-channel').click(); + await page.waitForTimeout(300); + + let channelButtons = await page.locator('#channel-list .btn-switch-channel').allInnerTexts(); + assert.ok(channelButtons.some(b => b.includes('#announcements')), 'New channel #announcements must appear in list'); + + // Attempt creating duplicate channel #announcements + let alertMessage = ''; + page.once('dialog', async (dialog) => { + alertMessage = dialog.message(); + await dialog.accept(); + }); + + await page.locator('#channel-name-input').fill('#announcements'); + await page.locator('#btn-create-channel').click(); + await page.waitForTimeout(300); + + assert.ok(alertMessage.includes('already exists in this organization'), `Expected duplicate channel alert, got: "${alertMessage}"`); +}); + +await test('1.3 Channel switching filters message activity stream', async () => { + // Click on #governance channel button + const govBtn = page.locator('#channel-list .btn-switch-channel', { hasText: '#governance' }); + await govBtn.click(); + await page.waitForTimeout(200); + + let activeChan = await page.locator('#active-channel-name').innerText(); + assert.equal(activeChan, '#governance', 'Active channel name must switch to #governance'); + + // Switch back to #general + const genBtn = page.locator('#channel-list .btn-switch-channel', { hasText: '#general' }); + await genBtn.click(); + await page.waitForTimeout(200); + + activeChan = await page.locator('#active-channel-name').innerText(); + assert.equal(activeChan, '#general', 'Active channel name must switch back to #general'); +}); + +await test('1.4 ActivityPub message dispatch with progressive delivery states', async () => { + const testSubject = 'Telemetry Sync'; + const testBody = 'Vector clock reconciled with all 3 replicas.'; + + await page.locator('#msg-subject').fill(testSubject); + await page.locator('#msg-body').fill(testBody); + await page.locator('#btn-dispatch-message').click(); + + // Verify message immediately appears in feed + await page.waitForTimeout(100); + let messagesText = await page.locator('#messages-list').innerText(); + assert.ok(messagesText.includes(testSubject), 'Message subject must be in activity stream'); + assert.ok(messagesText.includes(testBody), 'Message body must be in activity stream'); + + // Wait for delivery state progression: Sent -> Delivered -> Acknowledged + await page.waitForTimeout(300); + const statusBadge = await page.locator('#messages-list .message-card:last-child .msg-status-badge').innerText(); + assert.equal(statusBadge, 'Acknowledged', 'Message delivery status must reach Acknowledged'); +}); + +// ============================================================================ +// PART 2: 25 MIB ATTACHMENT BOUNDARY & PERSISTENT KAPPA STORE +// ============================================================================ +console.log('\n--- PART 2: 25 MIB ATTACHMENT BOUNDARY & KAPPA STORE INTEGRATION ---'); + +await test('2.1 Hostile / Oversized media attachment (> 25 MiB) is rejected', async () => { + let alertMessage = ''; + page.once('dialog', async (dialog) => { + alertMessage = dialog.message(); + await dialog.accept(); + }); + + // Inject a synthetic oversized File object (> 25 MiB) into the file input + await page.evaluate(() => { + const oversizedBytes = 25 * 1024 * 1024 + 1024; // 25 MiB + 1 KiB + const fakeFile = new File(['x'], 'huge_firmware.bin', { type: 'application/octet-stream' }); + Object.defineProperty(fakeFile, 'size', { value: oversizedBytes }); + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(fakeFile); + document.getElementById('msg-attachment-file').files = dataTransfer.files; + + document.getElementById('msg-subject').value = 'Exploit payload'; + document.getElementById('msg-body').value = 'Testing 25 MiB boundary enforcement'; + }); + + await page.locator('#btn-dispatch-message').click(); + await page.waitForTimeout(300); + + assert.ok( + alertMessage.includes('Attachment exceeds 25 MiB limit'), + `Expected 25 MiB rejection dialog, got: "${alertMessage}"` + ); +}); + +await test('2.2 Valid media attachment is hashed with SHA-256 and stored in Kappa store', async () => { + // Attach valid payload with accessible alt text + const payloadText = 'Decentralized Sovereign Object Specification v1.0'; + await page.evaluate((payload) => { + const file = new File([payload], 'spec_document.txt', { type: 'text/plain' }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + document.getElementById('msg-attachment-file').files = dataTransfer.files; + + document.getElementById('msg-attachment-alt').value = 'Full text of the pure-model architecture specification'; + document.getElementById('msg-subject').value = 'Specification Release'; + document.getElementById('msg-body').value = 'Attached is the verified architecture document.'; + }, payloadText); + + await page.locator('#btn-dispatch-message').click(); + await page.waitForTimeout(400); + + // Verify attachment card in message feed + const messagesHtml = await page.locator('#messages-list').innerHTML(); + assert.ok(messagesHtml.includes('Attachment: spec_document.txt'), 'Attachment card must be present'); + assert.ok(messagesHtml.includes('MIME: text/plain'), 'Attachment MIME must be text/plain'); + assert.ok(messagesHtml.includes('Kappa Digest: sha256:'), 'Kappa Digest must be displayed'); + assert.ok(messagesHtml.includes('Full text of the pure-model architecture specification'), 'Alt text must be rendered for accessibility'); + + // Verify blob can be retrieved directly from the persistent Kappa store + const kappaVerification = await page.evaluate(async (expectedPayload) => { + const uor = window.uorFoundry; + const lastMsg = uor.state.messages[uor.state.messages.length - 1]; + if (!lastMsg || !lastMsg.attachment) return { error: 'No attachment found in message' }; + + const digest = lastMsg.attachment.digest; + const blobRecord = await uor.getBlob(digest); + if (!blobRecord) return { error: 'Blob not found in Kappa store' }; + + const retrievedText = new TextDecoder().decode(new Uint8Array(blobRecord.data)); + return { + digest: blobRecord.digest, + match: retrievedText === expectedPayload, + length: blobRecord.length + }; + }, payloadText); + + assert.ok(kappaVerification.match, 'Retrieved Kappa blob content must match original payload byte-for-byte'); + assert.ok(kappaVerification.length > 0, 'Blob record length must be positive'); +}); + +// ============================================================================ +// PART 3: NOTIFICATIONS INBOX & CROSS-CUTTING EVENT TRIGGERS +// ============================================================================ +console.log('\n--- PART 3: NOTIFICATIONS INBOX & EVENT LIFECYCLE ---'); + +await test('3.1 Notifications Inbox bell, badge counter and panel dropdown toggle', async () => { + const badgeVal = await page.locator('#unread-notif-badge').innerText(); + assert.ok(parseInt(badgeVal, 10) >= 1, 'Unread badge must have count >= 1'); + + // Panel initially hidden + let isPanelHidden = await page.locator('#inbox-panel').getAttribute('hidden'); + assert.notEqual(isPanelHidden, null, 'Inbox panel must initially be hidden'); + + // Toggle open + await page.locator('#btn-inbox').click(); + await page.waitForTimeout(200); + + isPanelHidden = await page.locator('#inbox-panel').getAttribute('hidden'); + assert.equal(isPanelHidden, null, 'Inbox panel must be visible after click'); + let expanded = await page.locator('#btn-inbox').getAttribute('aria-expanded'); + assert.equal(expanded, 'true', 'Inbox button aria-expanded must be true'); + + // Toggle close + await page.locator('#btn-inbox').click(); + await page.waitForTimeout(200); + isPanelHidden = await page.locator('#inbox-panel').getAttribute('hidden'); + assert.notEqual(isPanelHidden, null, 'Inbox panel must be hidden after second click'); +}); + +await test('3.2 Cross-cutting event notifications: login, backup, invite, proposal', async () => { + // Open inbox panel + await page.locator('#btn-inbox').click(); + await page.waitForTimeout(200); + + // Trigger dispatchNotification programmatically and via actions + await page.evaluate(() => { + window.uorFoundry.dispatchNotification({ + event: 'SecurityLogin', + severity: 'Info', + title: 'WebCrypto Session Active', + summary: 'Session active for alice@uor.foundation', + route: '#panel-identity' + }); + }); + + await page.waitForTimeout(200); + const inboxText = await page.locator('#inbox-list').innerText(); + assert.ok(inboxText.includes('WebCrypto Session Active'), 'SecurityLogin notification must be listed'); + + // Test individual Mark Read button + const markReadBtn = page.locator('#inbox-list .btn-mark-read').first(); + if (await markReadBtn.isVisible()) { + await markReadBtn.click(); + await page.waitForTimeout(200); + } + + // Test Mark All Read button + await page.locator('#btn-mark-all-read').click(); + await page.waitForTimeout(200); + + const badgeValAfter = await page.locator('#unread-notif-badge').innerText(); + assert.equal(badgeValAfter, '0', 'Unread notification count must drop to 0 after Mark All Read'); +}); + +// ============================================================================ +// PART 4: SHARED WORKSPACES & MEMBER/TEAM DIRECTORIES +// ============================================================================ +console.log('\n--- PART 4: SHARED WORKSPACES & DIRECTORY (U50, U52) ---'); + +await test('4.1 Shared Workspaces directory and state synchronization', async () => { + await page.locator('#tab-workspaces').click(); + await page.waitForTimeout(200); + + const isVisible = await page.locator('#panel-workspaces').isVisible(); + assert.ok(isVisible, 'Workspaces panel must be visible'); + + // Create workspace + await page.locator('#ws-name-input').fill('Field Research Lab'); + await page.locator('#btn-create-workspace').click(); + await page.waitForTimeout(300); + + let wsTable = await page.locator('#workspace-tbody').innerText(); + assert.ok(wsTable.includes('Field Research Lab'), 'New workspace must be listed in directory'); + + // Mutate shared state + await page.locator('#ws-state-key').fill('telemetry_rate_hz'); + await page.locator('#ws-state-val').fill('25'); + await page.locator('#btn-mutate-workspace').click(); + await page.waitForTimeout(300); + + const stateTable = await page.locator('#workspace-state-tbody').innerText(); + assert.ok(stateTable.includes('telemetry_rate_hz'), 'State key must appear in synchronization table'); + assert.ok(stateTable.includes('25'), 'State value must appear in synchronization table'); +}); + +await test('4.2 Member and Team directories render verified identities and scopes', async () => { + await page.locator('#tab-organization').click(); + await page.waitForTimeout(200); + + const memberTable = await page.locator('#member-directory-list').innerText(); + assert.ok(memberTable.includes('alice@uor.foundation'), 'Alice must be in member directory'); + assert.ok(memberTable.includes('did:key:'), 'Member must hold valid DID'); + + const teamTable = await page.locator('#team-directory-list').innerText(); + assert.ok(teamTable.includes('Core Engineering'), 'Core Engineering must be in team directory'); +}); + +// ============================================================================ +// PART 5: MULTI-BROWSER AXE WCAG 2.2 AA ACCESSIBILITY +// ============================================================================ +console.log('\n--- PART 5: MULTI-BROWSER AXE WCAG 2.2 AA ACCESSIBILITY ---'); + +for (const browserType of [chromium, firefox]) { + const browserName = browserType.name(); + await test(`5.1 ${browserName}: Messaging tab and Notifications Inbox have 0 Axe violations`, async () => { + const b = await browserType.launch({ headless: true }); + const ctx = await b.newContext(); + const p = await ctx.newPage(); + await p.goto(siteHtmlPath); + await p.waitForLoadState('domcontentloaded'); + + // Test Messaging tab + await p.locator('#tab-messaging').click(); + await p.waitForTimeout(300); + const axeMessaging = await new AxeBuilder({ page: p }) + .include('#panel-messaging') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + assert.equal(axeMessaging.violations.length, 0, `${browserName} Messaging tab Axe violations: ${JSON.stringify(axeMessaging.violations)}`); + + // Test Inbox panel + await p.locator('#btn-inbox').click(); + await p.waitForTimeout(300); + const axeInbox = await new AxeBuilder({ page: p }) + .include('#inbox-panel') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + assert.equal(axeInbox.violations.length, 0, `${browserName} Inbox panel Axe violations: ${JSON.stringify(axeInbox.violations)}`); + + await b.close(); + }); +} + +await browser.close(); + +console.log('\n================================================================'); +console.log(`TOTAL TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +} diff --git a/tests/e2e/test_m3_projects.mjs b/tests/e2e/test_m3_projects.mjs new file mode 100644 index 0000000..6fe62c2 --- /dev/null +++ b/tests/e2e/test_m3_projects.mjs @@ -0,0 +1,326 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, firefox } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import '../../site/crypto.js'; + +const uorCrypto = globalThis.uorCrypto; +const siteHtmlPath = pathToFileURL(resolve('site/index.html')).href; + +console.log('================================================================'); +console.log('CHALLENGER DEEP VERIFICATION SUITE (Milestone 3 - Projects Lifecycle)'); +console.log('================================================================'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +// ============================================================================ +// PART 1: PROJECT NAVIGATION & METRICS +// ============================================================================ +console.log('\n--- PART 1: PROJECTS NAVIGATION & METRICS GRID ---'); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext(); +const page = await context.newPage(); + +await page.goto(siteHtmlPath); +await page.waitForLoadState('domcontentloaded'); + +await test('1.1 Projects tab navigation and metrics grid initial state', async () => { + await page.locator('#tab-projects').click(); + await page.waitForTimeout(200); + + const isVisible = await page.locator('#panel-projects').isVisible(); + assert.ok(isVisible, 'Projects panel must be visible after clicking tab'); + + const activeName = await page.locator('#project-active-name').innerText(); + assert.equal(activeName, 'Community Garden Platform', 'Initial active project must be Community Garden Platform'); + + const count = await page.locator('#project-count').innerText(); + assert.equal(count, '1', 'Initial active project count must be 1'); + + const progress = await page.locator('#project-milestone-progress').innerText(); + assert.equal(progress, '50%', 'Initial milestone progress must be 50%'); + + const releases = await page.locator('#project-release-count').innerText(); + assert.equal(releases, '1', 'Initial release count must be 1'); +}); + +// ============================================================================ +// PART 2: PROJECT CREATION & DUPLICATE REJECTION +// ============================================================================ +console.log('\n--- PART 2: PROJECT CREATION & DUPLICATE BOUNDARY REJECTION ---'); + +await test('1.2 Duplicate project name within same organization is rejected', async () => { + // Listen for alert dialog + let alertMessage = ''; + page.once('dialog', async (dialog) => { + alertMessage = dialog.message(); + await dialog.accept(); + }); + + await page.locator('#project-name-input').fill('Community Garden Platform'); + await page.locator('#btn-create-project').click(); + await page.waitForTimeout(300); + + assert.ok( + alertMessage.includes('Cannot create project with duplicate name'), + `Expected duplicate project alert, got: "${alertMessage}"` + ); +}); + +await test('1.3 Create new project, verify selector update and notification dispatch', async () => { + const newName = 'Urban Soil Sensor Network'; + const newSlug = 'soil-sensor-network'; + + await page.locator('#project-name-input').fill(newName); + await page.locator('#project-slug-input').fill(newSlug); + await page.locator('#project-desc-input').fill('IoT telemetry network for urban farming'); + await page.locator('#btn-create-project').click(); + await page.waitForTimeout(400); + + // Active project should switch to new project + const activeName = await page.locator('#project-active-name').innerText(); + assert.equal(activeName, newName, 'Active project must switch to newly created project'); + + const count = await page.locator('#project-count').innerText(); + assert.equal(count, '2', 'Active project count must increment to 2'); + + // Verify project select contains both projects + const options = await page.locator('#project-select option').allInnerTexts(); + assert.ok(options.some(opt => opt.includes(newName)), 'New project must be present in selector dropdown'); + + // Verify ProjectCreated notification was dispatched to inbox + const unreadBadge = await page.locator('#unread-notif-badge').innerText(); + assert.ok(parseInt(unreadBadge, 10) >= 1, 'Unread notification badge must reflect new notification'); +}); + +// ============================================================================ +// PART 3: PROJECT LIFECYCLE (ARCHIVE / RESTORE / DELETE) +// ============================================================================ +console.log('\n--- PART 3: PROJECT LIFECYCLE (ARCHIVE, RESTORE, DELETE SAFEGUARD) ---'); + +await test('1.4 Project Archive and Restore state transitions with tamper-evident logging', async () => { + // Archive project + await page.locator('#btn-archive-project').click(); + await page.waitForTimeout(300); + + let statusBadge = await page.locator('#project-status-badge').innerText(); + assert.equal(statusBadge, 'Archived', 'Project status must transition to Archived'); + + let activeCount = await page.locator('#project-count').innerText(); + assert.equal(activeCount, '1', 'Active project count must decrement when project is archived'); + + // Restore project + await page.locator('#btn-restore-project').click(); + await page.waitForTimeout(300); + + statusBadge = await page.locator('#project-status-badge').innerText(); + assert.equal(statusBadge, 'Active', 'Project status must transition back to Active'); + + activeCount = await page.locator('#project-count').innerText(); + assert.equal(activeCount, '2', 'Active project count must increment when project is restored'); +}); + +// ============================================================================ +// PART 4: MILESTONES & DELIVERABLES WITH SHA-256 DIGEST PROOFS +// ============================================================================ +console.log('\n--- PART 4: MILESTONES & DELIVERABLES WITH CRYPTOGRAPHIC VERIFICATION ---'); + +await test('1.5 Create Milestone and Deliverable in active project', async () => { + // Add milestone + await page.locator('#milestone-title-input').fill('Milestone 1: Prototype Ingestion'); + await page.locator('#milestone-date-input').fill('2026-11-15'); + await page.locator('#btn-add-milestone').click(); + await page.waitForTimeout(300); + + let milestonesText = await page.locator('#milestone-list').innerText(); + assert.ok(milestonesText.includes('Milestone 1: Prototype Ingestion'), 'Milestone must be visible in list'); + + // Add deliverable + await page.locator('#deliverable-title-input').fill('Telemetry Serialization Specs'); + await page.locator('#btn-add-deliverable').click(); + await page.waitForTimeout(300); + + milestonesText = await page.locator('#milestone-list').innerText(); + assert.ok(milestonesText.includes('Telemetry Serialization Specs'), 'Deliverable must be visible in list'); + assert.ok(milestonesText.includes('Complete & Verify'), 'Deliverable must display Complete & Verify button'); +}); + +await test('1.6 Deliverable verification produces genuine SHA-256 digest and completes milestone', async () => { + const verifyBtn = page.locator('#milestone-list .btn-verify-deliverable').first(); + await verifyBtn.click(); + await page.waitForTimeout(400); + + const milestonesText = await page.locator('#milestone-list').innerText(); + assert.ok(milestonesText.includes('Verified'), 'Deliverable status must be Verified'); + assert.ok(milestonesText.includes('sha256:'), 'Deliverable must display genuine SHA-256 digest'); + assert.ok(milestonesText.includes('Completed'), 'Milestone status must transition to Completed'); + + // Milestone progress metric should update to 100% + const progress = await page.locator('#project-milestone-progress').innerText(); + assert.equal(progress, '100%', 'Milestone progress must reach 100%'); +}); + +// ============================================================================ +// PART 5: TAMPER-EVIDENT SHA-256 ACTIVITY CHAIN & TAMPER DETECTION +// ============================================================================ +console.log('\n--- PART 5: TAMPER-EVIDENT ACTIVITY CHAIN & EMPIRICAL INTEGRITY ---'); + +await test('1.7 Project Activity Log maintains cryptographically verified SHA-256 hash chain', async () => { + const chainBadge = await page.locator('#activity-chain-status').innerText(); + assert.equal(chainBadge, 'Verified Chain', 'Activity chain status must be Verified Chain'); + + const activityText = await page.locator('#project-activity-list').innerText(); + assert.ok(activityText.includes('ProjectCreated'), 'Activity feed must log ProjectCreated'); + assert.ok(activityText.includes('MilestoneAdded'), 'Activity feed must log MilestoneAdded'); + assert.ok(activityText.includes('DeliverableVerified'), 'Activity feed must log DeliverableVerified'); +}); + +await test('1.8 Tamper detection: modifying predecessor digest causes chain invalidation', async () => { + const tamperResult = await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + if (!proj || proj.activityLog.length < 2) return { error: 'Insufficient log entries' }; + + // Tamper with the second entry's previous digest + const originalPrev = proj.activityLog[1].prev_entry_digest; + proj.activityLog[1].prev_entry_digest = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + + // Verify + const checkTampered = await uor.verifyActivityChain(proj.activityLog, proj.id); + + // Restore original + proj.activityLog[1].prev_entry_digest = originalPrev; + const checkRestored = await uor.verifyActivityChain(proj.activityLog, proj.id); + + return { + tamperedValid: checkTampered.valid, + tamperedError: checkTampered.error, + restoredValid: checkRestored.valid + }; + }); + + assert.equal(tamperResult.tamperedValid, false, 'Tampered log must be rejected by verification'); + assert.ok(tamperResult.tamperedError.includes('Predecessor mismatch'), 'Error must specify predecessor mismatch'); + assert.equal(tamperResult.restoredValid, true, 'Restored log must pass verification'); +}); + +// ============================================================================ +// PART 6: RELEASE DRAFTING & PUBLICATION IMMUTABILITY +// ============================================================================ +console.log('\n--- PART 6: PROJECT RELEASES & IMMUTABILITY ---'); + +await test('1.9 Draft release and publish, ensuring immutable transition', async () => { + await page.locator('#release-tag-input').fill('v0.2.0'); + await page.locator('#release-title-input').fill('Beta IoT Firmware'); + await page.locator('#release-notes-input').fill('Firmware deployment specs'); + await page.locator('#btn-create-release').click(); + await page.waitForTimeout(300); + + let releasesTable = await page.locator('#project-releases-tbody').innerText(); + assert.ok(releasesTable.includes('v0.2.0'), 'Release v0.2.0 must be drafted in table'); + assert.ok(releasesTable.includes('Draft'), 'Release v0.2.0 status must be Draft'); + + // Publish release + const publishBtn = page.locator('#project-releases-tbody .btn-publish-release').first(); + await publishBtn.click(); + await page.waitForTimeout(400); + + releasesTable = await page.locator('#project-releases-tbody').innerText(); + assert.ok(releasesTable.includes('Published'), 'Release status must update to Published'); + assert.ok(releasesTable.includes('Immutable'), 'Published release must be designated Immutable'); +}); + +// ============================================================================ +// PART 7: DYNAMIC ORGANIZATION RETIREMENT SAFEGUARD (M1/M2 INTEGRATION) +// ============================================================================ +console.log('\n--- PART 7: DYNAMIC ORG RETIREMENT SAFEGUARD WITH ACTIVE PROJECTS ---'); + +await test('1.10 Organization retirement is blocked when active dependent projects exist', async () => { + await page.locator('#tab-organization').click(); + await page.waitForTimeout(200); + + let alertMessage = ''; + page.once('dialog', async (dialog) => { + alertMessage = dialog.message(); + await dialog.accept(); + }); + + // Attempt to retire org while active projects exist + await page.locator('#btn-retire-org').click(); + await page.waitForTimeout(300); + + assert.ok( + alertMessage.includes('Cannot retire organization: 2 active dependent project(s) remain'), + `Expected active project retirement block, got: "${alertMessage}"` + ); + + const orgBadge = await page.locator('#org-lifecycle-badge').innerText(); + assert.notEqual(orgBadge, 'Retired', 'Organization must not be retired when active projects remain'); +}); + +// ============================================================================ +// PART 8: MULTI-BROWSER AXE WCAG 2.2 AA ACCESSIBILITY +// ============================================================================ +console.log('\n--- PART 8: MULTI-BROWSER AXE WCAG 2.2 AA ACCESSIBILITY ---'); + +for (const browserType of [chromium, firefox]) { + const browserName = browserType.name(); + await test(`1.11 ${browserName}: Projects & Workspaces tabs have 0 Axe violations`, async () => { + const b = await browserType.launch({ headless: true }); + const ctx = await b.newContext(); + const p = await ctx.newPage(); + await p.goto(siteHtmlPath); + await p.waitForLoadState('domcontentloaded'); + + // Test Projects tab + await p.locator('#tab-projects').click(); + await p.waitForTimeout(300); + const axeProjects = await new AxeBuilder({ page: p }) + .include('#panel-projects') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + assert.equal(axeProjects.violations.length, 0, `${browserName} Projects tab Axe violations: ${JSON.stringify(axeProjects.violations)}`); + + // Test Workspaces tab + await p.locator('#tab-workspaces').click(); + await p.waitForTimeout(300); + const axeWorkspaces = await new AxeBuilder({ page: p }) + .include('#panel-workspaces') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + assert.equal(axeWorkspaces.violations.length, 0, `${browserName} Workspaces tab Axe violations: ${JSON.stringify(axeWorkspaces.violations)}`); + + await b.close(); + }); +} + +await browser.close(); + +console.log('\n================================================================'); +console.log(`TOTAL TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +} diff --git a/tests/e2e/test_m3_projects_adversarial.mjs b/tests/e2e/test_m3_projects_adversarial.mjs new file mode 100644 index 0000000..91b0e50 --- /dev/null +++ b/tests/e2e/test_m3_projects_adversarial.mjs @@ -0,0 +1,402 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium, firefox } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import '../../site/crypto.js'; + +const siteHtmlPath = pathToFileURL(resolve('site/index.html')).href; + +console.log('================================================================'); +console.log('CHALLENGER EMPIRICAL ADVERSARIAL SUITE (Milestone 3 - Projects)'); +console.log('================================================================'); + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failureDetails = []; + +async function test(name, fn) { + totalTests++; + try { + await fn(); + console.log(` ✔ PASS: ${name}`); + passedTests++; + } catch (err) { + console.error(` ✖ FAIL: ${name}`); + console.error(` ${err.message}`); + failureDetails.push({ name, error: err.message, stack: err.stack }); + failedTests++; + } +} + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext(); +const page = await context.newPage(); + +await page.goto(siteHtmlPath); +await page.waitForLoadState('domcontentloaded'); + +// Ensure Projects tab is active +await page.locator('#tab-projects').click(); +await page.waitForTimeout(200); + +// ============================================================================ +// PART 1: GENESIS ACTIVITY LOG SPECIFICATION +// ============================================================================ +console.log('\n--- PART 1: GENESIS ACTIVITY LOG INTEGRITY (64 ZEROES) ---'); + +await test('2.1 Genesis entry has exactly 64 zeroes for prev_entry_digest', async () => { + const genesisCheck = await page.evaluate(() => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + if (!proj || !proj.activityLog || proj.activityLog.length === 0) { + return { error: 'No project activity log found' }; + } + const genesis = proj.activityLog[0]; + const prev = (genesis.prev_entry_digest || genesis.prevHash || '').replace(/^sha256:/, ''); + const actualDigest = (genesis.entry_digest || genesis.hash || '').replace(/^sha256:/, ''); + + return { + prev, + prevLength: prev.length, + isAllZeroes: /^0{64}$/.test(prev), + digestLength: actualDigest.length, + isValidHex: /^[0-9a-f]{64}$/.test(actualDigest) + }; + }); + + assert.equal(genesisCheck.prevLength, 64, 'Genesis prev_entry_digest must be exactly 64 characters'); + assert.equal(genesisCheck.isAllZeroes, true, 'Genesis prev_entry_digest must be exactly 64 zeroes'); + assert.equal(genesisCheck.digestLength, 64, 'Genesis entry_digest must be 64 characters'); + assert.equal(genesisCheck.isValidHex, true, 'Genesis entry_digest must be a valid 64-char hex string'); +}); + +// ============================================================================ +// PART 2: ADVERSARIAL ACTIVITY JOURNAL TAMPERING MATRIX +// ============================================================================ +console.log('\n--- PART 2: ADVERSARIAL JOURNAL TAMPERING MATRIX ---'); + +// Append some activity items to have a multi-entry chain +await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + await uor.appendProjectActivity(proj, 'ActionOne', 'alice@uor.foundation', 'Param A=10'); + await uor.appendProjectActivity(proj, 'ActionTwo', 'bob@uor.foundation', 'Param B=20'); + await uor.appendProjectActivity(proj, 'ActionThree', 'charlie@uor.foundation', 'Param C=30'); + await uor.updateProjectsUI(); +}); + +await test('2.2 Chain initially verifies cleanly before tampering', async () => { + const initialVerify = await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + return await uor.verifyActivityChain(proj.activityLog, proj.id); + }); + assert.equal(initialVerify.valid, true, 'Initial activity chain must be valid'); +}); + +await test('2.3 Tampering with historical details invalidates chain hash', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const target = proj.activityLog[1]; + const orig = target.details; + + target.details = 'MALICIOUS_DETAILS_OVERWRITE'; + const tampered = await uor.verifyActivityChain(proj.activityLog, proj.id); + + target.details = orig; + const restored = await uor.verifyActivityChain(proj.activityLog, proj.id); + + return { tamperedValid: tampered.valid, tamperedError: tampered.error, restoredValid: restored.valid }; + }); + + assert.equal(result.tamperedValid, false, 'Tampered details must be rejected'); + assert.ok(result.tamperedError.includes('Digest mismatch'), 'Error must report digest mismatch'); + assert.equal(result.restoredValid, true, 'Restored details must pass verification'); +}); + +await test('2.4 Tampering with historical actor invalidates chain hash', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const target = proj.activityLog[2]; + const orig = target.actor; + + target.actor = 'impostor@attacker.org'; + const tampered = await uor.verifyActivityChain(proj.activityLog, proj.id); + + target.actor = orig; + const restored = await uor.verifyActivityChain(proj.activityLog, proj.id); + + return { tamperedValid: tampered.valid, tamperedError: tampered.error, restoredValid: restored.valid }; + }); + + assert.equal(result.tamperedValid, false, 'Tampered actor must be rejected'); + assert.ok(result.tamperedError.includes('Digest mismatch'), 'Error must report digest mismatch'); + assert.equal(result.restoredValid, true, 'Restored actor must pass verification'); +}); + +await test('2.5 Tampering with historical timestamp invalidates chain hash', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const target = proj.activityLog[1]; + const orig = target.timestamp; + + target.timestamp = '1970-01-01T00:00:00.000Z'; + const tampered = await uor.verifyActivityChain(proj.activityLog, proj.id); + + target.timestamp = orig; + const restored = await uor.verifyActivityChain(proj.activityLog, proj.id); + + return { tamperedValid: tampered.valid, tamperedError: tampered.error, restoredValid: restored.valid }; + }); + + assert.equal(result.tamperedValid, false, 'Tampered timestamp must be rejected'); + assert.ok(result.tamperedError.includes('Digest mismatch'), 'Error must report digest mismatch'); + assert.equal(result.restoredValid, true, 'Restored timestamp must pass verification'); +}); + +await test('2.6 Tampering with historical action invalidates chain hash', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const target = proj.activityLog[2]; + const orig = target.action; + + target.action = 'UnauthorizedPrivilegeEscalation'; + const tampered = await uor.verifyActivityChain(proj.activityLog, proj.id); + + target.action = orig; + const restored = await uor.verifyActivityChain(proj.activityLog, proj.id); + + return { tamperedValid: tampered.valid, tamperedError: tampered.error, restoredValid: restored.valid }; + }); + + assert.equal(result.tamperedValid, false, 'Tampered action must be rejected'); + assert.ok(result.tamperedError.includes('Digest mismatch'), 'Error must report digest mismatch'); + assert.equal(result.restoredValid, true, 'Restored action must pass verification'); +}); + +await test('2.7 Splicing / deleting an intermediate entry breaks predecessor chain', async () => { + const result = await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const removed = proj.activityLog.splice(1, 1)[0]; + + const tampered = await uor.verifyActivityChain(proj.activityLog, proj.id); + + proj.activityLog.splice(1, 0, removed); + const restored = await uor.verifyActivityChain(proj.activityLog, proj.id); + + return { tamperedValid: tampered.valid, tamperedError: tampered.error, restoredValid: restored.valid }; + }); + + assert.equal(result.tamperedValid, false, 'Spliced entry must break predecessor chain'); + assert.ok(result.tamperedError.includes('Predecessor mismatch'), 'Error must report predecessor mismatch'); + assert.equal(result.restoredValid, true, 'Restored chain must pass verification'); +}); + +// ============================================================================ +// PART 3: DELIVERABLES PROOF & MILESTONE AUTO-COMPLETION +// ============================================================================ +console.log('\n--- PART 3: DELIVERABLE PROOFS & MILESTONE AUTO-COMPLETION ---'); + +await test('2.8 Multi-deliverable milestone auto-completes only when all deliverables are verified', async () => { + // Add test project with milestone and 2 deliverables via state and UI + await page.evaluate(async () => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + proj.milestones = proj.milestones || []; + const newM = { + id: 'm-auto-test', + title: 'Auto-Complete Stress Milestone', + dueDate: '2026-12-31', + status: 'Planned', + deliverables: [ + { id: 'del-1', title: 'Specification Doc', status: 'Pending', digest: null }, + { id: 'del-2', title: 'Implementation Bin', status: 'Pending', digest: null } + ] + }; + proj.milestones.push(newM); + await uor.updateProjectsUI(); + }); + + // Verify initial milestone state + let milestoneStatus = await page.evaluate(() => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const m = proj.milestones.find(item => item.id === 'm-auto-test'); + return m.status; + }); + assert.equal(milestoneStatus, 'Planned', 'Initial milestone status must be Planned'); + + // Verify Deliverable 1 + const del1Btn = page.locator('button[data-mid="m-auto-test"][data-did="del-1"]'); + await del1Btn.click(); + await page.waitForTimeout(300); + + // Check state after Deliverable 1 verified + let stateAfterDel1 = await page.evaluate(() => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const m = proj.milestones.find(item => item.id === 'm-auto-test'); + return { + mStatus: m.status, + del1Status: m.deliverables[0].status, + del1Digest: m.deliverables[0].digest, + del2Status: m.deliverables[1].status + }; + }); + + assert.equal(stateAfterDel1.del1Status, 'Verified', 'Del 1 status must be Verified'); + assert.ok(stateAfterDel1.del1Digest.startsWith('sha256:'), 'Del 1 digest must have sha256: prefix'); + assert.equal(stateAfterDel1.del1Digest.length, 71, 'Del 1 digest must be sha256: + 64 hex chars'); + assert.notEqual(stateAfterDel1.mStatus, 'Completed', 'Milestone must NOT be Completed when Del 2 is still Pending'); + + // Verify Deliverable 2 + const del2Btn = page.locator('button[data-mid="m-auto-test"][data-did="del-2"]'); + await del2Btn.click(); + await page.waitForTimeout(300); + + // Check state after Deliverable 2 verified + let stateAfterDel2 = await page.evaluate(() => { + const uor = window.uorFoundry; + const proj = uor.state.projects.find(p => p.id === uor.state.activeProjectId); + const m = proj.milestones.find(item => item.id === 'm-auto-test'); + return { + mStatus: m.status, + del1Status: m.deliverables[0].status, + del2Status: m.deliverables[1].status, + del2Digest: m.deliverables[1].digest + }; + }); + + assert.equal(stateAfterDel2.del2Status, 'Verified', 'Del 2 status must be Verified'); + assert.ok(stateAfterDel2.del2Digest.startsWith('sha256:'), 'Del 2 digest must have sha256: prefix'); + assert.equal(stateAfterDel2.mStatus, 'Completed', 'Milestone must AUTO-COMPLETE when all deliverables are Verified'); +}); + +// ============================================================================ +// PART 4: ORGANIZATION RETIREMENT DYNAMIC SAFEGUARDS +// ============================================================================ +console.log('\n--- PART 4: ORG RETIREMENT DYNAMIC SAFEGUARD VARIATIONS ---'); + +await test('2.9 Org retirement blocked with active projects; succeeds when projects are archived', async () => { + await page.locator('#tab-organization').click(); + await page.waitForTimeout(200); + + // 1. Ensure retirement blocked while active projects exist + let alertTriggered = false; + page.once('dialog', async (dialog) => { + alertTriggered = true; + await dialog.accept(); + }); + await page.locator('#btn-retire-org').click(); + await page.waitForTimeout(200); + assert.equal(alertTriggered, true, 'Alert must be triggered when retiring with active projects'); + + let orgState = await page.evaluate(() => { + const uor = window.uorFoundry; + const org = uor.state.organizations.find(o => o.id === uor.state.activeOrgId); + return org.state; + }); + assert.notEqual(orgState, 'Retired', 'Org must not be retired when blocked'); + + // 2. Archive all projects belonging to this org + await page.evaluate(async () => { + const uor = window.uorFoundry; + for (const p of uor.state.projects) { + if (p.orgId === uor.state.activeOrgId) { + p.state = 'Archived'; + } + } + }); + + // Verify active count is now 0 + const activeCount = await page.evaluate(() => { + const uor = window.uorFoundry; + return uor.state.projects.filter(p => p.orgId === uor.state.activeOrgId && p.state === 'Active').length; + }); + assert.equal(activeCount, 0, 'Active project count must be 0 after archiving all projects'); + + // 3. Attempt retirement without 2 admins -> M2 quorum safeguard rejects + await page.locator('#btn-retire-org').click(); + await page.waitForTimeout(200); + + orgState = await page.evaluate(() => { + const uor = window.uorFoundry; + const org = uor.state.organizations.find(o => o.id === uor.state.activeOrgId); + return org.state; + }); + assert.notEqual(orgState, 'Retired', 'Org must not be retired when admin quorum (<2 admins) is not met'); + + // 4. Add second administrator to satisfy M2 quorum + await page.evaluate(() => { + const uor = window.uorFoundry; + const org = uor.state.organizations.find(o => o.id === uor.state.activeOrgId); + org.admins.push({ email: 'bob@uor.foundation', scope: 'Organization' }); + }); + + // 5. Attempt retirement now with 2 admins and 0 active projects -> MUST SUCCEED + await page.locator('#btn-retire-org').click(); + await page.waitForTimeout(300); + + orgState = await page.evaluate(() => { + const uor = window.uorFoundry; + const org = uor.state.organizations.find(o => o.id === uor.state.activeOrgId); + return org.state; + }); + assert.equal(orgState, 'Retired', 'Org must transition to Retired when active project count is 0 and quorum is met'); + + const lifecycleBadge = await page.locator('#org-lifecycle-badge').innerText(); + assert.equal(lifecycleBadge, 'Retired', 'DOM badge must display Retired'); +}); + +await test('2.10 Force override allows retirement even with active projects', async () => { + // Reset org state to Provisional, reactivate a project, and check force override + await page.evaluate(() => { + const uor = window.uorFoundry; + const org = uor.state.organizations.find(o => o.id === uor.state.activeOrgId); + org.state = 'Provisional'; + // Re-activate first project + if (uor.state.projects.length > 0) { + uor.state.projects[0].state = 'Active'; + } + }); + + const activeCount = await page.evaluate(() => { + const uor = window.uorFoundry; + return uor.state.projects.filter(p => p.orgId === uor.state.activeOrgId && p.state === 'Active').length; + }); + assert.equal(activeCount, 1, 'Active project count should be 1'); + + // Enable force override checkbox + await page.locator('#retire-force-override').check(); + await page.waitForTimeout(100); + + // Click retire + await page.locator('#btn-retire-org').click(); + await page.waitForTimeout(300); + + const orgState = await page.evaluate(() => { + const uor = window.uorFoundry; + const org = uor.state.organizations.find(o => o.id === uor.state.activeOrgId); + return org.state; + }); + assert.equal(orgState, 'Retired', 'Org must retire when force_override is enabled despite active projects'); +}); + +await browser.close(); + +console.log('\n================================================================'); +console.log(`TOTAL ADVERSARIAL TESTS: ${totalTests}`); +console.log(`PASSED: ${passedTests}`); +console.log(`FAILED: ${failedTests}`); +console.log('================================================================'); + +if (failedTests > 0) { + process.exit(1); +}