From b0c00cb7242266510bb2a5a4af3953d1915edc14 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Mon, 8 Jun 2026 15:17:25 +1200 Subject: [PATCH] Add local and staging HTTP integration test harness. integration_e2e.mjs exercises SIWE session, JWT/JWKS, refresh, logout, quota, and expose-split checks (staging). Shell wrappers start a local server or target the deployed staging Cloud Run URLs. --- scripts/run-integration-e2e-staging.sh | 9 + scripts/run-integration-e2e.sh | 43 +++ tests/polyglot/nodejs/integration_e2e.mjs | 329 ++++++++++++++++++++++ tests/polyglot/nodejs/package.json | 5 +- 4 files changed, 385 insertions(+), 1 deletion(-) create mode 100755 scripts/run-integration-e2e-staging.sh create mode 100755 scripts/run-integration-e2e.sh create mode 100644 tests/polyglot/nodejs/integration_e2e.mjs diff --git a/scripts/run-integration-e2e-staging.sh b/scripts/run-integration-e2e-staging.sh new file mode 100755 index 0000000..b2df9ef --- /dev/null +++ b/scripts/run-integration-e2e-staging.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Run the full HTTP integration suite against deployed staging trust-relay. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}/tests/polyglot/nodejs" + +npm ci --silent +TRUST_RELAY_TARGET=staging npm run integration diff --git a/scripts/run-integration-e2e.sh b/scripts/run-integration-e2e.sh new file mode 100755 index 0000000..bcbf7fc --- /dev/null +++ b/scripts/run-integration-e2e.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Start trust-relay locally and run the full HTTP integration suite. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" +BASE="http://127.0.0.1:${PORT}" + +export APP_SERVER__HOST=127.0.0.1 +export APP_SERVER__PORT="${PORT}" +export APP_SERVER__EXPOSE=all +export APP_SIGNING__ISSUER="${BASE}" +export APP_AUTH__URI="${BASE}" +export APP_AUTH__DOMAIN=localhost +export APP_TELEMETRY__FORMAT=json +export APP_TELEMETRY__FILTER=warn + +cargo build --quiet --bin trust-relay +./target/debug/trust-relay & +PID=$! +trap 'kill "${PID}" 2>/dev/null || true' EXIT + +for _ in $(seq 1 100); do + if curl -sf "${BASE}/healthz" >/dev/null; then + break + fi + sleep 0.05 +done +curl -sf "${BASE}/healthz" >/dev/null + +cd tests/polyglot/nodejs +npm ci --silent + +export TRUST_RELAY_TARGET=local +export TRUST_RELAY_PUBLIC_URL="${BASE}" +export TRUST_RELAY_ISSUER="${BASE}" +export TRUST_RELAY_AUTH_URI="${BASE}" +export TRUST_RELAY_AUTH_DOMAIN=localhost +export TRUST_RELAY_SIGNING_KID=dev-key-1 + +npm run integration diff --git a/tests/polyglot/nodejs/integration_e2e.mjs b/tests/polyglot/nodejs/integration_e2e.mjs new file mode 100644 index 0000000..941ea49 --- /dev/null +++ b/tests/polyglot/nodejs/integration_e2e.mjs @@ -0,0 +1,329 @@ +/** + * Full HTTP integration test for trust-relay (local or staging). + * + * Presets via TRUST_RELAY_TARGET: + * local — http://127.0.0.1:3001, localhost SIWE (default dev config) + * staging — GCP Cloud Run public/internal split deploy + * + * Override any field with env vars (see PRESETS below). + * + * Run from this directory after `npm ci`: + * npm run integration:local + * npm run integration:staging + */ + +import assert from "node:assert/strict"; +import { privateKeyToAccount } from "viem/accounts"; +import * as jose from "jose"; +import { SiweMessage } from "siwe"; + +const PRESETS = { + local: { + publicUrl: "http://127.0.0.1:3001", + internalUrl: null, + authDomain: "localhost", + authUri: "http://127.0.0.1:3001", + issuer: "http://127.0.0.1:3001", + signingKid: "dev-key-1", + testExposeSplit: false, + }, + staging: { + publicUrl: "https://trust-relay-public-fi2qaxvqxa-uw.a.run.app", + internalUrl: "https://trust-relay-internal-fi2qaxvqxa-uw.a.run.app", + authDomain: "staging.nodle.com", + authUri: "https://staging.nodle.com", + issuer: "https://trust-relay-public-fi2qaxvqxa-uw.a.run.app", + signingKid: "2026-staging-key-1", + testExposeSplit: true, + }, +}; + +const target = process.env.TRUST_RELAY_TARGET ?? "local"; +const preset = PRESETS[target]; +if (!preset) { + console.error(`Unknown TRUST_RELAY_TARGET=${target}; use local or staging`); + process.exit(2); +} + +const PUBLIC_URL = process.env.TRUST_RELAY_PUBLIC_URL ?? preset.publicUrl; +const INTERNAL_URL = + process.env.TRUST_RELAY_INTERNAL_URL ?? preset.internalUrl ?? null; +const AUTH_DOMAIN = process.env.TRUST_RELAY_AUTH_DOMAIN ?? preset.authDomain; +const AUTH_URI = process.env.TRUST_RELAY_AUTH_URI ?? preset.authUri; +const ISSUER = process.env.TRUST_RELAY_ISSUER ?? preset.issuer; +const SIGNING_KID = process.env.TRUST_RELAY_SIGNING_KID ?? preset.signingKid; +const AUDIENCE = process.env.TRUST_RELAY_AUDIENCE ?? "nodle-backend"; +const CHAIN_ID = Number(process.env.TRUST_RELAY_CHAIN_ID ?? "324"); +const TEST_EXPOSE_SPLIT = + process.env.TRUST_RELAY_TEST_EXPOSE_SPLIT === "1" || + (process.env.TRUST_RELAY_TEST_EXPOSE_SPLIT !== "0" && preset.testExposeSplit); + +const TEST_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const TEST_WALLET = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + +const results = []; +let failed = 0; + +function pass(name) { + results.push({ name, ok: true }); + console.log(`✓ ${name}`); +} + +function fail(name, err) { + results.push({ name, ok: false, err: String(err) }); + console.error(`✗ ${name}: ${err}`); + failed += 1; +} + +async function check(name, fn) { + try { + await fn(); + pass(name); + } catch (e) { + fail(name, e); + } +} + +async function fetchStatus(url, init = {}) { + const res = await fetch(url, init); + return { res, status: res.status, body: await res.text() }; +} + +async function siweSession(baseUrl) { + const account = privateKeyToAccount(TEST_PRIVATE_KEY); + const { res: nonceRes, body: nonceBody } = await fetchStatus( + `${baseUrl}/v1/auth/nonce`, + ); + assert.equal(nonceRes.status, 200, `nonce: ${nonceBody}`); + const { nonce } = JSON.parse(nonceBody); + + const siwe = new SiweMessage({ + domain: AUTH_DOMAIN, + address: account.address, + uri: AUTH_URI, + version: "1", + chainId: CHAIN_ID, + nonce, + }); + const message = siwe.prepareMessage(); + const signature = await account.signMessage({ message }); + + const { res: sessionRes, body: sessionBody } = await fetchStatus( + `${baseUrl}/v1/auth/session`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message, signature }), + }, + ); + assert.equal(sessionRes.status, 200, `session: ${sessionBody}`); + return JSON.parse(sessionBody); +} + +async function verifyJwt(accessToken) { + const jwks = jose.createRemoteJWKSet( + new URL(`${PUBLIC_URL}/.well-known/jwks.json`), + ); + const { payload, protectedHeader } = await jose.jwtVerify( + accessToken, + jwks, + { issuer: ISSUER, audience: AUDIENCE }, + ); + assert.equal(protectedHeader.typ, "at+jwt"); + assert.equal(payload.sub, TEST_WALLET); + assert.equal(protectedHeader.kid, SIGNING_KID); + return payload; +} + +console.log(`Integration E2E [${target}] → public=${PUBLIC_URL}`); +console.log( + ` SIWE domain=${AUTH_DOMAIN} uri=${AUTH_URI} issuer=${ISSUER} kid=${SIGNING_KID}`, +); +if (TEST_EXPOSE_SPLIT) { + console.log(` expose-split checks enabled (internal=${INTERNAL_URL})`); +} +console.log(); + +await check("JWKS returns Ed25519 key", async () => { + const { status, body } = await fetchStatus( + `${PUBLIC_URL}/.well-known/jwks.json`, + ); + assert.equal(status, 200); + const jwks = JSON.parse(body); + assert.ok(Array.isArray(jwks.keys) && jwks.keys.length >= 1); + assert.equal(jwks.keys[0].kid, SIGNING_KID); + assert.equal(jwks.keys[0].alg, "EdDSA"); +}); + +await check("GET /v1/auth/nonce", async () => { + const { status, body } = await fetchStatus(`${PUBLIC_URL}/v1/auth/nonce`); + assert.equal(status, 200); + const json = JSON.parse(body); + assert.ok(json.nonce?.length > 0); + assert.ok(json.expiresAt); +}); + +let session = null; +await check("POST /v1/auth/session (SIWE) + JWT verify", async () => { + session = await siweSession(PUBLIC_URL); + assert.equal(session.tokenType, "Bearer"); + assert.equal(session.walletAddress, TEST_WALLET); + assert.ok(session.accessToken); + assert.ok(session.refreshToken); + await verifyJwt(session.accessToken); +}); + +await check("Nonce replay rejected", async () => { + const account = privateKeyToAccount(TEST_PRIVATE_KEY); + const { body: nonceBody } = await fetchStatus(`${PUBLIC_URL}/v1/auth/nonce`); + const { nonce } = JSON.parse(nonceBody); + const siwe = new SiweMessage({ + domain: AUTH_DOMAIN, + address: account.address, + uri: AUTH_URI, + version: "1", + chainId: CHAIN_ID, + nonce, + }); + const message = siwe.prepareMessage(); + const signature = await account.signMessage({ message }); + const body = JSON.stringify({ message, signature }); + + const first = await fetchStatus(`${PUBLIC_URL}/v1/auth/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + assert.equal(first.status, 200, first.body); + + const replay = await fetchStatus(`${PUBLIC_URL}/v1/auth/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + assert.notEqual( + replay.status, + 200, + `expected replay failure, got ${replay.status}`, + ); +}); + +await check("GET /v1/auth/quota with bearer", async () => { + const { status, body } = await fetchStatus(`${PUBLIC_URL}/v1/auth/quota`, { + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + assert.equal(status, 200, body); + const json = JSON.parse(body); + assert.ok(Object.keys(json).length > 0); +}); + +await check("POST /v1/auth/session/refresh", async () => { + const { status, body } = await fetchStatus( + `${PUBLIC_URL}/v1/auth/session/refresh`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: session.refreshToken }), + }, + ); + assert.equal(status, 200, body); + const json = JSON.parse(body); + assert.ok(json.accessToken); + assert.ok(json.refreshToken); + await verifyJwt(json.accessToken); + session = { + ...session, + accessToken: json.accessToken, + refreshToken: json.refreshToken, + }; +}); + +await check("POST /v1/auth/logout returns 204", async () => { + const { status, body } = await fetchStatus(`${PUBLIC_URL}/v1/auth/logout`, { + method: "POST", + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + assert.equal(status, 204, body); +}); + +await check("Refresh after logout (200 or 401/403)", async () => { + const { status } = await fetchStatus( + `${PUBLIC_URL}/v1/auth/session/refresh`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: session.refreshToken }), + }, + ); + assert.ok( + status === 200 || status === 401 || status === 403, + `refresh after logout: ${status}`, + ); +}); + +if (TEST_EXPOSE_SPLIT && INTERNAL_URL) { + await check("POST /v1/auth/quota/consume absent on public (404)", async () => { + const fresh = await siweSession(PUBLIC_URL); + const { status } = await fetchStatus( + `${PUBLIC_URL}/v1/auth/quota/consume`, + { + method: "POST", + headers: { + Authorization: `Bearer ${fresh.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ scope: "ai:invoke", amount: 1 }), + }, + ); + assert.equal(status, 404); + }); + + await check("GET /v1/auth/nonce absent on internal (404/403)", async () => { + const { status } = await fetchStatus(`${INTERNAL_URL}/v1/auth/nonce`); + assert.ok(status === 404 || status === 403, `got ${status}`); + }); + + await check("Internal consume blocked from internet (403/404)", async () => { + const fresh = await siweSession(PUBLIC_URL); + const { status } = await fetchStatus( + `${INTERNAL_URL}/v1/auth/quota/consume`, + { + method: "POST", + headers: { + Authorization: `Bearer ${fresh.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ scope: "ai:invoke", amount: 1 }), + }, + ); + assert.ok(status === 403 || status === 404, `got ${status}`); + }); +} else { + await check("POST /v1/auth/quota/consume on unified local surface", async () => { + const fresh = await siweSession(PUBLIC_URL); + const { status, body } = await fetchStatus( + `${PUBLIC_URL}/v1/auth/quota/consume`, + { + method: "POST", + headers: { + Authorization: `Bearer ${fresh.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ scope: "ai:invoke", amount: 1 }), + }, + ); + assert.equal(status, 200, body); + const json = JSON.parse(body); + assert.equal(json.scope, "ai:invoke"); + assert.ok(typeof json.remaining === "number"); + }); +} + +console.log( + `\n--- Summary: ${results.length - failed}/${results.length} passed ---`, +); +if (failed > 0) { + console.error(JSON.stringify(results.filter((r) => !r.ok), null, 2)); + process.exit(1); +} diff --git a/tests/polyglot/nodejs/package.json b/tests/polyglot/nodejs/package.json index 0a63ac8..0a458ce 100644 --- a/tests/polyglot/nodejs/package.json +++ b/tests/polyglot/nodejs/package.json @@ -3,7 +3,10 @@ "private": true, "type": "module", "scripts": { - "test": "node --test trust_relay_e2e.test.mjs" + "test": "node --test trust_relay_e2e.test.mjs", + "integration": "node integration_e2e.mjs", + "integration:local": "TRUST_RELAY_TARGET=local node integration_e2e.mjs", + "integration:staging": "TRUST_RELAY_TARGET=staging node integration_e2e.mjs" }, "dependencies": { "jose": "^6.0.11",