Files
welshman/packages/signer/__tests__/nip46.test.ts
T
2025-02-28 14:18:02 -08:00

674 lines
19 KiB
TypeScript

import {afterAll, beforeEach, describe, expect, it, vi} from "vitest"
import {
Nip46Signer,
Nip46Broker,
Nip46Event,
Nip46Receiver,
Nip46Sender,
Nip46Request,
Nip46Response,
Nip46BrokerParams,
} from "../src/signers/nip46"
import {testSigner} from "./common"
import {NOSTR_CONNECT, SignedEvent, TrustedEvent} from "@welshman/util"
import {publish, subscribe, SubscriptionEvent} from "@welshman/net"
import {now} from "@welshman/lib"
const mockSubscription = {
on: vi.fn(),
close: vi.fn(),
}
vi.mock(import("@welshman/net"), async importOriginal => ({
...(await importOriginal()),
subscribe: vi.fn().mockImplementation(() => mockSubscription),
publish: vi.fn().mockImplementation(() => ({
emitter: {
on: vi.fn(),
},
})),
}))
describe("Nip46Signer", () => {
let mockBroker: any
const signerPubkey = "ee".repeat(32)
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockBroker = {
getPublicKey: vi.fn().mockResolvedValue("ee".repeat(32)),
signEvent: vi.fn().mockResolvedValue({sig: "ff".repeat(64)} as SignedEvent),
nip04Encrypt: vi.fn((pubkey, message) => "encrypted:" + message),
nip04Decrypt: vi.fn((pubkey, encryptedMessage) => encryptedMessage.split("encrypted:")[1]),
nip44Encrypt: vi.fn((pubkey, message) => "encrypted:" + message),
nip44Decrypt: vi.fn((pubkey, encryptedMessage) => encryptedMessage.split("encrypted:")[1]),
}
})
afterAll(() => {
vi.useRealTimers()
})
testSigner("Nip46Signer", () => new Nip46Signer(mockBroker))
describe("Nip46Receiver", () => {
let mockSigner: any
let receiver: Nip46Receiver
beforeEach(() => {
mockSigner = {
getPubkey: vi.fn().mockResolvedValue("test-pubkey"),
nip04: {
decrypt: vi.fn().mockResolvedValue('{"method":"test","params":[]}'),
},
nip44: {
decrypt: vi.fn().mockResolvedValue('{"method":"test","params":[]}'),
},
}
receiver = new Nip46Receiver(mockSigner, {
relays: ["wss://relay.test"],
clientSecret: "test-secret",
})
})
it("should setup subscription with correct filters", async () => {
receiver.start()
await vi.advanceTimersToNextTimerAsync()
expect(subscribe).toHaveBeenCalledWith({
relays: ["wss://relay.test"],
filters: [
{
kinds: [NOSTR_CONNECT],
"#p": ["test-pubkey"],
},
],
})
})
it("should handle incoming events", async () => {
const receiveSpy = vi.fn()
receiver.on(Nip46Event.Receive, receiveSpy)
receiver.start()
await vi.advanceTimersToNextTimerAsync()
// Get the event handler
const eventHandler = (mockSubscription as any).on.mock.calls.find(
call => call[0] === SubscriptionEvent.Event,
)[1]
// Simulate incoming event
await eventHandler("wss://relay.test", {
pubkey: "sender-pubkey",
content: "encrypted-content",
} as TrustedEvent)
expect(receiveSpy).toHaveBeenCalledWith(
expect.objectContaining({
url: "wss://relay.test",
event: expect.any(Object),
}),
)
})
it("should cleanup on stop", async () => {
receiver.start()
await vi.advanceTimersToNextTimerAsync()
receiver.stop()
expect(mockSubscription.close).toHaveBeenCalled()
})
})
describe("Nip46Sender", () => {
let mockSigner: any
let sender: Nip46Sender
let mockPublish: any
beforeEach(() => {
vi.clearAllMocks()
mockSigner = {
getPubkey: vi.fn().mockResolvedValue("test-pubkey"),
sign: vi.fn(template => ({...template, sig: "ee".repeat(64)})),
nip44: {
encrypt: vi.fn((pub, message) => "encrypted:" + message),
},
}
mockPublish = {
emitter: {on: vi.fn()},
}
vi.mocked(publish).mockReturnValue(mockPublish)
sender = new Nip46Sender(mockSigner, {
relays: ["wss://relay.test"],
clientSecret: "test-secret",
signerPubkey,
})
})
it("should encrypt and send request", async () => {
const request = new Nip46Request("test-method", ["param1"])
await sender.send(request)
expect(mockSigner.nip44.encrypt).toHaveBeenCalledWith(signerPubkey, expect.any(String))
expect(publish).toHaveBeenCalledWith(
expect.objectContaining({
relays: ["wss://relay.test"],
event: expect.any(Object),
}),
)
})
it("should throw error if no signer pubkey", async () => {
sender = new Nip46Sender(mockSigner, {
relays: ["wss://relay.test"],
clientSecret: "test-secret",
})
const request = new Nip46Request("test-method", ["param1"])
await expect(sender.send(request)).rejects.toThrow("signer pubkey")
})
it("should process queue sequentially", async () => {
const request1 = new Nip46Request("method1", ["param1"])
const request2 = new Nip46Request("method2", ["param2"])
sender.enqueue(request1)
sender.enqueue(request2)
await vi.runAllTimersAsync()
// Check that requests were processed in order
const calls = vi.mocked(publish).mock.calls
expect(calls[0][0].event.content).toContain("method1")
expect(calls[1][0].event.content).toContain("method2")
})
})
describe("Nip46Request", () => {
let mockReceiver: any
let mockSender: any
let request: Nip46Request
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockReceiver = {
start: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
}
mockSender = {
enqueue: vi.fn(),
}
request = new Nip46Request("test-method", ["param1"])
})
afterAll(() => {
vi.useRealTimers()
})
it("should handle successful response", async () => {
// Setup response handler
let responseHandler: (response: Nip46Response) => void
mockReceiver.on.mockImplementation((event, handler) => {
responseHandler = handler
})
// Start listening
const listenPromise = request.listen(mockReceiver)
await vi.advanceTimersToNextTimerAsync()
// Simulate successful response
responseHandler!({
id: request.id,
url: "wss://relay.test",
event: {} as TrustedEvent,
result: "success",
})
await listenPromise
const result = await request.promise
expect(result.result).toBe("success")
})
it("should handle error response", async () => {
let responseHandler: (response: Nip46Response) => void
mockReceiver.on.mockImplementation((event, handler) => {
responseHandler = handler
})
const listenPromise = request.listen(mockReceiver)
await vi.advanceTimersToNextTimerAsync()
responseHandler!({
id: request.id,
url: "wss://relay.test",
event: {} as TrustedEvent,
error: "test error",
})
await listenPromise
await expect(request.promise).rejects.toMatchObject({
error: "test error",
})
})
it("should handle auth_url result", async () => {
const popupSpy = vi.spyOn(window, "open")
let responseHandler: (response: Nip46Response) => void
mockReceiver.on.mockImplementation((event, handler) => {
responseHandler = handler
})
const listenPromise = request.listen(mockReceiver)
await vi.advanceTimersToNextTimerAsync()
responseHandler!({
id: request.id,
url: "wss://relay.test",
event: {} as TrustedEvent,
result: "auth_url",
error: "https://auth.test",
})
await listenPromise
expect(popupSpy).toHaveBeenCalledWith(
"https://auth.test",
expect.any(String),
expect.any(String),
)
})
it("should ignore responses with different ids", async () => {
let responseHandler: (response: Nip46Response) => void
mockReceiver.on.mockImplementation((event, handler) => {
responseHandler = handler
})
const listenPromise = request.listen(mockReceiver)
await vi.advanceTimersToNextTimerAsync()
responseHandler!({
id: "different-id",
url: "wss://relay.test",
event: {} as TrustedEvent,
result: "success",
})
await listenPromise
// Promise should not be resolved
const promiseStatus = await Promise.race([
request.promise,
vi.advanceTimersByTimeAsync(100).then(() => "timeout"),
])
expect(promiseStatus).toBe("timeout")
})
it("should enqueue request on send", async () => {
await request.send(mockSender)
expect(mockSender.enqueue).toHaveBeenCalledWith(request)
})
})
describe("Nip46Broker", () => {
let defaultParams: Nip46BrokerParams
const pubkey = "cc".repeat(32)
beforeEach(() => {
vi.clearAllMocks()
defaultParams = {
relays: ["wss://relay.test"],
clientSecret: "dd".repeat(32),
signerPubkey: "ee".repeat(32),
}
})
describe("singleton management", () => {
it("should maintain single instance with same params", () => {
const broker1 = Nip46Broker.get(defaultParams)
const broker2 = Nip46Broker.get(defaultParams)
expect(broker1).toBe(broker2)
})
it("should create new instance with different params", () => {
const broker1 = Nip46Broker.get(defaultParams)
const broker2 = Nip46Broker.get({
...defaultParams,
relays: ["wss://other.relay"],
})
expect(broker1).not.toBe(broker2)
})
it("should teardown old instance when creating new one", () => {
const broker1 = Nip46Broker.get(defaultParams)
const teardownSpy = vi.spyOn(broker1, "teardown")
Nip46Broker.get({
...defaultParams,
relays: ["wss://other.relay"],
})
expect(teardownSpy).toHaveBeenCalled()
})
})
describe("URL handling", () => {
it("should parse bunker URL correctly", () => {
const url = `bunker://${pubkey}?relay=wss://relay1.test&relay=wss://relay2.test&secret=testsecret`
const result = Nip46Broker.parseBunkerUrl(url)
expect(result).toEqual({
signerPubkey: pubkey,
relays: ["wss://relay1.test/", "wss://relay2.test/"],
connectSecret: "testsecret",
})
})
it("should handle invalid bunker URL", () => {
const result = Nip46Broker.parseBunkerUrl("invalid-url")
expect(result).toEqual({
signerPubkey: "",
connectSecret: "",
relays: [],
})
})
it("should generate bunker URL", () => {
const broker = new Nip46Broker(defaultParams)
const url = broker.getBunkerUrl()
expect(url).toContain("bunker://")
expect(url).toContain(defaultParams.signerPubkey)
expect(url).toContain(encodeURIComponent(defaultParams.relays[0]))
})
it("should throw when generating bunker URL without signerPubkey", () => {
const broker = new Nip46Broker({
...defaultParams,
signerPubkey: undefined,
})
expect(() => broker.getBunkerUrl()).toThrow("no signerPubkey")
})
it("should generate nostrconnect URL", async () => {
const broker = new Nip46Broker(defaultParams)
const url = await broker.makeNostrconnectUrl({app: "test"})
expect(url).toContain("nostrconnect://")
expect(url).toContain("app=test")
expect(url).toContain("secret=")
expect(url).toContain(encodeURIComponent(defaultParams.relays[0]))
})
})
describe("connection handling", () => {
it("should handle nostrconnect response", async () => {
const broker = new Nip46Broker(defaultParams)
const url = await broker.makeNostrconnectUrl()
// Start waiting for response
const connectPromise = broker.waitForNostrconnect(url)
// Get the secret from the URL we're connecting to
const secret = new URL(url).searchParams.get("secret")
// Simulate a response through the broker's receiver
broker.receiver.emit(Nip46Event.Receive, {
result: secret,
event: {pubkey: "responder-pubkey"},
})
const response = await connectPromise
expect(broker.params.signerPubkey).toBe("responder-pubkey")
})
it("should handle connection abort", async () => {
const broker = new Nip46Broker(defaultParams)
const url = await broker.makeNostrconnectUrl()
const controller = new AbortController()
const connectPromise = broker.waitForNostrconnect(url, controller)
controller.abort()
await expect(connectPromise).rejects.toBeUndefined()
})
})
describe("NIP-46 methods", () => {
let broker: Nip46Broker
beforeEach(() => {
broker = new Nip46Broker(defaultParams)
})
it("should send ping request", async () => {
const pingPromise = broker.ping()
// We need to wait a tick for the request to be created and registered
await vi.runAllTimersAsync()
// Make sure we started the handshake with the remote signer
const sentHandler = (mockSubscription as any).on.mock.calls.find(
call => call[0] === SubscriptionEvent.Send,
)[1]
// the sub was sent
sentHandler()
let req = {} as Nip46Request
// catch up the send event to get the request id
broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res))
await vi.runAllTimersAsync()
// The receiver should emit a response with the same ID as the request
broker.receiver.emit(Nip46Event.Receive, {
id: req?.id,
result: "pong",
error: undefined,
event: {} as TrustedEvent,
url: "wss://test.relay",
})
const result = await pingPromise
expect(result).toBe("pong")
})
it("should get public key", async () => {
const pubkeyPromise = broker.getPublicKey()
await vi.runAllTimersAsync()
// Make sure we started the handshake with the remote signer
const sentHandler = (mockSubscription as any).on.mock.calls.find(
call => call[0] === SubscriptionEvent.Send,
)[1]
// the sub handshake was sent
sentHandler()
let req = {} as Nip46Request
// catch up the send event to get the request id
broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res))
await vi.runAllTimersAsync()
expect(req.method).toBe("get_public_key")
// Simulate response
broker.receiver.emit(Nip46Event.Receive, {
id: req.id,
result: "test-pubkey",
error: undefined,
event: {} as TrustedEvent,
url: "wss://test.relay",
})
const result = await pubkeyPromise
expect(result).toBe("test-pubkey")
})
it("should sign event", async () => {
const event = {kind: 1, pubkey, created_at: now(), content: "test", tags: []}
const signedEvent = {...event, sig: "signature"}
const signPromise = broker.signEvent(event)
await vi.runAllTimersAsync()
// Make sure we started the handshake with the remote signer
const sentHandler = (mockSubscription as any).on.mock.calls.find(
call => call[0] === SubscriptionEvent.Send,
)[1]
// the sub handshake was sent
sentHandler()
let req = {} as Nip46Request
// catch up the request send event to get the request id
broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res))
await vi.runAllTimersAsync()
// Simulate response
broker.receiver.emit(Nip46Event.Receive, {id: req.id, result: JSON.stringify(signedEvent)})
const result = await signPromise
expect(result).toEqual(signedEvent)
})
it("should handle encryption methods", async () => {
const encryptPromise = broker.nip04Encrypt("bb".repeat(32), "message")
await vi.runAllTimersAsync()
// Make sure we started the handshake with the remote signer
const sentHandler = (mockSubscription as any).on.mock.calls.find(
call => call[0] === SubscriptionEvent.Send,
)[1]
// the sub handshake was sent
sentHandler()
let req = {} as Nip46Request
// catch up the request send event to get the request id
broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res))
await vi.runAllTimersAsync()
// Simulate response
broker.receiver.emit(Nip46Event.Receive, {id: req.id, result: "encrypted"})
const result = await encryptPromise
expect(result).toBe("encrypted")
})
})
describe("error handling", () => {
it("should handle request timeout", async () => {
// const broker = new Nip46Broker({
// ...defaultParams,
// timeout: 100,
// })
// const pingPromise = broker.ping()
// await expect(pingPromise).rejects.toThrow()
})
it("should handle request errors", async () => {
const broker = new Nip46Broker(defaultParams)
const pingPromise = broker.ping()
// We need to wait a tick for the request to be created and registered
await vi.runAllTimersAsync()
// Make sure we started the handshake with the remote signer
const sentHandler = (mockSubscription as any).on.mock.calls.find(
call => call[0] === SubscriptionEvent.Send,
)[1]
// the sub was sent
sentHandler()
let req = {} as Nip46Request
// catch up the send event to get the request id
broker.sender.on(Nip46Event.Send, (res: Nip46Request) => (req = res))
await vi.runAllTimersAsync()
// The receiver should emit a response with the same ID as the request
broker.receiver.emit(Nip46Event.Receive, {
id: req?.id,
result: "",
error: "test error",
event: {} as TrustedEvent,
url: "wss://test.relay",
})
await expect(pingPromise).rejects.toMatchObject({error: "test error"})
})
})
describe("state management", () => {
it("should update params correctly", () => {
const broker = new Nip46Broker(defaultParams)
const newParams = {
signerPubkey: "new-pubkey",
algorithm: "nip04" as const,
}
broker.setParams(newParams)
expect(broker.params).toEqual({
...defaultParams,
...newParams,
})
})
it("should cleanup on teardown", () => {
const broker = new Nip46Broker(defaultParams)
const senderStopSpy = vi.spyOn(broker.sender, "stop")
const receiverStopSpy = vi.spyOn(broker.receiver, "stop")
broker.teardown()
expect(senderStopSpy).toHaveBeenCalled()
expect(receiverStopSpy).toHaveBeenCalled()
})
})
})
describe("Nip46Broker", () => {
// Test broker-specific functionality
it("should parse bunker URL correctly", () => {
const url = `bunker://${signerPubkey}?relay=wss://relay1&relay=wss://relay2&secret=123`
const result = Nip46Broker.parseBunkerUrl(url)
expect(result).toEqual({
signerPubkey,
relays: ["wss://relay1/", "wss://relay2/"],
connectSecret: "123",
})
})
})
})