Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions scripts/run-integration-e2e-staging.sh
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions scripts/run-integration-e2e.sh
Original file line number Diff line number Diff line change
@@ -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
329 changes: 329 additions & 0 deletions tests/polyglot/nodejs/integration_e2e.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 4 additions & 1 deletion tests/polyglot/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading