diff --git a/tests/hermes-two-users.test.mjs b/tests/hermes-two-users.test.mjs new file mode 100644 index 00000000..dce4f0d7 --- /dev/null +++ b/tests/hermes-two-users.test.mjs @@ -0,0 +1,218 @@ +#!/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);