aboutsummaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-01 18:51:16 -0800
committerAdam Soltys <[email protected]>2026-03-01 18:51:16 -0800
commitc7e2383736d3ef99f069226bbafad9fbddfeb030 (patch)
treee71947a1e627cd1a4802808661ed88866a234f81 /test
parente3b7208ae23d7b56126b0b32b66dfa48140dc1f2 (diff)
downloadcoinos-ui-c7e2383736d3ef99f069226bbafad9fbddfeb030.tar.gz
coinos-ui-c7e2383736d3ef99f069226bbafad9fbddfeb030.zip
messages ui
Diffstat (limited to 'test')
-rw-r--r--test/e2e/dm-subscribe.spec.ts139
-rw-r--r--test/unified-seed.test.ts41
2 files changed, 180 insertions, 0 deletions
diff --git a/test/e2e/dm-subscribe.spec.ts b/test/e2e/dm-subscribe.spec.ts
new file mode 100644
index 00000000..70fbae76
--- /dev/null
+++ b/test/e2e/dm-subscribe.spec.ts
@@ -0,0 +1,139 @@
+import { expect, test } from "@playwright/test";
+import { login, bobUsername, bobPassword, aliceUsername, alicePassword } from "./helpers";
+
+test("recipient receives DM via relay subscription in real-time", async ({ browser }) => {
+ test.setTimeout(60_000);
+
+ // --- Set up Alice (recipient) ---
+ const aliceContext = await browser.newContext();
+ const alicePage = await aliceContext.newPage();
+
+ const aliceConsole: string[] = [];
+ alicePage.on("console", (msg) => aliceConsole.push(`[${msg.type()}] ${msg.text()}`));
+
+ // Track WebSocket frames on Alice's relay connections
+ const aliceWsFrames: { url: string; dir: string; text: string; time: number }[] = [];
+ alicePage.on("websocket", (ws) => {
+ const url = ws.url();
+ console.log(`[alice-ws] connected: ${url}`);
+ ws.on("framesent", (frame) => {
+ aliceWsFrames.push({ url, dir: "sent", text: frame.payload.toString(), time: Date.now() });
+ });
+ ws.on("framereceived", (frame) => {
+ aliceWsFrames.push({ url, dir: "recv", text: frame.payload.toString(), time: Date.now() });
+ });
+ });
+
+ await login(alicePage, aliceUsername, alicePassword);
+ await alicePage.goto(`/dm?username=${bobUsername}`);
+ await alicePage.waitForLoadState("domcontentloaded");
+
+ const aliceTextarea = alicePage.locator("#message-contents");
+ await expect(aliceTextarea).toBeVisible({ timeout: 15_000 });
+
+ // Wait for subscription to be fully established (EOSE received)
+ await alicePage.waitForTimeout(3_000);
+
+ // Verify subscription REQ was sent correctly (NOT double-nested)
+ const subReqs = aliceWsFrames.filter(
+ (f) => f.dir === "sent" && f.text.includes('"REQ"') && f.text.includes("1059") && f.text.includes('"since"'),
+ );
+ console.log(`[alice] Subscription REQs: ${subReqs.length}`);
+ for (const req of subReqs) {
+ console.log(`[alice] ${req.url}: ${req.text.slice(0, 200)}`);
+ // Verify filter is NOT double-nested (the bug was [[{...}]] instead of [{...}])
+ const parsed = JSON.parse(req.text);
+ // REQ format: ["REQ", subId, filter1, filter2, ...]
+ // Filter should be an object, not an array
+ expect(typeof parsed[2], "Filter should be an object, not double-nested array").toBe("object");
+ expect(Array.isArray(parsed[2]), "Filter should not be an array").toBeFalsy();
+ }
+ expect(subReqs.length, "Should have at least one subscription REQ").toBeGreaterThanOrEqual(1);
+
+ // Verify EOSE was received for the subscription
+ const subEoses = aliceWsFrames.filter(
+ (f) => f.dir === "recv" && f.text.includes('"EOSE"'),
+ );
+ console.log(`[alice] EOSE messages: ${subEoses.length}`);
+ expect(subEoses.length, "Should have received EOSE").toBeGreaterThanOrEqual(1);
+
+ // Record the timestamp before Bob sends
+ const beforeSend = Date.now();
+
+ // --- Set up Bob (sender) ---
+ const bobContext = await browser.newContext();
+ const bobPage = await bobContext.newPage();
+
+ const bobWsFrames: string[] = [];
+ bobPage.on("websocket", (ws) => {
+ ws.on("framereceived", (frame) => bobWsFrames.push(frame.payload.toString()));
+ });
+
+ await login(bobPage, bobUsername, bobPassword);
+ await bobPage.goto(`/dm?username=${aliceUsername}`);
+ await bobPage.waitForLoadState("domcontentloaded");
+
+ const bobTextarea = bobPage.locator("#message-contents");
+ await expect(bobTextarea).toBeVisible({ timeout: 15_000 });
+
+ const sendButton = bobPage.locator("#send-message");
+ await expect(sendButton).toBeEnabled({ timeout: 5_000 });
+
+ // --- Bob sends a message ---
+ const testMessage = `realtime-test-${Date.now()}`;
+ console.log(`[bob] Sending: ${testMessage}`);
+ await bobTextarea.fill(testMessage);
+ await sendButton.click();
+ await expect(bobTextarea).toHaveValue("", { timeout: 15_000 });
+ console.log("[bob] Message sent");
+
+ // Verify Bob's relay accepted the events
+ const bobAccepts = bobWsFrames.filter((m) => m.includes('"OK"') && m.includes(",true,"));
+ console.log(`[bob] Relay accepted ${bobAccepts.length} events`);
+ expect(bobAccepts.length, "Relay should accept at least 1 event").toBeGreaterThanOrEqual(1);
+
+ // --- Check if Alice's subscription received the new event ---
+ // Wait a few seconds for the relay to deliver the event to Alice
+ await alicePage.waitForTimeout(5_000);
+
+ // Look for new EVENT messages received by Alice AFTER Bob sent
+ const newEvents = aliceWsFrames.filter(
+ (f) =>
+ f.dir === "recv" &&
+ f.text.includes('"EVENT"') &&
+ f.time > beforeSend,
+ );
+ console.log(`[alice] New EVENT frames received after Bob sent: ${newEvents.length}`);
+ for (const ev of newEvents) {
+ console.log(`[alice] ${ev.url}: ${ev.text.slice(0, 200)}`);
+ }
+
+ // Check specifically for kind 1059 events (gift wraps)
+ const newGiftWraps = newEvents.filter((f) => {
+ try {
+ const parsed = JSON.parse(f.text);
+ return parsed[2]?.kind === 1059;
+ } catch {
+ return false;
+ }
+ });
+ console.log(`[alice] New kind-1059 gift wraps received: ${newGiftWraps.length}`);
+
+ // The subscription should deliver at least one new gift-wrapped event
+ expect(
+ newGiftWraps.length,
+ "Alice's subscription should receive Bob's gift-wrapped message from the relay",
+ ).toBeGreaterThanOrEqual(1);
+
+ // Log any decrypt attempts from subscription handler
+ const subDecryptLogs = aliceConsole.filter(
+ (l) => l.includes("subscription event"),
+ );
+ if (subDecryptLogs.length) {
+ console.log("[alice] Subscription decrypt attempts:");
+ for (const l of subDecryptLogs) console.log(`[alice] ${l}`);
+ }
+
+ await bobContext.close();
+ await aliceContext.close();
+});
diff --git a/test/unified-seed.test.ts b/test/unified-seed.test.ts
index 4410dbea..1bdf35d3 100644
--- a/test/unified-seed.test.ts
+++ b/test/unified-seed.test.ts
@@ -10,6 +10,8 @@ import {
import { wordlist } from "@scure/bip39/wordlists/english.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { encrypt, decrypt } from "nostr-tools/nip49";
+import { privateKeyFromSeedWords } from "nostr-tools/nip06";
+import { getPublicKey } from "nostr-tools";
const versions = {
private: 0x04358394,
@@ -217,3 +219,42 @@ describe("backward compatibility", () => {
expect(() => decrypt(encrypted, "wrongpassword")).toThrow();
});
});
+
+describe("NIP-06 nostr key derivation", () => {
+ const mnemonic = generateMnemonic(wordlist);
+
+ it("derives a deterministic nostr key from mnemonic via NIP-06 path", () => {
+ const sk = privateKeyFromSeedWords(mnemonic);
+ const pubkey = getPublicKey(sk);
+
+ expect(sk).toBeInstanceOf(Uint8Array);
+ expect(sk.length).toBe(32);
+ expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
+
+ // Same mnemonic produces same key
+ const sk2 = privateKeyFromSeedWords(mnemonic);
+ expect(bytesToHex(sk2)).toBe(bytesToHex(sk));
+ });
+
+ it("derives nostr key from entropy round-trip", () => {
+ const entropy = mnemonicToEntropy(mnemonic, wordlist);
+ const recovered = entropyToMnemonic(entropy, wordlist);
+ expect(recovered).toBe(mnemonic);
+
+ const sk = privateKeyFromSeedWords(mnemonic);
+ const skRecovered = privateKeyFromSeedWords(recovered);
+ expect(bytesToHex(skRecovered)).toBe(bytesToHex(sk));
+ });
+
+ it("nostr NIP-06 key differs from Bitcoin and Ark keys", async () => {
+ const seed = await mnemonicToSeed(mnemonic, password);
+ const master = HDKey.fromMasterSeed(seed, versions);
+
+ const btcKey = bytesToHex(master.derive("m/84'/0'/0'").privateKey!);
+ const arkKey = bytesToHex(master.derive("m/86'/0'/0'/0/0").privateKey!);
+ const nostrKey = bytesToHex(privateKeyFromSeedWords(mnemonic));
+
+ expect(nostrKey).not.toBe(btcKey);
+ expect(nostrKey).not.toBe(arkKey);
+ });
+});