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
This commit is contained in:
parent
bbe5d3d298
commit
4b3fd4be42
1 changed files with 218 additions and 0 deletions
218
tests/hermes-two-users.test.mjs
Normal file
218
tests/hermes-two-users.test.mjs
Normal file
|
|
@ -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);
|
||||
Loading…
Add table
Reference in a new issue