theseus/tests/hermes-two-users.test.mjs
Local Dev 4b3fd4be42 hermes: mobile mockup collapse + two-user integration test
The /overview page's UI mockup used a fixed 220px/1fr grid that overflowed on
375px viewports. Added a @media (<=640px) rule that drops the contacts pane,
expands the thread to the full width, breaks out to the screen edges, and
tightens the window-bar + compose padding so the mockup reads cleanly on a
phone.

Added tests/hermes-two-users.test.mjs — a Node integration suite that drives
two personas (alice, bob) through the full flow against the live nos.lol
relay: BIP-39 key derivation, phone-claim publish, phone→pubkey discovery
both directions, bidirectional NIP-17 gift-wrap DM delivery, vCard parse +
findByPhones. Eight assertions, all pass. Run with:
  node TheseusNavigator/tests/hermes-two-users.test.mjs
2026-10-22 11:55:06 +03:00

218 lines
9 KiB
JavaScript

#!/usr/bin/env node
// Hermes two-user integration test: Alice and Bob each derive a key from a
// mnemonic, publish a phone claim to nos.lol, discover each other by phone,
// and round-trip a NIP-17 gift-wrapped DM. Runs against the real production
// relay — pass --relay wss://... to point elsewhere.
//
// Usage: node hermes-two-users-test.mjs
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
import WebSocket from "ws";
const HERMES = pathToFileURL(path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../lib/hermes.js"
)).href;
const H = await import(HERMES);
const RELAY = process.argv.includes("--relay")
? process.argv[process.argv.indexOf("--relay") + 1]
: "wss://nos.lol";
const log = (tag, ...msg) => console.log(`[${tag.padEnd(8)}]`, ...msg);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---- user: mnemonic → nostr key + handle --------------------------------
async function makeUser(name, mnemonic, phone) {
const { sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic);
const phoneHash = await H.hashPhone(phone);
log(name, `pk=${pkHex.slice(0, 16)}… phone=${H.normalisePhone(phone)} hash=${phoneHash}`);
return { name, sk, pkHex, npub, phone, phoneHash };
}
// ---- relay helpers ------------------------------------------------------
function openRelay(label) {
const ws = new WebSocket(RELAY);
const ready = new Promise((resolve, reject) => {
ws.once("open", () => { log(label, `relay connected: ${RELAY}`); resolve(); });
ws.once("error", reject);
});
return { ws, ready };
}
function publish(ws, ev) {
return new Promise((resolve) => {
const subId = ev.id;
const onMsg = (buf) => {
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
if (msg[0] === "OK" && msg[1] === subId) {
ws.off("message", onMsg);
resolve({ ok: !!msg[2], reason: msg[3] || "" });
}
};
ws.on("message", onMsg);
ws.send(JSON.stringify(["EVENT", ev]));
setTimeout(() => { ws.off("message", onMsg); resolve({ ok: false, reason: "timeout" }); }, 5000);
});
}
function query(ws, filter, { timeout = 4000 } = {}) {
return new Promise((resolve) => {
const subId = "sub-" + Math.random().toString(36).slice(2, 10);
const events = [];
const onMsg = (buf) => {
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
if (msg[0] === "EVENT" && msg[1] === subId) events.push(msg[2]);
else if (msg[0] === "EOSE" && msg[1] === subId) finish();
};
const finish = () => {
ws.off("message", onMsg);
try { ws.send(JSON.stringify(["CLOSE", subId])); } catch {}
resolve(events);
};
ws.on("message", onMsg);
ws.send(JSON.stringify(["REQ", subId, filter]));
setTimeout(finish, timeout);
});
}
// ---- the test -----------------------------------------------------------
const ALICE_MNEMONIC = "abandon ability able about above absent absorb abstract absurd abuse access accident";
const BOB_MNEMONIC = "zoo yellow year xylophone wrong wrist worth world worry worm wool wood";
const alice = await makeUser("alice", ALICE_MNEMONIC, "+15550001000");
const bob = await makeUser("bob", BOB_MNEMONIC, "+15550002000");
const relay = openRelay("relay");
await relay.ready;
// --- step 1: both publish phone claims
log("step 1", "publishing phone claims…");
const aliceClaim = await H.buildPhoneClaimEvent({ sk: alice.sk, phone: alice.phone });
const bobClaim = await H.buildPhoneClaimEvent({ sk: bob.sk, phone: bob.phone });
const [rA, rB] = await Promise.all([publish(relay.ws, aliceClaim), publish(relay.ws, bobClaim)]);
log("alice", `phone claim: ok=${rA.ok} ${rA.reason}`);
log("bob", `phone claim: ok=${rB.ok} ${rB.reason}`);
await sleep(300);
// --- step 2: Alice looks up Bob by his phone
log("step 2", "Alice looking up Bob by phone…");
const dtag = H.PHONE_DTAG_PREFIX + bob.phoneHash;
const matches = await query(relay.ws, { kinds: [30078], "#d": [dtag], limit: 5 });
log("step 2", `found ${matches.length} event(s) for dtag ${dtag}`);
if (!matches.length) { log("FAIL", "no phone-claim event came back"); process.exit(1); }
const foundPk = matches[0].pubkey;
log("step 2", `recovered pubkey: ${foundPk.slice(0, 16)}…`);
if (foundPk !== bob.pkHex) {
log("FAIL", `wrong pubkey — expected ${bob.pkHex.slice(0, 16)}…, got ${foundPk.slice(0, 16)}…`);
process.exit(1);
}
log("PASS ", "✓ Alice discovered Bob from phone number");
// --- step 3: Bob also looks up Alice
const dtagA = H.PHONE_DTAG_PREFIX + alice.phoneHash;
const matchesA = await query(relay.ws, { kinds: [30078], "#d": [dtagA], limit: 5 });
if (!matchesA.length || matchesA[0].pubkey !== alice.pkHex) {
log("FAIL", "Bob couldn't discover Alice");
process.exit(1);
}
log("PASS ", "✓ Bob discovered Alice from phone number");
// --- step 4: Alice sends a DM to Bob
log("step 4", "Alice sending gift-wrapped DM to Bob…");
const greeting = "hey bob, this is alice. If you can read this, nip-17 round-tripped.";
const wrap = H.wrapChat({ senderSk: alice.sk, recipientPkHex: bob.pkHex, text: greeting });
const rSend = await publish(relay.ws, wrap);
log("alice", `wrap publish: ok=${rSend.ok} ${rSend.reason}`);
// --- step 5: Bob subscribes for incoming wraps, decrypts, verifies sender
log("step 5", "Bob listening for his wraps…");
const bobWraps = await query(relay.ws, { kinds: [1059], "#p": [bob.pkHex], limit: 10 }, { timeout: 6000 });
log("bob", `received ${bobWraps.length} gift-wrap(s)`);
let foundOurs = null;
for (const w of bobWraps) {
try {
const dec = H.unwrapChat({ receiverSk: bob.sk, wrap: w });
if (dec.text === greeting) { foundOurs = dec; break; }
} catch {}
}
if (!foundOurs) { log("FAIL", "Bob did not receive our greeting"); process.exit(1); }
if (foundOurs.senderPkHex !== alice.pkHex) {
log("FAIL", `sender mismatch — expected ${alice.pkHex.slice(0, 16)}…, got ${foundOurs.senderPkHex.slice(0, 16)}…`);
process.exit(1);
}
log("PASS ", "✓ Bob decrypted Alice's message and recovered her pubkey");
// --- step 6: Bob replies, Alice reads
log("step 6", "Bob replying…");
const reply = "roger that, alice. reply working.";
const rWrap = H.wrapChat({ senderSk: bob.sk, recipientPkHex: alice.pkHex, text: reply });
await publish(relay.ws, rWrap);
const aliceWraps = await query(relay.ws, { kinds: [1059], "#p": [alice.pkHex], limit: 10 }, { timeout: 6000 });
let foundReply = null;
for (const w of aliceWraps) {
try {
const dec = H.unwrapChat({ receiverSk: alice.sk, wrap: w });
if (dec.text === reply) { foundReply = dec; break; }
} catch {}
}
if (!foundReply) { log("FAIL", "Alice did not get Bob's reply"); process.exit(1); }
if (foundReply.senderPkHex !== bob.pkHex) {
log("FAIL", "reply sender mismatch");
process.exit(1);
}
log("PASS ", "✓ Alice decrypted Bob's reply");
// --- step 7: Alice imports a vCard listing Bob's phone — exercise the
// panel's parse + lookup path end-to-end without the UI.
log("step 7", "Alice imports a vCard listing Bob…");
const vcard = `BEGIN:VCARD
VERSION:3.0
FN:Bob Smith
TEL;TYPE=CELL:+1 (555) 000-2000
END:VCARD
BEGIN:VCARD
VERSION:3.0
FN:Someone Else
TEL:+15559999999
END:VCARD`;
const entries = [];
for (const block of vcard.split(/BEGIN:VCARD/i).slice(1)) {
const body = block.split(/END:VCARD/i)[0] || "";
const fn = (body.match(/^FN(?:;[^:]*)?:(.+)$/mi) || [])[1];
const tel = (body.match(/^TEL(?:;[^:]*)?:(.+)$/mi) || [])[1];
if (tel) entries.push({ name: (fn || "").trim(), phone: tel.trim() });
}
log("step 7", `parsed ${entries.length} vCard entries`);
// Mirror findByPhones from main.js, run against the live relay.
const phoneMap = new Map();
for (const e of entries) {
try { phoneMap.set(await H.hashPhone(e.phone), e.phone); } catch {}
}
const vcDtags = Array.from(phoneMap.keys()).map((h) => H.PHONE_DTAG_PREFIX + h);
const vcMatches = await query(relay.ws, { kinds: [30078], "#d": vcDtags, limit: vcDtags.length * 2 }, { timeout: 4000 });
const bestByDtag = new Map();
for (const r of vcMatches) {
const d = (r.tags.find((t) => t[0] === "d") || [])[1];
const prev = bestByDtag.get(d);
if (!prev || r.created_at > prev.created_at) bestByDtag.set(d, r);
}
const resolved = [];
for (const [dtag, hit] of bestByDtag) {
const hash = dtag.slice(H.PHONE_DTAG_PREFIX.length);
resolved.push({ phone: phoneMap.get(hash), pkHex: hit.pubkey });
}
log("step 7", `${resolved.length} vCard entries matched existing Hermes users`);
const bobMatch = resolved.find(r => r.pkHex === bob.pkHex);
if (!bobMatch) { log("FAIL", "vCard parse → findByPhones did not surface Bob"); process.exit(1); }
log("PASS ", `✓ Alice's vCard import auto-discovered Bob (${bobMatch.phone} → ${bobMatch.pkHex.slice(0,16)}…)`);
const stranger = resolved.find(r => r.phone.includes("9999999"));
if (stranger) { log("FAIL", "stranger phone unexpectedly matched"); process.exit(1); }
log("PASS ", "✓ unknown phone number produced no match (as expected)");
log("DONE ", "all phone-discovery + DM + vCard-import round-trips passed ✓");
relay.ws.close();
process.exit(0);