diff --git a/docs/agents/runs/cleanup-1-9-ledger.md b/docs/agents/runs/cleanup-1-9-ledger.md index a488d027..9553c3d5 100644 --- a/docs/agents/runs/cleanup-1-9-ledger.md +++ b/docs/agents/runs/cleanup-1-9-ledger.md @@ -9,7 +9,7 @@ - Feature branches: one branch per approved ticket, created from the latest integrated `staging` - Human owner: plebdev - Started: 2026-07-18 -- Current status: items 1–6 / issues #131–#136 merged into `staging`; item 7 / issue #137 is implemented with PR #146 pending on `feature/fast-default-test-loop` +- Current status: items 1–7 / issues #131–#137 merged into `staging`; item 8 / issue #138 is locally complete and awaiting its PR on `feature/public-behavior-test-seams` - Skill setup status: present and verified (`AGENTS.md`, GitHub issue tracker, triage labels, domain docs, ADRs, CI, CodeRabbit) ## Goal @@ -27,7 +27,7 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch - Agent briefs: Grok 4.5 is the exclusive delegated sidecar; Cursor exposes the highest available tier as `cursor-grok-4.5-high`, which is used for all standards/spec passes - Review packets: `issue-131-review-packet.md` through `issue-137-review-packet.md`; created per later ticket - Local CodeRabbit report: `issue-131-coderabbit-local.md` through `issue-136-coderabbit-local.md`; created per later ticket -- PR URL: #140 merged for issue #131; #141 merged for issue #132; #142 merged for issue #133; #143 merged for issue #134; #144 merged for issue #135; #145 merged for issue #136; issue #137 PR pending; always non-draft and targeting `staging` +- PR URL: #140 merged for issue #131; #141 merged for issue #132; #142 merged for issue #133; #143 merged for issue #134; #144 merged for issue #135; #145 merged for issue #136; #146 merged for issue #137; issue #138 PR pending; always non-draft and targeting `staging` ## Commands @@ -39,17 +39,17 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch ## Ticket Ledger -| Issue | Type | Status | Branch | Review | Verified | -| --------------------------------- | ---- | --------------- | --------------------------------------- | -------------------------------------------------------- | --------------------------------------- | -| #131 NIP-46 diagnostic redaction | AFK | merged | `feature/nip46-diagnostic-redaction` | Grok approved; CodeRabbit local/hosted clean after fixes | Jest/Bun 1054/1054; hosted CI green | -| #132 published declaration purity | AFK | merged | `feature/public-type-test-purity` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1055/1055; hosted CI green | -| #133 shared diagnostic seam | AFK | merged | `feature/shared-diagnostics-completion` | Grok standards/spec pass; local and hosted clean | Jest/Bun 1067/1067; hosted CI green | -| #134 NIP-47 service lifecycle | AFK | merged | `feature/nip47-service-lifecycle` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1073/1073; hosted CI green | -| #135 NIP-57 consolidation | AFK | merged | `feature/nip57-client-consolidation` | Grok pass; CodeRabbit local/hosted clean after fixes | Jest/Bun 1082/1082; hosted CI green | -| #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | -| #137 default test feedback loop | AFK | review cooldown | `feature/fast-default-test-loop` | Grok pass; final CodeRabbit retry pending after cooldown | routine 1063; slow 40; coverage 1103 | -| #138 public behavior test seams | AFK | blocked by #137 | `feature/public-behavior-test-seams` | pending | pending | -| #139 ephemeral Relay internals | AFK | blocked by #138 | `feature/ephemeral-relay-internals` | pending | pending | +| Issue | Type | Status | Branch | Review | Verified | +| --------------------------------- | ---- | --------------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------- | +| #131 NIP-46 diagnostic redaction | AFK | merged | `feature/nip46-diagnostic-redaction` | Grok approved; CodeRabbit local/hosted clean after fixes | Jest/Bun 1054/1054; hosted CI green | +| #132 published declaration purity | AFK | merged | `feature/public-type-test-purity` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1055/1055; hosted CI green | +| #133 shared diagnostic seam | AFK | merged | `feature/shared-diagnostics-completion` | Grok standards/spec pass; local and hosted clean | Jest/Bun 1067/1067; hosted CI green | +| #134 NIP-47 service lifecycle | AFK | merged | `feature/nip47-service-lifecycle` | Grok pass; CodeRabbit local/hosted clean | Jest/Bun 1073/1073; hosted CI green | +| #135 NIP-57 consolidation | AFK | merged | `feature/nip57-client-consolidation` | Grok pass; CodeRabbit local/hosted clean after fixes | Jest/Bun 1082/1082; hosted CI green | +| #136 NIP-46 protocol core | AFK | merged | `feature/nip46-protocol-core` | Grok and CodeRabbit local/hosted clean after fixes | Jest/Bun 1096/1096; hosted CI green | +| #137 default test feedback loop | AFK | merged | `feature/fast-default-test-loop` | Grok pass; CodeRabbit hosted finding fixed and confirmed | routine 1063; slow 40; coverage 1103 | +| #138 public behavior test seams | AFK | PR pending | `feature/public-behavior-test-seams` | Grok approved; CodeRabbit local clean after fixes | Jest/Bun 1061; slow 35; coverage 1096 | +| #139 ephemeral Relay internals | AFK | blocked by #138 | `feature/ephemeral-relay-internals` | pending | pending | ## Parked HITL Slices @@ -59,15 +59,16 @@ Complete cleanup items 1–9 from the staging audit end to end, branch by branch ## Issue Session Ledger -| Issue | Fixed point | Implementation owner | Commit | Review result | Checks | -| ----- | ----------- | --------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| #131 | `f4bda34` | current Codex orchestrator; Grok 4.5 High reviewers | `7ed8433`, `00104cc`, `21b4ad9`, `426c17b` | Grok approved after hosted fixes; CodeRabbit local code review clean | focused Jest/Bun 7/7; final Jest/Bun 1054/1054; policies, lint, types, builds, examples, pack | -| #132 | `cf705f0` | current Codex orchestrator; Grok 4.5 High reviewers | `0c111d7`, `20565a0`, `abfc836`, `dea7aa0` | Grok passed; CodeRabbit local/hosted clean after four local fixes | focused 8/8; Jest/Bun 1055/1055; all local gates and four hosted lanes green | -| #133 | `46d7289` | current Codex orchestrator; Grok 4.5 High reviewers | `b238461`, `6dd75c3`, `ae15ace` | Grok standards/spec passed; CodeRabbit local/hosted clean | focused 204/204; Jest/Bun 1067/1067; all local gates and four hosted lanes green | -| #134 | `2a3556d` | current Codex orchestrator; Grok 4.5 High reviewers | `569b266` plus review artifacts | Grok standards/spec passed; CodeRabbit local/hosted clean | focused 6/6; Jest/Bun 1073/1073; all local gates and four hosted lanes green | -| #135 | `25e055d` | current Codex orchestrator; Grok 4.5 High reviewers | `0909227`, `1b12872`, `b66d483` | Grok passes; CodeRabbit local/hosted clean after fixes | focused 23/23; NIP-57 41/41; Jest/Bun 1082/1082; all local gates and four hosted lanes green | -| #136 | `ed9fa4a` | current Codex orchestrator; Grok 4.5 High reviewers | PR #145 through merge `8b970e4` | Grok and CodeRabbit local/hosted clean after fixes | NIP-46 185/185; Jest/Bun 1096/1096; all local and hosted gates green | -| #137 | `8b970e4` | current Codex orchestrator; Grok 4.5 High reviewers | implementation `0de11d9`, `91ffafa`, `295d114`, `d8de419`; review records through HEAD | Grok standards/spec passed after Bun 1.3.9 fix; clean CodeRabbit retry pending | baseline 85/1096/58.793s; routine 84/1063/32.356s; slow 2/40/43.154s; coverage 86/1103 | +| Issue | Fixed point | Implementation owner | Commit | Review result | Checks | +| ----- | ----------- | --------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| #131 | `f4bda34` | current Codex orchestrator; Grok 4.5 High reviewers | `7ed8433`, `00104cc`, `21b4ad9`, `426c17b` | Grok approved after hosted fixes; CodeRabbit local code review clean | focused Jest/Bun 7/7; final Jest/Bun 1054/1054; policies, lint, types, builds, examples, pack | +| #132 | `cf705f0` | current Codex orchestrator; Grok 4.5 High reviewers | `0c111d7`, `20565a0`, `abfc836`, `dea7aa0` | Grok passed; CodeRabbit local/hosted clean after four local fixes | focused 8/8; Jest/Bun 1055/1055; all local gates and four hosted lanes green | +| #133 | `46d7289` | current Codex orchestrator; Grok 4.5 High reviewers | `b238461`, `6dd75c3`, `ae15ace` | Grok standards/spec passed; CodeRabbit local/hosted clean | focused 204/204; Jest/Bun 1067/1067; all local gates and four hosted lanes green | +| #134 | `2a3556d` | current Codex orchestrator; Grok 4.5 High reviewers | `569b266` plus review artifacts | Grok standards/spec passed; CodeRabbit local/hosted clean | focused 6/6; Jest/Bun 1073/1073; all local gates and four hosted lanes green | +| #135 | `25e055d` | current Codex orchestrator; Grok 4.5 High reviewers | `0909227`, `1b12872`, `b66d483` | Grok passes; CodeRabbit local/hosted clean after fixes | focused 23/23; NIP-57 41/41; Jest/Bun 1082/1082; all local gates and four hosted lanes green | +| #136 | `ed9fa4a` | current Codex orchestrator; Grok 4.5 High reviewers | PR #145 through merge `8b970e4` | Grok and CodeRabbit local/hosted clean after fixes | NIP-46 185/185; Jest/Bun 1096/1096; all local and hosted gates green | +| #137 | `8b970e4` | current Codex orchestrator; Grok 4.5 High reviewers | PR #146 through merge `b33f31f` | Grok standards/spec passed after Bun 1.3.9 fix; CodeRabbit hosted finding fixed and explicitly confirmed | baseline 85/1096/58.793s; routine 84/1063/32.356s; slow 2/40/43.154s; coverage 86/1103; hosted CI green | +| #138 | `b33f31f` | current Codex orchestrator; Grok 4.5 High reviewers | `bfd3314` through `7bdbcfa` | Grok approved after fixes; final CodeRabbit local full-diff review clean | routine Jest/Bun 1061; slow Jest/Bun 35; coverage 1096; policies, lint, types, builds, examples, pack | ## Alignment Decisions diff --git a/docs/agents/runs/issue-138-session.md b/docs/agents/runs/issue-138-session.md new file mode 100644 index 00000000..6e37ba23 --- /dev/null +++ b/docs/agents/runs/issue-138-session.md @@ -0,0 +1,54 @@ +# Issue 138 Session: Public Behavior Test Seams + +## Fixed Point + +- Base branch: `staging` +- Fixed point: `b33f31f` +- Feature branch: `feature/public-behavior-test-seams` +- Issue: #138 — Move behavior tests off private shapes + +## Scope + +Remove the shared `Nostr` and `Relay` private-shape adapters, rewrite tests around public behavior where possible, and place only irreducible protocol injection or fault control behind narrow exports owned by `src/testing`. + +## Test-First Plan + +1. Add a source contract test that rejects the legacy adapters and named broad private-shape interfaces in the targeted Relay, Nostr, NIP-46, and NIP-47 clusters. +2. Replace Nostr state reads and key mutation with `addRelay`, `getRelay`, `removeRelay`, `setPrivateKey`, and `getPublicKey`; retain a narrow relay-replacement seam only where a deterministic mock must be injected. +3. Replace Relay field access with public getters/setters, observable callbacks, mock WebSocket behavior, and narrow protocol-message/fault seams in `src/testing`. +4. Replace NIP-46 and NIP-47 broad casts with public lifecycle behavior or focused testing-entrypoint controls. +5. Run focused Jest/Bun suites, the routine and slow lanes, complete coverage, build/package gates, Grok standards/spec review, and CodeRabbit local/hosted review. + +## Guardrails + +- Do not expose new production API solely to make an assertion convenient. +- A testing seam must name one behavior or fault; it must not return a shadow object containing arbitrary private state. +- Preserve failure sensitivity: cleanup tests must still prove cleanup and protocol tests must still traverse the production handler. + +## Status + +- Local implementation and review are complete; the non-draft PR to `staging` is pending. + +## Implementation Result + +- Removed the shared `NostrInternals`, `getNostrInternals`, `RelayTestAccess`, and `asTestRelay` private-shape adapters. +- Reworked the targeted Nostr, Relay, NIP-46, and NIP-47 suites around public lifecycle, callbacks, relay wire behavior, or focused controls exported by `src/testing`. +- Added a source contract that prevents the removed adapters and broad private-shape patterns from returning in the targeted suites. +- Extracted the NIP-46 replay guard into an internal module and exercised replay rejection, lifecycle reset, timer cleanup, relay reconnect policy, socket replacement, and replaceable-event ordering through behavior-sensitive tests. +- Kept NIP-04 permissions out of the shared core fixture and granted them only in the encryption compatibility test. + +## Review Result + +- Grok design, standards, and specification review: approved after the behavior-sensitivity fixes. +- CodeRabbit local review: clean, 0 findings on the final full diff after five earlier minor findings were fixed. +- No public replay-window option was added: exposing product configuration only to support a test would violate this issue's guardrail, while the internal replay guard remains directly testable. + +## Verification Result + +- Routine Jest: 86 suites, 1,061 tests passed. +- Slow Jest: 35 tests passed. +- Routine Bun: 1,061 tests passed. +- Slow Bun: 35 tests passed. +- Complete Jest coverage lane: 88 suites, 1,096 tests passed; 80.72% statements, 68.59% branches, 82.63% functions, and 81.20% lines. +- Commands policy, package-manager policy, lint, strict TypeScript, build, examples build, and package verification passed. +- Final NIP-04 permission adjustment passed its focused Jest and Bun test plus lint and strict TypeScript. diff --git a/src/nip46/bunker.ts b/src/nip46/bunker.ts index 40630494..1b48f82c 100644 --- a/src/nip46/bunker.ts +++ b/src/nip46/bunker.ts @@ -32,6 +32,7 @@ import { import { LogLevel } from "../utils/logger"; import { NIP46DiagnosticLogger } from "./utils/diagnostics"; import { NIP46BunkerEngine } from "./internal/bunker-engine"; +import { NIP46ReplayGuard } from "./internal/replay-guard"; export class NostrRemoteSignerBunker { private readonly engine: NIP46BunkerEngine; @@ -49,7 +50,7 @@ export class NostrRemoteSignerBunker { params: string[], ) => boolean | null) | null = null; - private usedRequestIds: Map = new Map(); // Request ID -> timestamp + private readonly replayGuard = new NIP46ReplayGuard(); private cleanupInterval: NodeJS.Timeout | null = null; // For cleanup interval management constructor(options: NIP46BunkerOptions) { @@ -125,10 +126,15 @@ export class NostrRemoteSignerBunker { }, }; }, - beforeRequest: (request) => - this.isReplayAttack(request.id) - ? { action: "drop" } - : { action: "continue" }, + beforeRequest: (request) => { + const replay = this.replayGuard.isReplay(request.id); + if (replay) { + this.logger.warn("Replay attack detected", { + requestId: request.id, + }); + } + return replay ? { action: "drop" } : { action: "continue" }; + }, handlers: { [NIP46Method.CONNECT]: (request, clientPubkey) => this.handleConnect(request, clientPubkey), @@ -176,7 +182,7 @@ export class NostrRemoteSignerBunker { } this.connectedClients.clear(); this.pendingAuthChallenges.clear(); - this.usedRequestIds.clear(); + this.replayGuard.clear(); }, }); @@ -259,51 +265,6 @@ export class NostrRemoteSignerBunker { this.logger.debug("Custom permission handler cleared"); } - /** - * Check if a request ID has been used before (replay attack prevention) - * @private - */ - private isReplayAttack(requestId: string): boolean { - const now = Date.now(); - const requestTime = this.usedRequestIds.get(requestId); - - if (requestTime !== undefined) { - // This request ID has been seen before - this.logger.warn("Replay attack detected", { - requestId, - originalTime: new Date(requestTime).toISOString(), - attemptTime: new Date(now).toISOString(), - }); - return true; - } - - // Store the request ID with current timestamp - this.usedRequestIds.set(requestId, now); - - return false; - } - - /** - * Clean up old request IDs to prevent memory leaks - * @private - */ - private cleanupOldRequestIds(): void { - const now = Date.now(); - const maxAge = 120000; // 2 minutes - reduced from 1 hour for better security - let cleaned = 0; - - for (const [requestId, timestamp] of this.usedRequestIds.entries()) { - if (now - timestamp > maxAge) { - this.usedRequestIds.delete(requestId); - cleaned++; - } - } - - if (cleaned > 0) { - this.logger.debug("Cleaned up old request IDs", { count: cleaned }); - } - } - /** * Resolve an auth challenge by marking it as resolved * @param pubkey The client pubkey that completed authentication @@ -389,7 +350,12 @@ export class NostrRemoteSignerBunker { * Periodic cleanup of expired data */ private cleanup(): void { - this.cleanupOldRequestIds(); + const cleanedRequestIds = this.replayGuard.cleanup(); + if (cleanedRequestIds > 0) { + this.logger.debug("Cleaned up old request IDs", { + count: cleanedRequestIds, + }); + } // Clean up expired auth challenges const now = Date.now(); diff --git a/src/nip46/client.ts b/src/nip46/client.ts index 1caf1343..d50cd3d3 100644 --- a/src/nip46/client.ts +++ b/src/nip46/client.ts @@ -3,7 +3,6 @@ import { LogLevel } from "../utils/logger"; import { NIP46DiagnosticLogger } from "./utils/diagnostics"; import { generateRequestId } from "./utils/request-response"; import { NIP46ClientEngine } from "./internal/client-engine"; -import { PendingNIP46Request } from "./internal/request-correlator"; import { NIP46ClientOptions, NIP46ConnectionError, @@ -71,15 +70,6 @@ export class NostrRemoteSignerClient { }); } - // Runtime-compatible private seams retained for the existing test contract. - private get connected(): boolean { - return this.engine.connected; - } - - private get pendingRequests(): Map { - return this.engine.pendingRequests; - } - /** Connect and retain the advanced facade's `ack`/secret return contract. */ public async connect(connectionString: string): Promise { this.logger.info("Connecting to signer", { connectionString }); diff --git a/src/nip46/internal/replay-guard.ts b/src/nip46/internal/replay-guard.ts new file mode 100644 index 00000000..129dd281 --- /dev/null +++ b/src/nip46/internal/replay-guard.ts @@ -0,0 +1,46 @@ +const DEFAULT_REPLAY_WINDOW_MS = 120_000; + +export interface NIP46ReplayGuardOptions { + windowMs?: number; + now?: () => number; +} + +/** Owns the bounded replay window for NIP-46 request identifiers. */ +export class NIP46ReplayGuard { + private readonly seenAt = new Map(); + private readonly windowMs: number; + private readonly now: () => number; + + constructor(options: NIP46ReplayGuardOptions = {}) { + this.windowMs = options.windowMs ?? DEFAULT_REPLAY_WINDOW_MS; + this.now = options.now ?? Date.now; + } + + /** Return true for an already-seen ID, otherwise record it. */ + isReplay(requestId: string): boolean { + if (this.seenAt.has(requestId)) return true; + this.seenAt.set(requestId, this.now()); + return false; + } + + /** Remove IDs older than the configured replay window. */ + cleanup(): number { + const now = this.now(); + let cleaned = 0; + for (const [requestId, timestamp] of this.seenAt) { + if (now - timestamp > this.windowMs) { + this.seenAt.delete(requestId); + cleaned += 1; + } + } + return cleaned; + } + + clear(): void { + this.seenAt.clear(); + } + + get size(): number { + return this.seenAt.size; + } +} diff --git a/src/nip46/simple-client.ts b/src/nip46/simple-client.ts index 92334987..410f5844 100644 --- a/src/nip46/simple-client.ts +++ b/src/nip46/simple-client.ts @@ -7,7 +7,6 @@ import { NIP46DecryptionError, NIP46EncryptionError, NIP46Error, - NIP46KeyPair, NIP46Method, NIP46SigningError, NIP46TimeoutError, @@ -60,11 +59,6 @@ export class SimpleNIP46Client { }); } - // Runtime-compatible private seam retained for the existing test contract. - private get clientKeys(): NIP46KeyPair { - return this.engine.clientKeys; - } - /** Connect and retain the simple facade's user-pubkey return contract. */ async connect(connectionString: string): Promise { this.logger.info("Connecting to signer", { connectionString }); diff --git a/src/nip47/client.ts b/src/nip47/client.ts index 27923008..d5d500c0 100644 --- a/src/nip47/client.ts +++ b/src/nip47/client.ts @@ -33,12 +33,7 @@ import { import { SecurityValidationError } from "../utils/security-validator"; import { Logger, LogLevel } from "../utils/logger"; import type { DiagnosticLogger } from "../utils/logger"; -import { - generateNWCURL, - parseNIP47Response, - parseNWCURL, - validateNIP47Response, -} from "./protocol"; +import { generateNWCURL, parseNIP47Response, parseNWCURL } from "./protocol"; export { generateNWCURL, parseNWCURL }; @@ -436,10 +431,14 @@ export class NostrWalletConnectClient { event.kind === NIP47EventKind.NOTIFICATION || event.kind === NIP47EventKind.NOTIFICATION_NIP44 ) { - this.logger.debug(`Processing as NOTIFICATION event (kind ${event.kind})`); + this.logger.debug( + `Processing as NOTIFICATION event (kind ${event.kind})`, + ); await this.handleNotification(event); } else if (event.kind === NIP47EventKind.INFO) { - this.logger.debug(`Processing as INFO event (kind ${NIP47EventKind.INFO})`); + this.logger.debug( + `Processing as INFO event (kind ${NIP47EventKind.INFO})`, + ); this.handleInfoEvent(event); } else { this.logger.debug( @@ -448,17 +447,6 @@ export class NostrWalletConnectClient { } } - /** - * Validate that a response follows the NIP-47 specification structure - */ - private validateResponse(response: unknown): NIP47Response { - return validateNIP47Response( - response, - (message) => - new NIP47ClientError(message, NIP47ErrorCode.INVALID_REQUEST), - ); - } - /** * Handle response events */ @@ -869,8 +857,8 @@ export class NostrWalletConnectClient { info.notifications ?? this.supportedNotifications; const nextEncryption = info.encryption ? info.encryption - .map((s) => s as NIP47EncryptionScheme) - .filter((s) => Object.values(NIP47EncryptionScheme).includes(s)) + .map((s) => s as NIP47EncryptionScheme) + .filter((s) => Object.values(NIP47EncryptionScheme).includes(s)) : [NIP47EncryptionScheme.NIP04]; // Commit the capability snapshot only after every field validates. diff --git a/src/testing/behavior-controls.ts b/src/testing/behavior-controls.ts new file mode 100644 index 00000000..19f23d69 --- /dev/null +++ b/src/testing/behavior-controls.ts @@ -0,0 +1,299 @@ +import type { Nostr } from "../nip01/nostr"; +import type { Relay } from "../nip01/relay"; +import type { RelayRegistry } from "../nip01/relayRegistry"; +import type { NostrRemoteSignerBunker } from "../nip46/bunker"; +import type { NIP46ClientEngine } from "../nip46/internal/client-engine"; +import type { NIP46RateLimiter } from "../nip46/utils/rate-limiter"; +import type { NIP46Request, NIP46Response } from "../nip46/types"; +import type { NostrWalletConnectClient } from "../nip47/client"; +import type { NostrWalletService } from "../nip47/service"; +import type { + NIP47EncryptionScheme, + NIP47Request, + NIP47Response, +} from "../nip47/types"; +import type { + NostrEvent, + PublishOptions, + PublishResponse, +} from "../types/nostr"; + +/** Minimal socket surface used when a test must drive Relay transport behavior. */ +export interface RelayTestSocket { + readyState: number; + onopen?: ((event: unknown) => void) | null; + onclose?: ((event: unknown) => void) | null; + onerror?: ((event: unknown) => void) | null; + onmessage?: ((event: { data: unknown }) => void) | null; + send(data: string): void; + close(code?: number, reason?: string): void; + terminate?: () => void; +} + +/** Deliver one decoded relay frame through the production message handler. */ +export function dispatchRelayMessage(relay: Relay, message: unknown[]): void { + ( + relay as unknown as { + handleMessage(value: unknown[]): void; + } + ).handleMessage(message); +} + +/** Wait until validation work triggered by a delivered EVENT frame has settled. */ +export async function waitForRelayValidation( + relay: Relay, + subscriptionId: string, + maxMicrotaskTurns = 1000, +): Promise { + const pending = ( + relay as unknown as { + pendingValidationCounts: Map; + } + ).pendingValidationCounts; + for (let turn = 0; turn < maxMicrotaskTurns; turn += 1) { + if ((pending.get(subscriptionId) ?? 0) === 0) return; + await new Promise((resolve) => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + channel.port1.close(); + channel.port2.close(); + resolve(); + }; + channel.port2.postMessage(undefined); + }); + } + throw new Error( + `Timed out waiting for inbound EVENT validation for ${subscriptionId}`, + ); +} + +/** Replace the inbound validator to hold or fail one lifecycle-sensitive test. */ +export function replaceRelayInboundValidator( + relay: Relay, + validator: (event: unknown) => Promise, +): () => void { + const target = relay as unknown as { + validateInboundEvent(event: unknown): Promise; + }; + const original = target.validateInboundEvent; + target.validateInboundEvent = validator; + return () => { + target.validateInboundEvent = original; + }; +} + +/** Install a deterministic socket and connection flag, returning a restore hook. */ +export function installRelaySocket( + relay: Relay, + socket: RelayTestSocket | null, + connected = socket?.readyState === 1, +): () => void { + const target = relay as unknown as { + ws: RelayTestSocket | null; + connected: boolean; + }; + const originalSocket = target.ws; + const originalConnected = target.connected; + target.ws = socket; + target.connected = connected; + return () => { + target.ws = originalSocket; + target.connected = originalConnected; + }; +} + +/** Return the active socket only for tests that exercise the wire peer directly. */ +export function getRelaySocket(relay: Relay): RelayTestSocket | null { + return (relay as unknown as { ws: RelayTestSocket | null }).ws; +} + +/** Trigger scheduling without exposing Relay's other private methods. */ +export function scheduleRelayReconnect(relay: Relay): void { + (relay as unknown as { scheduleReconnect(): void }).scheduleReconnect(); +} + +/** + * Invoke only the bunker connect handler for auth-challenge protocol tests. + * This intentionally bypasses engine middleware such as replay protection. + */ +export function invokeNip46BunkerConnect( + bunker: NostrRemoteSignerBunker, + request: NIP46Request, + clientPubkey: string, +): Promise { + return ( + bunker as unknown as { + handleConnect( + value: NIP46Request, + requester: string, + ): Promise; + } + ).handleConnect(request, clientPubkey); +} + +export interface NIP46ClientEngineLifecycleHooks { + prepareConnection?: () => Promise; + setupSubscription?: () => Promise; + cleanup?: () => Promise; +} + +/** Install lifecycle gates without exposing the engine's other private state. */ +export function installNip46ClientEngineLifecycleHooks( + engine: NIP46ClientEngine, + hooks: NIP46ClientEngineLifecycleHooks, +): () => void { + const target = engine as unknown as { + prepareConnection(): Promise; + setupSubscription(): Promise; + cleanup(): Promise; + }; + const originals = { + prepareConnection: target.prepareConnection, + setupSubscription: target.setupSubscription, + cleanup: target.cleanup, + }; + if (hooks.prepareConnection) + target.prepareConnection = hooks.prepareConnection; + if (hooks.setupSubscription) + target.setupSubscription = hooks.setupSubscription; + if (hooks.cleanup) target.cleanup = hooks.cleanup; + return () => { + target.prepareConnection = originals.prepareConnection; + target.setupSubscription = originals.setupSubscription; + target.cleanup = originals.cleanup; + }; +} + +/** Replace only the rate-limiter teardown fault used by bunker stop tests. */ +export function replaceNip46RateLimiterDestroy( + bunker: NostrRemoteSignerBunker, + destroy: () => void, +): () => void { + const limiter = ( + bunker as unknown as { + rateLimiter: NIP46RateLimiter; + } + ).rateLimiter; + const original = limiter.destroy; + limiter.destroy = destroy; + return () => { + limiter.destroy = original; + }; +} + +/** Replace only the capability-discovery wait used during client initialization. */ +export function replaceNip47CapabilityDiscoveryWait( + client: NostrWalletConnectClient, + wait: () => Promise, +): () => void { + const target = client as unknown as { + waitForCapabilityDiscovery(): Promise; + }; + const original = target.waitForCapabilityDiscovery; + target.waitForCapabilityDiscovery = wait; + return () => { + target.waitForCapabilityDiscovery = original; + }; +} + +export type NIP47RequestSender = ( + request: NIP47Request, + expiration?: number, + allowDuringInitialization?: boolean, +) => Promise; + +/** Replace only request sending to exercise capability fallback outcomes. */ +export function replaceNip47RequestSender( + client: NostrWalletConnectClient, + send: NIP47RequestSender, +): () => void { + const target = client as unknown as { + sendRequest( + request: NIP47Request, + expiration?: number, + allowDuringInitialization?: boolean, + ): Promise; + }; + const original = target.sendRequest; + target.sendRequest = send; + return () => { + target.sendRequest = original; + }; +} + +/** Deliver one correlated response through the production NIP-47 response path. */ +export async function dispatchNip47ClientResponse( + client: NostrWalletConnectClient, + requestId: string, + encryptionScheme: NIP47EncryptionScheme, + event: NostrEvent, +): Promise { + const target = client as unknown as { + pendingRequests: Map< + string, + { + encryptionScheme: NIP47EncryptionScheme; + resolve: (response: NIP47Response) => void; + } + >; + handleResponse(value: NostrEvent): Promise; + }; + target.pendingRequests.set(requestId, { + encryptionScheme, + resolve: () => undefined, + }); + try { + await target.handleResponse(event); + } finally { + target.pendingRequests.delete(requestId); + } +} + +/** Deliver one request through the production NIP-47 service protocol handler. */ +export async function dispatchNip47ServiceRequest( + service: NostrWalletService, + event: NostrEvent, +): Promise { + const target = service as unknown as { + handleEvent(value: NostrEvent): Promise; + }; + await target.handleEvent(event); +} + +export interface NostrTestRelay { + publish?( + event: NostrEvent, + options?: PublishOptions, + ): Promise; + connect?(): Promise; + disconnect(): unknown; + on?(...args: unknown[]): unknown; + off?(...args: unknown[]): unknown; + subscribe?(...args: unknown[]): string; + unsubscribe?(subscriptionId: string): void; + authenticate?( + event: NostrEvent, + options?: PublishOptions, + ): Promise; + getLatestReplaceableEvent?( + pubkey: string, + kind: number, + ): NostrEvent | undefined; + getLatestAddressableEvent?( + kind: number, + pubkey: string, + dTagValue: string, + ): NostrEvent | undefined; + getAddressableEventsByPubkey?(pubkey: string): NostrEvent[]; + getAddressableEventsByKind?(kind: number): NostrEvent[]; +} + +/** Install one deterministic relay double into Nostr's owned registry. */ +export function installNostrTestRelay( + nostr: Nostr, + url: string, + relay: NostrTestRelay, +): void { + const registry = (nostr as unknown as { relays: RelayRegistry }).relays; + registry.set(url, relay as unknown as Relay); +} diff --git a/src/testing/index.ts b/src/testing/index.ts index fdb5815a..d2ecdd2d 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -6,6 +6,28 @@ */ export { NostrRelay } from "../utils/ephemeral-relay"; export type { NostrRelayOptions } from "../utils/ephemeral-relay"; +export { + dispatchNip47ClientResponse, + dispatchNip47ServiceRequest, + dispatchRelayMessage, + getRelaySocket, + installNostrTestRelay, + installNip46ClientEngineLifecycleHooks, + installRelaySocket, + invokeNip46BunkerConnect, + replaceNip46RateLimiterDestroy, + replaceNip47CapabilityDiscoveryWait, + replaceNip47RequestSender, + replaceRelayInboundValidator, + scheduleRelayReconnect, + waitForRelayValidation, +} from "./behavior-controls"; +export type { + NIP46ClientEngineLifecycleHooks, + NIP47RequestSender, + NostrTestRelay, + RelayTestSocket, +} from "./behavior-controls"; import type { RelayEvent, diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 6c980ebb..5285b79c 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -2,7 +2,6 @@ import { Nostr } from "../src/nip01/nostr"; import { NostrEvent, RelayEvent } from "../src/types/nostr"; import { generateKeypair } from "../src/utils/crypto"; import { startEphemeralRelay, stopEphemeralRelay } from "./utils/test-helpers"; -import { getNostrInternals } from "./types"; describe("Nostr Client Integration", () => { let nostr: Nostr; @@ -51,16 +50,11 @@ describe("Nostr Client Integration", () => { it("should handle connection timeouts gracefully", async () => { // Create a new client with a non-routable address to force timeout - const timeoutClient = new Nostr(["ws://10.255.255.255:8080"]); + const timeoutClient = new Nostr(["ws://10.255.255.255:8080"], { + relayOptions: { connectionTimeout: 500 }, + }); timeoutClient.setPrivateKey(privateKey); - // Set a very short timeout to make test fast - // Access the relay directly to set the timeout - const relays = getNostrInternals(timeoutClient).relays; - for (const relay of relays.values()) { - relay.setConnectionTimeout(500); - } - // Track connection errors let errorCount = 0; timeoutClient.on(RelayEvent.Error, () => { diff --git a/tests/nip01/event/addressable-events.test.ts b/tests/nip01/event/addressable-events.test.ts index b6c24f25..3d99dc65 100644 --- a/tests/nip01/event/addressable-events.test.ts +++ b/tests/nip01/event/addressable-events.test.ts @@ -3,16 +3,14 @@ import { createAddressableEvent, createSignedEvent, } from "../../../src/nip01/event"; -import { Relay } from "../../../src/nip01/relay"; -import { asTestRelay } from "../../types"; +import { RelayEventStore } from "../../../src/nip01/relayEventStore"; import { NostrEvent } from "../../../src/types/nostr"; // Mock WebSocket jest.mock("websocket-polyfill", () => ({})); describe("Addressable Events (NIP-01 §7.1)", () => { - let relay: Relay; - let testRelay: ReturnType; + let store: RelayEventStore; let user1Keypair: { privateKey: string; publicKey: string }; let testEvents: { event1: NostrEvent; @@ -22,9 +20,7 @@ describe("Addressable Events (NIP-01 §7.1)", () => { }; beforeEach(async () => { - // Create a relay instance and get test access to private methods - relay = new Relay("wss://test.relay"); - testRelay = asTestRelay(relay); + store = new RelayEventStore(); // Set up test data user1Keypair = await generateKeypair(); @@ -71,17 +67,17 @@ describe("Addressable Events (NIP-01 §7.1)", () => { test("Different d-tag values create separate addressable events", async () => { // Process events with same pubkey and kind but different d-tag values - testRelay.eventStore.storeAddressable(testEvents.event1); - testRelay.eventStore.storeAddressable(testEvents.event2); + store.storeAddressable(testEvents.event1); + store.storeAddressable(testEvents.event2); // Get latest events for each d-tag value - const latest1 = relay.getLatestAddressableEvent( + const latest1 = store.getAddressable( 30000, user1Keypair.publicKey, "value1", ); - const latest2 = relay.getLatestAddressableEvent( + const latest2 = store.getAddressable( 30000, user1Keypair.publicKey, "value2", @@ -96,13 +92,13 @@ describe("Addressable Events (NIP-01 §7.1)", () => { test("Newer event with same d-tag replaces older one", async () => { // Process original event - testRelay.eventStore.storeAddressable(testEvents.event1); + store.storeAddressable(testEvents.event1); // Process newer event with same d-tag - testRelay.eventStore.storeAddressable(testEvents.event3); + store.storeAddressable(testEvents.event3); // Get latest event - const latest = relay.getLatestAddressableEvent( + const latest = store.getAddressable( 30000, user1Keypair.publicKey, "value1", @@ -115,17 +111,17 @@ describe("Addressable Events (NIP-01 §7.1)", () => { test("Different kinds with same d-tag are stored separately", async () => { // Process events with same pubkey and d-tag but different kinds - testRelay.eventStore.storeAddressable(testEvents.event1); - testRelay.eventStore.storeAddressable(testEvents.event4); + store.storeAddressable(testEvents.event1); + store.storeAddressable(testEvents.event4); // Get latest events for each kind - const latest1 = relay.getLatestAddressableEvent( + const latest1 = store.getAddressable( 30000, user1Keypair.publicKey, "value1", ); - const latest2 = relay.getLatestAddressableEvent( + const latest2 = store.getAddressable( 30001, user1Keypair.publicKey, "value1", @@ -170,10 +166,10 @@ describe("Addressable Events (NIP-01 §7.1)", () => { // At this point, eventA.id < eventB.id is guaranteed // Scenario 1: Process larger ID event first, then smaller ID event - testRelay.processAddressableEvent(eventB); // Process B (larger id) - testRelay.processAddressableEvent(eventA); // Process A (smaller id) + store.storeAddressable(eventB); // Process B (larger id) + store.storeAddressable(eventA); // Process A (smaller id) - let latestEvent = relay.getLatestAddressableEvent( + let latestEvent = store.getAddressable( 30000, user1Keypair.publicKey, dTagValue, @@ -189,10 +185,10 @@ describe("Addressable Events (NIP-01 §7.1)", () => { // For this specific test targeting `processAddressableEvent` directly, the previous event is overwritten. // Scenario 2: Process smaller ID event first, then larger ID event - testRelay.processAddressableEvent(eventA); // Process A (smaller id) - testRelay.processAddressableEvent(eventB); // Process B (larger id) + store.storeAddressable(eventA); // Process A (smaller id) + store.storeAddressable(eventB); // Process B (larger id) - latestEvent = relay.getLatestAddressableEvent( + latestEvent = store.getAddressable( 30000, user1Keypair.publicKey, dTagValue, diff --git a/tests/nip01/event/event-ordering-integration.test.ts b/tests/nip01/event/event-ordering-integration.test.ts index 979c6953..8e2887e0 100644 --- a/tests/nip01/event/event-ordering-integration.test.ts +++ b/tests/nip01/event/event-ordering-integration.test.ts @@ -5,12 +5,22 @@ import { Relay } from "../../../src/nip01/relay"; import { NostrEvent } from "../../../src/types/nostr"; -import { RelayEventStore } from "../../../src/nip01/relayEventStore"; +import { + replaceRelayInboundValidator, + waitForRelayValidation, +} from "../../../src/testing"; import { useWebSocketImplementation, resetWebSocketImplementation, } from "../../../src/utils/websocket"; -import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from "@jest/globals"; /** * Mock WebSocket interface for testing @@ -37,13 +47,7 @@ describe("Relay Event Ordering Integration", () => { let relay: Relay; let onEventCallback: jest.Mock; let onEOSECallback: jest.Mock; - - // Type assertion function to access private members for testing - const asTestable = (r: Relay) => - r as unknown as { - flushSubscriptionBuffer: (subscriptionId: string) => void; - eventStore: RelayEventStore; - }; + let restoreValidator: () => void; beforeEach(() => { jest.useFakeTimers(); @@ -72,16 +76,17 @@ describe("Relay Event Ordering Integration", () => { // Create a relay with a 50ms buffer flush delay relay = new Relay("wss://test-relay.com", { bufferFlushDelay: 50 }); - // Expose private methods for testing - const testable = asTestable(relay); - testable.flushSubscriptionBuffer = - testable.flushSubscriptionBuffer.bind(relay); + restoreValidator = replaceRelayInboundValidator( + relay, + async (event) => event as NostrEvent, + ); }); afterEach(() => { jest.clearAllMocks(); jest.useRealTimers(); resetWebSocketImplementation(); + restoreValidator?.(); if (relay) { relay.disconnect(); } @@ -145,14 +150,13 @@ describe("Relay Event Ordering Integration", () => { sig: "sig", }; - // Manually add events to the buffer - const store = asTestable(relay).eventStore; for (const event of [event1, event3, event4, event2]) { - store.addToBuffer(subscriptionId, event); + mockSocketInstance.onmessage?.({ + data: JSON.stringify(["EVENT", subscriptionId, event]), + } as MessageEvent); } - - // Directly call flushSubscriptionBuffer - asTestable(relay).flushSubscriptionBuffer(subscriptionId); + await waitForRelayValidation(relay, subscriptionId); + jest.advanceTimersByTime(50); // Now events should be delivered in the correct order expect(onEventCallback).toHaveBeenCalledTimes(4); @@ -212,15 +216,17 @@ describe("Relay Event Ordering Integration", () => { sig: "sig", }; - // Manually add events to the buffer - const store = asTestable(relay).eventStore; - store.addToBuffer(subscriptionId, event1); - store.addToBuffer(subscriptionId, event2); + for (const event of [event1, event2]) { + mockSocketInstance.onmessage?.({ + data: JSON.stringify(["EVENT", subscriptionId, event]), + } as MessageEvent); + } // Simulate receiving EOSE mockSocketInstance.onmessage?.({ data: JSON.stringify(["EOSE", subscriptionId]), } as MessageEvent); + await waitForRelayValidation(relay, subscriptionId); // EOSE should trigger immediate buffer flush expect(onEventCallback).toHaveBeenCalledTimes(2); @@ -260,11 +266,9 @@ describe("Relay Event Ordering Integration", () => { sig: "sig", }; - // Manually add events to the buffer - asTestable(relay).eventStore.addToBuffer(subscriptionId, event); - - // Directly call flushSubscriptionBuffer - asTestable(relay).flushSubscriptionBuffer(subscriptionId); + mockSocketInstance.onmessage?.({ + data: JSON.stringify(["EVENT", subscriptionId, event]), + } as MessageEvent); // No events should be delivered expect(onEventCallback).not.toHaveBeenCalled(); diff --git a/tests/nip01/event/nostr-publish.test.ts b/tests/nip01/event/nostr-publish.test.ts index d223302c..2ff9c697 100644 --- a/tests/nip01/event/nostr-publish.test.ts +++ b/tests/nip01/event/nostr-publish.test.ts @@ -7,6 +7,7 @@ import { RelayStatus, NostrFilter, } from "../../../src/types/nostr"; +import { installNostrTestRelay } from "../../../src/testing"; // Simulated relay for testing class MockRelay { @@ -79,37 +80,20 @@ class MockRelay { } } -// Interface for Nostr private members we need to access in tests -interface NostrPrivateMembers { - privateKey: string; - publicKey: string; - relays: Map; // Properly typed for our test context -} - -// Extended Nostr class for testing that exposes private members -class TestNostr extends Nostr { - getPrivateMembers(): NostrPrivateMembers { - return this as unknown as NostrPrivateMembers; - } - - setPrivateKey(key: string): void { - this.getPrivateMembers().privateKey = key; - } - - setPublicKey(key: string): void { - this.getPrivateMembers().publicKey = key; - } - - setRelays(relays: Map): void { - this.getPrivateMembers().relays = relays; +function installMockRelays( + nostr: Nostr, + relays: ReadonlyMap, +): void { + for (const [url, relay] of relays) { + installNostrTestRelay(nostr, url, relay); } } describe("Nostr publish methods", () => { - let nostr: TestNostr; + let nostr: Nostr; beforeEach(() => { - nostr = new TestNostr(); + nostr = new Nostr(); }); describe("publishEvent", () => { @@ -130,12 +114,7 @@ describe("Nostr publish methods", () => { ); // Inject mock relays - nostr.setRelays(relays); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - + installMockRelays(nostr, relays); // Create test event const event: NostrEvent = { id: "test-event-id", @@ -175,12 +154,7 @@ describe("Nostr publish methods", () => { ); // Inject mock relays - nostr.setRelays(relays); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - + installMockRelays(nostr, relays); // Create test event const event: NostrEvent = { id: "test-event-id", @@ -205,12 +179,6 @@ describe("Nostr publish methods", () => { it("should handle having no relays configured", async () => { // Empty relays map - nostr.setRelays(new Map()); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - // Create test event const event: NostrEvent = { id: "test-event-id", @@ -256,12 +224,7 @@ describe("Nostr publish methods", () => { ); // Inject mock relays - nostr.setRelays(relays); - - // Set required keys - nostr.setPrivateKey("test-private-key"); - nostr.setPublicKey("test-public-key"); - + installMockRelays(nostr, relays); // Create test event const event: NostrEvent = { id: "test-event-id", diff --git a/tests/nip01/nostr.test.ts b/tests/nip01/nostr.test.ts index cd759881..2fb277c5 100644 --- a/tests/nip01/nostr.test.ts +++ b/tests/nip01/nostr.test.ts @@ -3,14 +3,18 @@ import { NostrEvent, Filter, RelayEvent, - Relay, RelayReceivedEvent, } from "../../src"; -import { NostrRelay } from "../../src/testing"; +import { + dispatchRelayMessage, + getRelaySocket, + installNostrTestRelay, + NostrRelay, +} from "../../src/testing"; import { generateKeypair } from "../../src/utils/crypto"; import { encrypt as encryptNIP04 } from "../../src/nip04"; import { createMetadataEvent } from "../../src/nip01/event"; -import { getNostrInternals, asTestRelay, testUtils } from "../types"; +import { testUtils } from "../types"; // Use ephemeral relay for all tests let ephemeralRelay: NostrRelay; @@ -72,9 +76,8 @@ describe("Nostr Client", () => { client.addRelay(additionalRelay); // This test verifies the relay was added but doesn't need to connect - const relays = getNostrInternals(client).relays; const normalizedRelay = testUtils.normalizeRelayUrl(additionalRelay); - expect(relays.has(normalizedRelay)).toBe(true); + expect(client.getRelay(normalizedRelay)).toBeDefined(); }); test("should use one Relay identity across normalized public operations", () => { @@ -84,7 +87,6 @@ describe("Nostr Client", () => { const existing = client.getRelay(ephemeralRelay.url); expect(client.addRelay(equivalentUrl)).toBe(existing); expect(client.getRelay(equivalentUrl)).toBe(existing); - expect(getNostrInternals(client).relays.size).toBe(1); client.removeRelay(equivalentUrl); expect(client.getRelay(ephemeralRelay.url)).toBeUndefined(); @@ -95,7 +97,7 @@ describe("Nostr Client", () => { expect(() => client.removeRelay("https://not-a-relay.example"), ).not.toThrow(); - expect(getNostrInternals(client).relays.size).toBe(1); + expect(client.getRelay(ephemeralRelay.url)).toBeDefined(); }); test("should connect to relays", async () => { @@ -114,8 +116,8 @@ describe("Nostr Client", () => { challenge = nextChallenge; }); - const relay = Array.from(getNostrInternals(client).relays.values())[0]; - asTestRelay(relay as Relay).handleMessage(["AUTH", "relay-challenge"]); + const relay = client.getRelay(ephemeralRelay.url)!; + dispatchRelayMessage(relay, ["AUTH", "relay-challenge"]); expect(challenge).toBe("relay-challenge"); }); @@ -141,9 +143,8 @@ describe("Nostr Client", () => { const relayUrl = ephemeralRelay.url; client.removeRelay(relayUrl); - const relays = getNostrInternals(client).relays; const normalizedRelayUrl = testUtils.normalizeRelayUrl(relayUrl); - expect(relays.has(normalizedRelayUrl)).toBe(false); + expect(client.getRelay(normalizedRelayUrl)).toBeUndefined(); }); }); @@ -161,7 +162,7 @@ describe("Nostr Client", () => { }); test("should publish metadata", async () => { - await client.generateKeys(); + const keys = await client.generateKeys(); await client.connectToRelays(); const metadata = { @@ -176,12 +177,8 @@ describe("Nostr Client", () => { // In test environments, the event might return null if the relay doesn't respond in time // But we can verify the event was created correctly before publishing if (!event) { - // Directly access the last created event to verify it was created properly - const privateKey = getNostrInternals(client).privateKey; - const _publicKey = getNostrInternals(client).publicKey; - // Create the event directly to verify structure - const metadataEvent = createMetadataEvent(metadata, privateKey); + const metadataEvent = createMetadataEvent(metadata, keys.privateKey); expect(metadataEvent.kind).toBe(0); expect(JSON.parse(metadataEvent.content)).toEqual(metadata); } else { @@ -208,10 +205,9 @@ describe("Nostr Client", () => { const note = await client.publishTextNote("relay-aware fetch"); expect(note).toBeDefined(); - const events = await client.fetchManyDetailed( - [{ ids: [note!.id] }], - { maxWait: 500 }, - ); + const events = await client.fetchManyDetailed([{ ids: [note!.id] }], { + maxWait: 500, + }); expect(events).toHaveLength(2); expect(events.map((event) => event.event.id)).toEqual([ @@ -278,9 +274,9 @@ describe("Nostr Client", () => { ); expect(subscriptionIdsByRelay.get(ephemeralRelay.url)).toBeTruthy(); expect(subscriptionIdsByRelay.get(secondRelay.url)).toBeTruthy(); - expect( - subscriptionIdsByRelay.get(ephemeralRelay.url), - ).not.toEqual(subscriptionIdsByRelay.get(secondRelay.url)); + expect(subscriptionIdsByRelay.get(ephemeralRelay.url)).not.toEqual( + subscriptionIdsByRelay.get(secondRelay.url), + ); } finally { await secondRelay.close(); } @@ -428,8 +424,7 @@ describe("Nostr Client", () => { autoClose: true, }); - const relayMap = getNostrInternals(client).relays as Map; - const relay = Array.from(relayMap.values())[0] as Relay; + const relay = client.getRelay(ephemeralRelay.url)!; expect(relay.getSubscriptionIds().has(subIds[0])).toBe(true); @@ -447,10 +442,10 @@ describe("Nostr Client", () => { const client = new Nostr([mockRelay.url]); await client.connectToRelays(); - // Get first relay using type assertion to access private property - const relayMap = getNostrInternals(client).relays as Map; - const relay = Array.from(relayMap.values())[0] as Relay; + const relay = client.getRelay(mockRelay.url)!; expect(relay).toBeDefined(); + const connectedSocket = getRelaySocket(relay); + expect(connectedSocket?.readyState).toBe(1); // Spy on relay's unsubscribe method const unsubscribeSpy = jest.spyOn(relay, "unsubscribe"); @@ -475,8 +470,9 @@ describe("Nostr Client", () => { // Verify all subscriptions were removed expect(relay.getSubscriptionIds().size).toBe(0); - // Verify relay is still connected (not disconnected) - expect(asTestRelay(relay).connected).toBe(true); + // Verify unsubscribeAll preserved the same open transport. + expect(getRelaySocket(relay)).toBe(connectedSocket); + expect(connectedSocket?.readyState).toBe(1); // Cleanup client.disconnectFromRelays(); @@ -574,10 +570,8 @@ describe("Nostr Client", () => { expect(events[0].content).toBe("multi-note"); // ensure subscriptions cleaned up - getNostrInternals(multi).relays.forEach((relay) => { - const testRelay = asTestRelay(relay as Relay); - expect(testRelay.getSubscriptionIds().size).toBe(0); - }); + expect(multi.getRelay(relayA.url)?.getSubscriptionIds().size).toBe(0); + expect(multi.getRelay(relayB.url)?.getSubscriptionIds().size).toBe(0); multi.disconnectFromRelays(); await relayA.close(); @@ -1003,13 +997,10 @@ describe("Nostr client", () => { ]); // Inject our mock relays into the Nostr client - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay1); - getNostrInternals(nostr).relays.set("wss://mock-relay2.com", relay2); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay1); + installNostrTestRelay(nostr, "wss://mock-relay2.com", relay2); // Set keys so we don't throw errors - getNostrInternals(nostr).privateKey = "test-private-key"; - getNostrInternals(nostr).publicKey = "test-public-key"; - // Create a test event const event: NostrEvent = { id: "test-event-id", @@ -1051,13 +1042,10 @@ describe("Nostr client", () => { ]); // Inject our mock relays into the Nostr client - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay1); - getNostrInternals(nostr).relays.set("wss://mock-relay2.com", relay2); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay1); + installNostrTestRelay(nostr, "wss://mock-relay2.com", relay2); // Set keys so we don't throw errors - getNostrInternals(nostr).privateKey = "test-private-key"; - getNostrInternals(nostr).publicKey = "test-public-key"; - // Create a test event const event: NostrEvent = { id: "test-event-id", @@ -1094,7 +1082,7 @@ describe("Nostr client", () => { }); const relay = new MockRelay("wss://mock-relay1.com", [{ success: true }]); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); const event = (id: string): NostrEvent => ({ id, @@ -1106,12 +1094,16 @@ describe("Nostr client", () => { sig: "test-signature", }); - await expect(nostr.publishEvent(event("event-1"))).resolves.toMatchObject({ - success: true, - }); - await expect(nostr.publishEvent(event("event-2"))).resolves.toMatchObject({ - success: true, - }); + await expect(nostr.publishEvent(event("event-1"))).resolves.toMatchObject( + { + success: true, + }, + ); + await expect(nostr.publishEvent(event("event-2"))).resolves.toMatchObject( + { + success: true, + }, + ); await expect(nostr.publishEvent(event("event-3"))).rejects.toThrow( /Publish rate limit exceeded/, ); @@ -1135,14 +1127,11 @@ describe("Nostr client", () => { ]); // Inject our mock relays into the Nostr client - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay1); - getNostrInternals(nostr).relays.set("wss://mock-relay2.com", relay2); - getNostrInternals(nostr).relays.set("wss://mock-relay3.com", relay3); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay1); + installNostrTestRelay(nostr, "wss://mock-relay2.com", relay2); + installNostrTestRelay(nostr, "wss://mock-relay3.com", relay3); // Set keys so we don't throw errors - getNostrInternals(nostr).privateKey = "test-private-key"; - getNostrInternals(nostr).publicKey = "test-public-key"; - // Create a test event const event: NostrEvent = { id: "test-event-id", @@ -1185,9 +1174,8 @@ describe("Nostr client", () => { const relay = new MockRelay("wss://mock-relay1.com"); const keys = await generateKeypair(); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); + nostr.setPrivateKey(keys.privateKey); const result = await nostr.authenticateRelay( "wss://mock-relay1.com", @@ -1211,9 +1199,8 @@ describe("Nostr client", () => { const relay = new MockRelay("wss://mock-relay1.com"); const keys = await generateKeypair(); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); + nostr.setPrivateKey(keys.privateKey); await nostr.authenticateRelay("WSS://MOCK-RELAY1.COM/", "challenge-789"); @@ -1229,8 +1216,7 @@ describe("Nostr client", () => { const nostr = new Nostr(); const keys = await generateKeypair(); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + nostr.setPrivateKey(keys.privateKey); await expect( nostr.authenticateRelay("wss://mock-relay1.com", "challenge-789"), @@ -1241,7 +1227,7 @@ describe("Nostr client", () => { const nostr = new Nostr(); const relay = new MockRelay("wss://mock-relay1.com"); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); await expect( nostr.authenticateRelay("wss://mock-relay1.com", "challenge-789"), @@ -1258,9 +1244,8 @@ describe("Nostr client", () => { const relay = new MockRelay("wss://mock-relay1.com"); const keys = await generateKeypair(); - getNostrInternals(nostr).relays.set("wss://mock-relay1.com", relay); - getNostrInternals(nostr).privateKey = keys.privateKey; - getNostrInternals(nostr).publicKey = keys.publicKey; + installNostrTestRelay(nostr, "wss://mock-relay1.com", relay); + nostr.setPrivateKey(keys.privateKey); await expect( nostr.authenticateRelay("wss://mock-relay1.com", "challenge-1"), @@ -1278,24 +1263,20 @@ describe("Detailed subscription cleanup", () => { it("should unsubscribe earlier relays when a later detailed subscribe fails", () => { const nostr = new Nostr(); const firstRelay = { + disconnect: jest.fn(), subscribe: jest.fn().mockReturnValue("sub-1"), unsubscribe: jest.fn(), }; const secondRelay = { + disconnect: jest.fn(), subscribe: jest.fn(() => { throw new Error("subscribe failed"); }), unsubscribe: jest.fn(), }; - getNostrInternals(nostr).relays.set( - "wss://mock-relay1.com", - firstRelay as unknown as Relay, - ); - getNostrInternals(nostr).relays.set( - "wss://mock-relay2.com", - secondRelay as unknown as Relay, - ); + installNostrTestRelay(nostr, "wss://mock-relay1.com", firstRelay); + installNostrTestRelay(nostr, "wss://mock-relay2.com", secondRelay); expect(() => nostr.subscribeDetailed([{ kinds: [1], limit: 1 }], () => {}), @@ -1309,6 +1290,7 @@ describe("Addressable events functionality", () => { let nostr: Nostr; // Define with specific structure for the test let mockRelays: { + disconnect: jest.Mock; getLatestAddressableEvent: jest.Mock; getAddressableEventsByPubkey: jest.Mock; getAddressableEventsByKind: jest.Mock; @@ -1318,11 +1300,13 @@ describe("Addressable events functionality", () => { // Create mock relay objects instead of actual Relay instances mockRelays = [ { + disconnect: jest.fn(), getLatestAddressableEvent: jest.fn(), getAddressableEventsByPubkey: jest.fn(), getAddressableEventsByKind: jest.fn(), }, { + disconnect: jest.fn(), getLatestAddressableEvent: jest.fn(), getAddressableEventsByPubkey: jest.fn(), getAddressableEventsByKind: jest.fn(), @@ -1333,9 +1317,8 @@ describe("Addressable events functionality", () => { nostr = new Nostr(); // Add the mocked relays to the nostr instance - const relays = getNostrInternals(nostr).relays; - relays.set("wss://relay1.example.com", mockRelays[0] as unknown as Relay); - relays.set("wss://relay2.example.com", mockRelays[1] as unknown as Relay); + installNostrTestRelay(nostr, "wss://relay1.example.com", mockRelays[0]); + installNostrTestRelay(nostr, "wss://relay2.example.com", mockRelays[1]); }); test("getLatestAddressableEvent should return the latest event from all relays", () => { diff --git a/tests/nip01/relay/filters.test.ts b/tests/nip01/relay/filters.test.ts index 31bcfaf4..792dcb44 100644 --- a/tests/nip01/relay/filters.test.ts +++ b/tests/nip01/relay/filters.test.ts @@ -11,10 +11,10 @@ import { RelayEvent, } from "../../../src/types/nostr"; import { Relay } from "../../../src/nip01/relay"; -import { NostrRelay } from "../../../src/testing"; +import { getRelaySocket, NostrRelay } from "../../../src/testing"; import { createSignedEvent } from "../../../src/nip01/event"; import { generateKeypair } from "../../../src/utils/crypto"; -import { asTestRelay, testUtils } from "../../types"; +import { testUtils } from "../../types"; // Helper function to wait for events const waitForEvents = ( @@ -72,11 +72,9 @@ describe("Enhanced NostrFilter Types", () => { relay.on(RelayEvent.Notice, handler); }); - const socket = asTestRelay(relay).ws; + const socket = getRelaySocket(relay); expect(socket).not.toBeNull(); - socket?.send( - JSON.stringify(["REQ", "invalid-filter", { kinds: "1" }]), - ); + socket?.send(JSON.stringify(["REQ", "invalid-filter", { kinds: "1" }])); await expect(notice).resolves.toBe("invalid: REQ filters"); }); diff --git a/tests/nip01/relay/relay-reconnect.test.ts b/tests/nip01/relay/relay-reconnect.test.ts index ec576a87..ddb9aeb5 100644 --- a/tests/nip01/relay/relay-reconnect.test.ts +++ b/tests/nip01/relay/relay-reconnect.test.ts @@ -2,9 +2,9 @@ * Tests for Relay Reconnection functionality */ -import { Relay, ReconnectionStrategy } from "../../../src"; +import { Relay } from "../../../src"; +import { scheduleRelayReconnect } from "../../../src/testing"; import { RelayConnectionOptions } from "../../../src/types/protocol"; -import { asTestRelay } from "../../types"; // Mock WebSocket jest.mock("websocket-polyfill", () => ({})); @@ -42,67 +42,77 @@ describe("Relay: Reconnection Configuration", () => { return relay; } - test("should initialize with default reconnection options", () => { - const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); - - // Verify the relay has the expected default values - expect(testRelay.autoReconnect).toBe(true); - expect(testRelay.maxReconnectAttempts).toBe(10); - expect(testRelay.maxReconnectDelay).toBe(30000); - expect(testRelay.reconnectAttempts).toBe(0); - }); + function captureReconnectTimers(): { + callbacks: Array<() => void>; + delays: number[]; + restore(): void; + } { + const callbacks: Array<() => void> = []; + const delays: number[] = []; + const timeoutSpy = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback: TimerHandler, delay?: number) => { + const timer = { unref: () => timer } as unknown as NodeJS.Timeout; + callbacks.push(callback as () => void); + delays.push(delay ?? 0); + return timer; + }); + return { + callbacks, + delays, + restore: () => timeoutSpy.mockRestore(), + }; + } - test("should allow custom reconnection options", () => { + test("applies constructor reconnection limits and capped delay", () => { const options: RelayConnectionOptions = { - autoReconnect: false, - maxReconnectAttempts: 5, - maxReconnectDelay: 15000, + autoReconnect: true, + maxReconnectAttempts: 2, + maxReconnectDelay: 1500, }; - + const timers = captureReconnectTimers(); const relay = createRelay("wss://test.relay", options); - const testRelay = asTestRelay(relay); - - // Verify the custom options were applied - expect(testRelay.autoReconnect).toBe(false); - expect(testRelay.maxReconnectAttempts).toBe(5); - expect(testRelay.maxReconnectDelay).toBe(15000); - }); - - test("should allow changing auto-reconnect setting", () => { - const relay = createRelay("wss://test.relay", { autoReconnect: false }); - const testRelay = asTestRelay(relay); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); - // Verify initial state - expect(testRelay.autoReconnect).toBe(false); - - // Change the setting - relay.setAutoReconnect(true); - - // Verify the setting was updated - expect(testRelay.autoReconnect).toBe(true); - }); - - test("should allow changing max reconnect attempts", () => { - const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); - - // Change the setting - relay.setMaxReconnectAttempts(20); - - // Verify the setting was updated - expect(testRelay.maxReconnectAttempts).toBe(20); + try { + scheduleRelayReconnect(relay); + timers.callbacks[0](); + scheduleRelayReconnect(relay); + timers.callbacks[1](); + scheduleRelayReconnect(relay); + + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(timers.callbacks).toHaveLength(2); + expect(timers.delays[1]).toBeGreaterThanOrEqual(1500); + expect(timers.delays[1]).toBeLessThanOrEqual(1950); + } finally { + connectSpy.mockRestore(); + timers.restore(); + } }); - test("should allow changing max reconnect delay", () => { + test("applies reconnect limit and delay changes to scheduling behavior", () => { const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); - - // Change the setting - relay.setMaxReconnectDelay(60000); + const timers = captureReconnectTimers(); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); - // Verify the setting was updated - expect(testRelay.maxReconnectDelay).toBe(60000); + try { + relay.setMaxReconnectAttempts(2); + relay.setMaxReconnectDelay(1200); + scheduleRelayReconnect(relay); + timers.callbacks[0](); + scheduleRelayReconnect(relay); + timers.callbacks[1](); + scheduleRelayReconnect(relay); + + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(timers.callbacks).toHaveLength(2); + expect(timers.delays[1]).toBeGreaterThanOrEqual(1200); + expect(timers.delays[1]).toBeLessThanOrEqual(1560); + } finally { + connectSpy.mockRestore(); + timers.restore(); + } }); test("should validate max reconnect attempts (non-negative)", () => { @@ -123,11 +133,33 @@ describe("Relay: Reconnection Configuration", () => { }).toThrow(/at least 1000ms/); }); - test("ignores stale queued callbacks without losing replacement timers", () => { + test("disabling auto-reconnect cancels an already queued attempt", () => { + const relay = createRelay("wss://test.relay"); + const queuedReconnects: Array<() => void> = []; + const timeoutSpy = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback: TimerHandler) => { + const timer = { unref: () => timer } as unknown as NodeJS.Timeout; + queuedReconnects.push(callback as () => void); + return timer; + }); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); + + try { + scheduleRelayReconnect(relay); + relay.setAutoReconnect(false); + queuedReconnects[0](); + + expect(connectSpy).not.toHaveBeenCalled(); + } finally { + connectSpy.mockRestore(); + timeoutSpy.mockRestore(); + } + }); + + test("ignores stale queued callbacks without losing the replacement attempt", () => { const relay = createRelay("wss://test.relay"); - const testRelay = asTestRelay(relay); const queuedReconnects: Array<() => void> = []; - const timers: NodeJS.Timeout[] = []; const timeoutSpy = jest .spyOn(globalThis, "setTimeout") .mockImplementation((callback: TimerHandler) => { @@ -135,62 +167,28 @@ describe("Relay: Reconnection Configuration", () => { unref: () => timer, } as unknown as NodeJS.Timeout; queuedReconnects.push(callback as () => void); - timers.push(timer); return timer; }); - const clearTimeoutSpy = jest - .spyOn(globalThis, "clearTimeout") - .mockImplementation(() => {}); + const connectSpy = jest.spyOn(relay, "connect").mockResolvedValue(true); try { - testRelay.scheduleReconnect(); + scheduleRelayReconnect(relay); relay.disconnect(); - testRelay.scheduleReconnect(); + scheduleRelayReconnect(relay); queuedReconnects[0](); - expect(testRelay.reconnectTimer).toBe(timers[1]); - expect(testRelay.reconnectAttempts).toBe(0); + expect(connectSpy).not.toHaveBeenCalled(); - relay.disconnect(); queuedReconnects[1](); + expect(connectSpy).toHaveBeenCalledTimes(1); - expect(testRelay.reconnectAttempts).toBe(0); - expect(testRelay.reconnectTimer).toBeNull(); - expect(testRelay.connectionPromise).toBeNull(); + scheduleRelayReconnect(relay); + relay.disconnect(); + queuedReconnects[2](); + expect(connectSpy).toHaveBeenCalledTimes(1); } finally { - clearTimeoutSpy.mockRestore(); + connectSpy.mockRestore(); timeoutSpy.mockRestore(); } }); - - // Additional test for ReconnectionStrategy interface - test("should be configurable with a full ReconnectionStrategy", () => { - // This test serves as a type-check for future implementation - // that uses the ReconnectionStrategy interface directly - - // Define a strategy that could be used in the future - const strategy: ReconnectionStrategy = { - enabled: true, - maxAttempts: 15, - initialDelay: 1000, - maxDelay: 45000, - backoffFactor: 1.5, - useJitter: true, - jitterFactor: 0.2, - }; - - // Current implementation uses individual properties, but - // we can test that we can extract these properties correctly - const relay = createRelay("wss://test.relay", { - autoReconnect: strategy.enabled, - maxReconnectAttempts: strategy.maxAttempts, - maxReconnectDelay: strategy.maxDelay, - }); - const testRelay = asTestRelay(relay); - - // Verify basic properties were applied - expect(testRelay.autoReconnect).toBe(strategy.enabled); - expect(testRelay.maxReconnectAttempts).toBe(strategy.maxAttempts); - expect(testRelay.maxReconnectDelay).toBe(strategy.maxDelay); - }); }); diff --git a/tests/nip01/relay/relay.test.ts b/tests/nip01/relay/relay.test.ts index 10a61095..f7f259b9 100644 --- a/tests/nip01/relay/relay.test.ts +++ b/tests/nip01/relay/relay.test.ts @@ -11,10 +11,15 @@ import { getPublicKey, signEvent, } from "../../../src"; -import { NostrRelay } from "../../../src/testing"; +import { + dispatchRelayMessage, + installRelaySocket, + NostrRelay, + replaceRelayInboundValidator, + waitForRelayValidation, +} from "../../../src/testing"; import { testUtils } from "../../types"; import { - asTestRelay, RelayConnectCallback, RelayDisconnectCallback, RelayErrorCallback, @@ -72,24 +77,8 @@ async function handleInboundEvent( subscriptionId: string, event: unknown, ): Promise { - const relayInternals = asTestRelay(relay); - relayInternals.handleMessage(["EVENT", subscriptionId, event]); - - const deadline = Date.now() + 1000; - while (Date.now() < deadline) { - const pendingValidations = - relayInternals.pendingValidationCounts.get(subscriptionId) ?? 0; - - if (pendingValidations === 0) { - return; - } - - await testUtils.sleep(1); - } - - throw new Error( - `Timed out waiting for inbound EVENT validation for ${subscriptionId}`, - ); + dispatchRelayMessage(relay, ["EVENT", subscriptionId, event]); + await waitForRelayValidation(relay, subscriptionId); } function expectRelayError(errors: unknown[], message: string): void { @@ -119,24 +108,19 @@ describe("Relay", () => { }); describe("Connection Management", () => { - test("disconnect clears retained state even without an active socket", () => { + test("disconnect discards buffered delivery without an active socket", async () => { const relay = new Relay("wss://example.com"); - const internals = asTestRelay(relay); - const subscriptionId = relay.subscribe([{}], jest.fn()); - internals.eventStore.addToBuffer(subscriptionId, { - id: "buffered", - pubkey: "pubkey", - created_at: 1, - kind: 1, - tags: [], - content: "", - sig: "sig", - }); + const onEvent = jest.fn(); + const subscriptionId = relay.subscribe([{}], onEvent); + const event = await createSignedRelayEvent(); + + dispatchRelayMessage(relay, ["EVENT", subscriptionId, event]); relay.disconnect(); + await testUtils.sleep(20); - expect(internals.subscriptions.size).toBe(0); - expect([...internals.eventStore.bufferIds()]).toEqual([]); + expect(relay.getSubscriptionIds().size).toBe(0); + expect(onEvent).not.toHaveBeenCalled(); }); test("should connect to ephemeral relay", async () => { @@ -244,9 +228,6 @@ describe("Relay", () => { await relay.connect(); relay.disconnect(); - // Verify the connectionPromise is cleared - expect(asTestRelay(relay).connectionPromise).toBeNull(); - // Second connect attempt should work const result = await relay.connect(); expect(result).toBe(true); @@ -278,7 +259,7 @@ describe("Relay", () => { const authHandler = jest.fn(); relay.on(RelayEvent.Auth, authHandler); - asTestRelay(relay).handleMessage(["AUTH", "challenge-123"]); + dispatchRelayMessage(relay, ["AUTH", "challenge-123"]); expect(authHandler).toHaveBeenCalledWith("challenge-123"); }); @@ -290,7 +271,7 @@ describe("Relay", () => { relay.on(RelayEvent.Auth, authHandler); relay.on(RelayEvent.Error, errorHandler); - asTestRelay(relay).handleMessage(["AUTH", 123]); + dispatchRelayMessage(relay, ["AUTH", 123]); expect(authHandler).not.toHaveBeenCalled(); expect(errorHandler).toHaveBeenCalledTimes(1); @@ -418,10 +399,7 @@ describe("Relay", () => { test("should send AUTH events to the relay and wait for OK by default", async () => { const authRelay = new Relay(ephemeralRelay.url); - const relayInternals = asTestRelay(authRelay); - - relayInternals.connected = true; - relayInternals.ws = { + installRelaySocket(authRelay, { readyState: 1, onopen: null, onclose: null, @@ -429,10 +407,15 @@ describe("Relay", () => { onmessage: null, send: jest.fn((payload: string) => { expect(payload).toBe(JSON.stringify(["AUTH", authEvent])); - relayInternals.handleMessage(["OK", authEvent.id, true, "accepted"]); + dispatchRelayMessage(authRelay, [ + "OK", + authEvent.id, + true, + "accepted", + ]); }), close: jest.fn(), - } as unknown as WebSocket; + }); const authEvent: NostrEvent = { id: "auth-event-id", @@ -508,10 +491,7 @@ describe("Relay", () => { test("should support waitForAck false for AUTH events", async () => { const authRelay = new Relay(ephemeralRelay.url); const send = jest.fn(); - const relayInternals = asTestRelay(authRelay); - - relayInternals.connected = true; - relayInternals.ws = { + installRelaySocket(authRelay, { readyState: 1, onopen: null, onclose: null, @@ -519,7 +499,7 @@ describe("Relay", () => { onmessage: null, send, close: jest.fn(), - } as unknown as WebSocket; + }); const authEvent: NostrEvent = { id: "auth-event-id", @@ -558,14 +538,13 @@ describe("Relay", () => { }; // Avoid relay-side validation errors interfering with timeout behavior - const testRelay = asTestRelay(relay); - const originalWs = testRelay.ws; const mockSend = jest.fn(); - testRelay.ws = { + const restoreSocket = installRelaySocket(relay, { // Use literal readyState values to avoid global WebSocket mutations readyState: 1, // OPEN send: mockSend, - } as unknown as WebSocket; + close: jest.fn(), + }); try { // Use a short real timeout for cross-runner compatibility @@ -585,7 +564,7 @@ describe("Relay", () => { } } finally { // Restore the original socket - testRelay.ws = originalWs; + restoreSocket(); } }); @@ -628,20 +607,24 @@ describe("Relay", () => { }; // Mock the WebSocket readyState to simulate a non-OPEN state - const testRelay = asTestRelay(relay); - const origWs = testRelay.ws; - testRelay.ws = { readyState: 2 } as WebSocket; // CLOSING - - // Try to publish with a socket that's not in OPEN state - const result: PublishResponse = await relay.publish(event); + const restoreSocket = installRelaySocket( + relay, + { readyState: 2, send: jest.fn(), close: jest.fn() }, + true, + ); - // Should fail with not_connected reason - expect(result.success).toBe(false); - expect(result.reason).toBe("not_connected"); - expect(result.relay).toBe(ephemeralRelay.url); + try { + // Try to publish with a socket that's not in OPEN state + const result: PublishResponse = await relay.publish(event); - // Restore the original WebSocket - testRelay.ws = origWs; + // Should fail with not_connected reason + expect(result.success).toBe(false); + expect(result.reason).toBe("not_connected"); + expect(result.relay).toBe(ephemeralRelay.url); + } finally { + // Restore the original WebSocket + restoreSocket(); + } }); test("should support waitForAck option", async () => { @@ -658,29 +641,28 @@ describe("Relay", () => { // Use an isolated relay instance to avoid shared-connection races const isolatedRelay = new Relay("ws://example.com"); - const testRelay = asTestRelay(isolatedRelay); - const origWs = testRelay.ws; - const origConnected = testRelay.connected; const mockSend = jest.fn(); - testRelay.ws = { + const restoreSocket = installRelaySocket(isolatedRelay, { readyState: 1, // OPEN send: mockSend, - } as unknown as WebSocket; - testRelay.connected = true; + close: jest.fn(), + }); try { // Publish with waitForAck: false const options: PublishOptions = { waitForAck: false }; - const result: PublishResponse = await isolatedRelay.publish(event, options); + const result: PublishResponse = await isolatedRelay.publish( + event, + options, + ); // Should immediately return success without waiting for OK expect(result.success).toBe(true); - expect(result.relay).toBe(asTestRelay(isolatedRelay).url); + expect(result.relay).toBe("ws://example.com"); expect(mockSend).toHaveBeenCalled(); } finally { // Restore original relay connection state - testRelay.ws = origWs; - testRelay.connected = origConnected; + restoreSocket(); isolatedRelay.disconnect(); } }); @@ -709,27 +691,27 @@ describe("Relay", () => { nonRoutableRelay.disconnect(); // For timeout error - const timeoutRelay = asTestRelay(relay); - const originalWs = timeoutRelay.ws; - timeoutRelay.ws = { + const restoreTimeoutSocket = installRelaySocket(relay, { readyState: 1, // OPEN send: jest.fn(), - } as unknown as WebSocket; + close: jest.fn(), + }); const timeoutPromise = relay.publish(event, { timeout: 20 }); const timeoutResult = await timeoutPromise; expect(timeoutResult.success).toBe(false); expect(["timeout", "not_connected"]).toContain(timeoutResult.reason); - timeoutRelay.ws = originalWs; + restoreTimeoutSocket(); // For disconnected error - const testRelay = asTestRelay(relay); - const mockWs = { readyState: 3 }; // CLOSED - const origWs = testRelay.ws; - testRelay.ws = mockWs as unknown as WebSocket; + const restoreClosedSocket = installRelaySocket( + relay, + { readyState: 3, send: jest.fn(), close: jest.fn() }, + true, + ); const disconnectedResult = await relay.publish(event); expect(disconnectedResult.success).toBe(false); expect(disconnectedResult.reason).toBe("not_connected"); - testRelay.ws = origWs; + restoreClosedSocket(); }); }); @@ -961,9 +943,7 @@ describe("Relay", () => { rawMessage: testRawMessage, }; - // Access the private handleMessage method using type assertion - const internalRelay = asTestRelay(relay); - internalRelay.handleMessage([ + dispatchRelayMessage(relay, [ "OK", testEventId, testSuccess, @@ -1005,8 +985,7 @@ describe("Relay", () => { message: "Event was stored", rawMessage: testRawMessage, }; - const internalRelay = asTestRelay(relay); - internalRelay.handleMessage([ + dispatchRelayMessage(relay, [ "OK", testEventId, testSuccess, @@ -1038,20 +1017,14 @@ describe("Relay", () => { }); // Create a subscription so we can verify it gets removed - const subId = "test-subscription-id"; - const internalRelay = asTestRelay(relay); - internalRelay.subscriptions.set(subId, { - id: subId, - filters: [{ kinds: [1] }], - onEvent: () => {}, - }); + const subId = relay.subscribe([{ kinds: [1] }], () => {}); // Verify the subscription exists before - expect(internalRelay.subscriptions.has(subId)).toBe(true); + expect(relay.getSubscriptionIds().has(subId)).toBe(true); // Manually trigger the CLOSED message handling const testMessage = "Subscription closed due to inactivity"; - internalRelay.handleMessage(["CLOSED", subId, testMessage]); + dispatchRelayMessage(relay, ["CLOSED", subId, testMessage]); // Verify the handler was called with the right parameters expect(closedMessageReceived).toBe(true); @@ -1059,7 +1032,7 @@ describe("Relay", () => { expect(closedMessage).toBe(testMessage); // Verify the subscription was removed - expect(internalRelay.subscriptions.has(subId)).toBe(false); + expect(relay.getSubscriptionIds().has(subId)).toBe(false); }); }); @@ -1092,10 +1065,12 @@ describe("Relay", () => { const validation = new Promise((resolve) => { resolveValidation = resolve; }); - const internals = asTestRelay(relay); - internals.validateInboundEvent = jest.fn(() => validation); + const restoreValidator = replaceRelayInboundValidator( + relay, + jest.fn(() => validation), + ); - internals.handleMessage(["EVENT", subscriptionId, event]); + dispatchRelayMessage(relay, ["EVENT", subscriptionId, event]); relay.unsubscribe(subscriptionId); resolveValidation(event); await validation; @@ -1105,6 +1080,7 @@ describe("Relay", () => { expect( relay.getLatestAddressableEvent(30001, event.pubkey, "late"), ).toBeUndefined(); + restoreValidator(); }); test("should reject malformed inbound EVENT messages", async () => { @@ -1324,350 +1300,32 @@ describe("Relay", () => { expect(onEvent).toHaveBeenCalledTimes(1); expect(onEvent).toHaveBeenCalledWith(encryptedEvent); }); - }); - - describe("Addressable events (kinds 30000-39999)", () => { - let relay: Relay; - let testEvent: NostrEvent; - - beforeEach(async () => { - relay = new Relay(ephemeralRelay.url); - await relay.connect(); - - // Create a valid addressable event - testEvent = { - id: "b0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3cd", - pubkey: - "884704d5780d85163c138d2695ca263eb7552b0f2c5ebaf86c8d9c4e62a337e3", - created_at: 1671312795, - kind: 30001, - tags: [ - ["d", "test-identifier"], - ["t", "test"], - ], - content: "test content for addressable event", - sig: "553e66e4316cd6f8e2c363b7677d41da0f6b37775daa7728acdda6e477ed2fbf03be27c33f1eddaa2ee2711e9ca7c0ad7e97dd4d3eb4c1cf9ceaf98d9000af6a", - }; - }); - - afterEach(() => { - relay.disconnect(); - }); - - test("should process and store addressable events", () => { - // Access the private method directly for testing - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - - // Get the stored event using the public API - const storedEvent = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "test-identifier", - ); - - // Verify it was stored correctly - expect(storedEvent).toBeDefined(); - expect(storedEvent?.id).toBe(testEvent.id); - expect(storedEvent?.kind).toBe(30001); - }); - - test("should replace older events with the same pubkey, kind, and d-tag", () => { - // Create an older event with the same pubkey, kind, and d-tag - const olderEvent = { - ...testEvent, - id: "c0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3ee", - created_at: 1671312700, // Older timestamp - content: "older content", - }; - - // Create a newer event with the same pubkey, kind, and d-tag - const newerEvent = { - ...testEvent, - id: "d0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3ff", - created_at: 1671312900, // Newer timestamp - content: "newer content", - }; - - // Process events out of order (newer first, then older) - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(newerEvent); - testRelay.processAddressableEvent(olderEvent); - - // Get the stored event - const storedEvent = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "test-identifier", - ); - - // Verify the newer event was kept - expect(storedEvent).toBeDefined(); - expect(storedEvent?.id).toBe(newerEvent.id); - expect(storedEvent?.content).toBe("newer content"); - }); - - test("should handle events with different d-tags separately", () => { - // Create an event with a different d-tag - const differentDTagEvent = { - ...testEvent, - id: "e0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f400", - tags: [ - ["d", "different-identifier"], - ["t", "test"], - ], - content: "different d-tag content", - }; - - // Process both events - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - testRelay.processAddressableEvent(differentDTagEvent); - // Get the stored events - const event1 = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "test-identifier", - ); - const event2 = relay.getLatestAddressableEvent( - 30001, - testEvent.pubkey, - "different-identifier", - ); - - // Verify both events were stored - expect(event1).toBeDefined(); - expect(event1?.id).toBe(testEvent.id); - expect(event1?.content).toBe("test content for addressable event"); - - expect(event2).toBeDefined(); - expect(event2?.id).toBe(differentDTagEvent.id); - expect(event2?.content).toBe("different d-tag content"); - }); - - test("should retrieve addressable events by pubkey", () => { - // Create another event with the same pubkey but different kind and d-tag - const anotherEvent = { - ...testEvent, - id: "f0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f401", - kind: 30002, - tags: [ - ["d", "another-identifier"], - ["t", "test"], - ], - content: "another content", - }; - - // Process both events - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - testRelay.processAddressableEvent(anotherEvent); - - // Get events by pubkey - const events = relay.getAddressableEventsByPubkey(testEvent.pubkey); - - // Verify both events were retrieved - expect(events.length).toBe(2); - expect(events.some((e) => e.id === testEvent.id)).toBe(true); - expect(events.some((e) => e.id === anotherEvent.id)).toBe(true); - }); - - test("should retrieve addressable events by kind", () => { - // Create another event with the same kind but different pubkey - const differentPubkeyEvent = { - ...testEvent, - id: "g0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f402", - pubkey: - "994704d5780d85163c138d2695ca263eb7552b0f2c5ebaf86c8d9c4e62a337f4", - tags: [ - ["d", "another-pubkey-identifier"], - ["t", "test"], - ], - content: "different pubkey content", - }; - - // Process both events - const testRelay = asTestRelay(relay); - testRelay.processAddressableEvent(testEvent); - testRelay.processAddressableEvent(differentPubkeyEvent); - - // Get events by kind - const events = relay.getAddressableEventsByKind(30001); - - // Verify both events were retrieved - expect(events.length).toBe(2); - expect(events.some((e) => e.id === testEvent.id)).toBe(true); - expect(events.some((e) => e.id === differentPubkeyEvent.id)).toBe(true); - }); - - test("should properly handle addressable events through handleMessage", async () => { - // Create a subscription to receive events - const onEvent = jest.fn(); - const subscriptionId = relay.subscribe([{}], onEvent); - const testRelay = asTestRelay(relay); - - // Create a valid addressable event - const addressableEvent = await createSignedRelayEvent({ + test("stores signed addressable events received through the protocol path", async () => { + const event = await createSignedRelayEvent({ kind: 30001, - tags: [["d", "test-identifier"]], - content: "Addressable event through handleMessage", + tags: [["d", "profile"]], + content: "addressable", }); + const subscriptionId = relay.subscribe([{ kinds: [30001] }], () => {}); - // Process the event through handleMessage - testRelay.handleMessage(["EVENT", subscriptionId, addressableEvent]); - - await testUtils.sleep(20); + await handleInboundEvent(relay, subscriptionId, event); - const storedEvent = relay.getLatestAddressableEvent( - 30001, - addressableEvent.pubkey, - "test-identifier", - ); - expect(storedEvent).toBeDefined(); - expect(storedEvent?.id).toBe(addressableEvent.id); - expect(onEvent).toHaveBeenCalledWith(addressableEvent); + expect( + relay.getLatestAddressableEvent(30001, event.pubkey, "profile"), + ).toEqual(event); }); - }); - describe("Replaceable events (kinds 0, 3, 10000-19999)", () => { - let relay: Relay; - let testEvent: NostrEvent; - - beforeEach(async () => { - relay = new Relay(ephemeralRelay.url); - await relay.connect(); - - // Create a valid replaceable event (kind 0) - testEvent = { - id: "a0635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3cd", - pubkey: - "884704d5780d85163c138d2695ca263eb7552b0f2c5ebaf86c8d9c4e62a337e3", - created_at: 1671312795, + test("stores signed replaceable events received through the protocol path", async () => { + const event = await createSignedRelayEvent({ kind: 0, - tags: [ - ["name", "Test User"], - ["about", "Test description"], - ], - content: JSON.stringify({ - name: "Test User", - about: "Test description", - }), - sig: "553e66e4316cd6f8e2c363b7677d41da0f6b37775daa7728acdda6e477ed2fbf03be27c33f1eddaa2ee2711e9ca7c0ad7e97dd4d3eb4c1cf9ceaf98d9000af6a", - }; - }); - - afterEach(() => { - relay.disconnect(); - }); - - test("should process and store replaceable events", () => { - // Process the event - const testRelay = asTestRelay(relay); - testRelay.processReplaceableEvent(testEvent); - - // Create a newer version - const newerEvent = { - ...testEvent, - created_at: testEvent.created_at + 1000, - id: "b1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3cf", - content: JSON.stringify({ - name: "Test User Updated", - about: "Updated description", - }), - }; - - // Process the newer event - testRelay.processReplaceableEvent(newerEvent); - - // Get the latest event - const retrievedEvent = relay.getLatestReplaceableEvent( - testEvent.pubkey, - testEvent.kind, - ); - - // It should be the newer event - expect(retrievedEvent).toBeDefined(); - expect(retrievedEvent?.id).toBe(newerEvent.id); - expect(retrievedEvent?.content).toBe(newerEvent.content); - }); - - test("should keep older event if newer one has older timestamp", () => { - // Process the event - const testRelay = asTestRelay(relay); - testRelay.processReplaceableEvent(testEvent); - - // Create an event with newer id but older timestamp - const olderEvent = { - ...testEvent, - created_at: testEvent.created_at - 1000, // Older timestamp - id: "c1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3c0", // Newer id - content: JSON.stringify({ - name: "Test User Older", - about: "This should not replace the original", - }), - }; - - // Process the older event - testRelay.processReplaceableEvent(olderEvent); - - // Get the latest event - const retrievedEvent = relay.getLatestReplaceableEvent( - testEvent.pubkey, - testEvent.kind, - ); - - // It should still be the original event - expect(retrievedEvent).toBeDefined(); - expect(retrievedEvent?.id).toBe(testEvent.id); - expect(retrievedEvent?.content).toBe(testEvent.content); - }); - - test("should retrieve replaceable events by pubkey and kind", () => { - // Process events of different kinds - const testRelay = asTestRelay(relay); - testRelay.processReplaceableEvent(testEvent); // kind 0 - - // Create a kind 3 event - const contactEvent = { - ...testEvent, - kind: 3, - content: "", - tags: [["p", "some-pubkey", "some-relay"]], - id: "d1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3d1", - }; - - // Create a kind 10002 event - const relayEvent = { - ...testEvent, - kind: 10002, - content: "", - tags: [["r", "wss://relay.example.com", "read"]], - id: "e1635d6a9851d3aed0bc1c4f0c0924235bff013e037bf21eee532da2bba4f3e2", - }; - - // Process these events - testRelay.processReplaceableEvent(contactEvent); - testRelay.processReplaceableEvent(relayEvent); + content: "metadata", + }); + const subscriptionId = relay.subscribe([{ kinds: [0] }], () => {}); - // Retrieve each kind - const retrievedKind0 = relay.getLatestReplaceableEvent( - testEvent.pubkey, - 0, - ); - const retrievedKind3 = relay.getLatestReplaceableEvent( - testEvent.pubkey, - 3, - ); - const retrievedKind10002 = relay.getLatestReplaceableEvent( - testEvent.pubkey, - 10002, - ); + await handleInboundEvent(relay, subscriptionId, event); - // Verify correct events were retrieved - expect(retrievedKind0?.id).toBe(testEvent.id); - expect(retrievedKind3?.id).toBe(contactEvent.id); - expect(retrievedKind10002?.id).toBe(relayEvent.id); + expect(relay.getLatestReplaceableEvent(event.pubkey, 0)).toEqual(event); }); }); }); diff --git a/tests/nip01/relay/relayEventStore.test.ts b/tests/nip01/relay/relayEventStore.test.ts index 13579e47..1fe3d173 100644 --- a/tests/nip01/relay/relayEventStore.test.ts +++ b/tests/nip01/relay/relayEventStore.test.ts @@ -27,9 +27,9 @@ describe("RelayEventStore", () => { ["maxAddressableEvents", 1.5], ["maxAddressableEvents", Number.MAX_SAFE_INTEGER + 1], ] as const)("rejects invalid %s capacity %s", (option, value) => { - expect( - () => new RelayEventStore({ [option]: value }), - ).toThrow(`${option} must be a positive safe integer`); + expect(() => new RelayEventStore({ [option]: value })).toThrow( + `${option} must be a positive safe integer`, + ); }); test("drains buffered events in NIP-01 order", () => { @@ -73,9 +73,7 @@ describe("RelayEventStore", () => { store.addToBuffer("new", event("new", 1)); expect([...store.bufferIds()]).toEqual(["kept", "new"]); - expect(evictions).toEqual([ - "Evicted event buffer for subscription: old", - ]); + expect(evictions).toEqual(["Evicted event buffer for subscription: old"]); }); test("uses the lower event id when replaceable timestamps tie", () => { @@ -87,6 +85,14 @@ describe("RelayEventStore", () => { expect(store.getReplaceable("pubkey", 0)?.id).toBe("a"); }); + test("keeps the newer replaceable event when an older candidate arrives", () => { + const store = new RelayEventStore(); + store.storeReplaceable(event("newer", 20, { kind: 0 })); + store.storeReplaceable(event("older", 10, { kind: 0 })); + + expect(store.getReplaceable("pubkey", 0)?.id).toBe("newer"); + }); + test("bounds replaceable pubkeys and ignores misses for LRU accounting", () => { let now = 0; const store = new RelayEventStore({ @@ -126,13 +132,20 @@ describe("RelayEventStore", () => { event("other", 1, { kind: 30001, tags: [["d", "other"]] }), ); - expect(store.getAddressable(30001, "pubkey", "profile")?.id).toBe( - "new", - ); + expect(store.getAddressable(30001, "pubkey", "profile")?.id).toBe("new"); expect(store.getAddressableByPubkey("pubkey")).toHaveLength(2); expect(store.getAddressableByKind(30001)).toHaveLength(2); }); + test("keeps the newer addressable event when an older candidate arrives", () => { + const store = new RelayEventStore(); + const coordinate = { kind: 30001, tags: [["d", "profile"]] }; + store.storeAddressable(event("newer", 20, coordinate)); + store.storeAddressable(event("older", 10, coordinate)); + + expect(store.getAddressable(30001, "pubkey", "profile")?.id).toBe("newer"); + }); + test("bounds addressable storage and ignores misses for LRU accounting", () => { let now = 0; const store = new RelayEventStore({ diff --git a/tests/nip46/bunker-functionality.test.ts b/tests/nip46/bunker-functionality.test.ts index d4e4ca13..5a5cfe5c 100644 --- a/tests/nip46/bunker-functionality.test.ts +++ b/tests/nip46/bunker-functionality.test.ts @@ -1,13 +1,17 @@ import { SimpleNIP46Client, SimpleNIP46Bunker, + Nostr, NostrRemoteSignerBunker, generateKeypair, verifySignature, } from "../../src"; import { LogLevel } from "../../src/nip46"; +import { NIP46Method } from "../../src/nip46/types"; +import { NIP46Wire } from "../../src/nip46/internal/wire"; import { NostrRelay } from "../../src/testing"; import { NostrEvent } from "../../src/types/nostr"; +import { testUtils } from "../types"; describe("NIP-46 Bunker Functionality", () => { let relay: NostrRelay; @@ -372,6 +376,102 @@ describe("NIP-46 Bunker Functionality", () => { }); }); + describe("Replay protection", () => { + test("drops a duplicate request ID after the production bunker handler sees it", async () => { + const requester = await generateKeypair(); + const warnings: string[] = []; + const bunker = new NostrRemoteSignerBunker({ + userPubkey: userKeypair.publicKey, + signerPubkey: signerKeypair.publicKey, + relays: [relayUrl], + defaultPermissions: [NIP46Method.PING], + logger: { + error: () => {}, + warn: (message) => warnings.push(message), + info: () => {}, + debug: () => {}, + trace: () => {}, + }, + }); + bunker.setUserPrivateKey(userKeypair.privateKey); + bunker.setSignerPrivateKey(signerKeypair.privateKey); + + const sender = new Nostr([relayUrl]); + try { + await bunker.start(); + await sender.connectToRelays(); + const request = await NIP46Wire.createRequestEvent( + { + id: "replay-probe", + method: NIP46Method.PING, + params: [], + }, + requester, + signerKeypair.publicKey, + ); + const matchingResponses = () => + relay.cache.filter( + (event) => + event.kind === request.kind && + event.pubkey === signerKeypair.publicKey && + event.tags.some( + (tag) => tag[0] === "p" && tag[1] === requester.publicKey, + ), + ); + + await sender.publishEvent(request); + await testUtils.waitFor(() => matchingResponses().length === 1); + + await sender.publishEvent(request); + await testUtils.waitFor(() => + warnings.some((message) => message === "Replay attack detected"), + ); + + expect(matchingResponses()).toHaveLength(1); + + await bunker.stop(); + await bunker.start(); + await sender.publishEvent(request); + await testUtils.waitFor(() => matchingResponses().length === 2); + } finally { + sender.disconnectFromRelays(); + await bunker.stop().catch(() => {}); + } + }); + + test("stop clears the bunker-owned cleanup interval", async () => { + const setIntervalSpy = jest.spyOn(globalThis, "setInterval"); + const clearIntervalSpy = jest.spyOn(globalThis, "clearInterval"); + const bunker = new NostrRemoteSignerBunker({ + userPubkey: userKeypair.publicKey, + signerPubkey: signerKeypair.publicKey, + relays: [relayUrl], + defaultPermissions: [NIP46Method.PING], + rateLimitConfig: { cleanupIntervalMs: 300000 }, + }); + bunker.setUserPrivateKey(userKeypair.privateKey); + bunker.setSignerPrivateKey(signerKeypair.privateKey); + + try { + await bunker.start(); + const cleanupCallIndex = setIntervalSpy.mock.calls.findIndex( + ([, delay]) => delay === 60000, + ); + expect(cleanupCallIndex).toBeGreaterThanOrEqual(0); + const cleanupTimer = + setIntervalSpy.mock.results[cleanupCallIndex].value; + + await bunker.stop(); + + expect(clearIntervalSpy).toHaveBeenCalledWith(cleanupTimer); + } finally { + await bunker.stop().catch(() => {}); + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + }); + describe("Bunker Initialization Security", () => { test("should validate bunker options on creation", () => { // Test empty userPubkey - should throw specific error diff --git a/tests/nip46/core-functionality.test.ts b/tests/nip46/core-functionality.test.ts index 833fda82..e46e336f 100644 --- a/tests/nip46/core-functionality.test.ts +++ b/tests/nip46/core-functionality.test.ts @@ -7,25 +7,12 @@ import { verifySignature, } from "../../src"; import { LogLevel } from "../../src/nip46"; -import { NostrRelay } from "../../src/testing"; -import { NIP46ConnectionError } from "../../src/nip46/types"; +import { invokeNip46BunkerConnect, NostrRelay } from "../../src/testing"; +import { NIP46ConnectionError, NIP46Method } from "../../src/nip46/types"; import { validateSecureInitialization } from "../../src/nip46/utils/security"; jest.setTimeout(60000); // 60 second timeout for NIP-46 operations to handle full test suite load -// Type for accessing internal client properties in tests -interface ClientWithInternals { - clientKeys: { publicKey: string }; -} - -// Interface for accessing internal bunker methods in tests -interface BunkerWithInternals { - handleConnect( - request: { id: string; method: string; params: string[] }, - clientPubkey: string, - ): Promise<{ id: string; result: string; error: string; auth_url?: string }>; -} - describe("NIP-46 Core Functionality (Optimized)", () => { let relay: NostrRelay; let relayUrl: string; @@ -162,6 +149,13 @@ describe("NIP-46 Core Functionality (Optimized)", () => { }); test("NIP-44 and NIP-04 encryption support", async () => { + bunker.setDefaultPermissions([ + "nip44_encrypt", + "nip44_decrypt", + "nip04_encrypt", + "nip04_decrypt", + ]); + const connectionString = bunker.getConnectionString(); await client.connect(connectionString); @@ -182,12 +176,6 @@ describe("NIP-46 Core Functionality (Optimized)", () => { ); expect(nip44Decrypted).toBe(message); - // Test NIP-04 (legacy) - need to grant permissions - const clientPubkey = (client as unknown as ClientWithInternals).clientKeys - .publicKey; - bunker.addClientPermission(clientPubkey, "nip04_encrypt"); - bunker.addClientPermission(clientPubkey, "nip04_decrypt"); - const nip04Encrypted = await client.nip04Encrypt( recipientKeys.publicKey, message, @@ -333,18 +321,7 @@ describe("NIP-46 Core Functionality (Optimized)", () => { // Test disconnect cleanup await fullClient.disconnect(); - const clientWithInternals = fullClient as unknown as { - connected: boolean; - pendingRequests: Map< - string, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - } - >; - }; - expect(clientWithInternals.connected).toBe(false); - expect(clientWithInternals.pendingRequests.size).toBe(0); + await expect(fullClient.ping()).rejects.toThrow(NIP46ConnectionError); } finally { await fullClient.disconnect().catch(() => {}); } @@ -408,24 +385,9 @@ describe("NIP-46 Core Functionality (Optimized)", () => { ]; await Promise.all(requests); - const clientWithInternals = testClient as unknown as { - pendingRequests: Map< - string, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - } - >; - connected: boolean; - }; - // Disconnect immediately await testClient.disconnect(); - // Verify cleanup - expect(clientWithInternals.pendingRequests.size).toBe(0); - expect(clientWithInternals.connected).toBe(false); - // New requests should be rejected await expect(testClient.ping()).rejects.toThrow(NIP46ConnectionError); @@ -496,10 +458,9 @@ describe("NIP-46 Core Functionality (Optimized)", () => { expect(typeof resolved).toBe("boolean"); // Test auth challenge format - const testBunker = authBunker as unknown as BunkerWithInternals; const connectRequest = { id: "test-connect-id", - method: "connect", + method: NIP46Method.CONNECT, params: [ testSignerKeypair.publicKey, "", @@ -507,7 +468,8 @@ describe("NIP-46 Core Functionality (Optimized)", () => { ], }; - const response = await testBunker.handleConnect( + const response = await invokeNip46BunkerConnect( + authBunker, connectRequest, testUserKeypair.publicKey, ); diff --git a/tests/nip46/performance-security.test.ts b/tests/nip46/performance-security.test.ts index 158f8ab0..3c37205a 100644 --- a/tests/nip46/performance-security.test.ts +++ b/tests/nip46/performance-security.test.ts @@ -4,8 +4,7 @@ import { NostrRemoteSignerBunker, generateKeypair, } from "../../src"; -import { NostrRelay } from "../../src/testing"; -import { generateRequestId } from "../../src/nip46/utils/request-response"; +import { NostrRelay, replaceNip46RateLimiterDestroy } from "../../src/testing"; import { NIP46RateLimiter } from "../../src/nip46/utils/rate-limiter"; jest.setTimeout(60000); // 60 second timeout for performance tests to handle full test suite load @@ -509,95 +508,6 @@ describe("[slow] NIP-46 Performance & DoS Protection", () => { }, 12000); // Increased timeout to be more realistic for concurrent operations }); - describe("Replay Attack Protection", () => { - let protectedBunker: NostrRemoteSignerBunker; - let signerKeypair: { publicKey: string; privateKey: string }; - - beforeEach(async () => { - signerKeypair = await generateKeypair(); - protectedBunker = new NostrRemoteSignerBunker({ - userPubkey: userKeypair.publicKey, - signerPubkey: signerKeypair.publicKey, - relays: [relay.url], - defaultPermissions: ["get_public_key", "ping"], - debug: true, - }); - protectedBunker.setUserPrivateKey(userKeypair.privateKey); - protectedBunker.setSignerPrivateKey(signerKeypair.privateKey); - await protectedBunker.start(); - }); - - afterEach(async () => { - if (protectedBunker) { - await protectedBunker.stop(); - } - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - test("Replay attack window is reduced to 2 minutes", async () => { - // Access private method for testing - const bunkerWithInternals = protectedBunker as unknown as { - usedRequestIds: Map; - isReplayAttack: (id: string) => boolean; - cleanupOldRequestIds: () => void; - }; - - // Simulate a request ID that's 1 minute old (should be valid) - const requestId1 = generateRequestId(); - const now = Date.now(); - bunkerWithInternals.usedRequestIds.set(requestId1, now - 60000); // 1 minute ago - - // Should be considered a replay attack because already used - expect(bunkerWithInternals.isReplayAttack(requestId1)).toBe(true); // Already used - - // Simulate a request ID that's 3 minutes old - const requestId2 = generateRequestId(); - bunkerWithInternals.usedRequestIds.set(requestId2, now - 180000); // 3 minutes ago - - // Clean up old IDs - bunkerWithInternals.cleanupOldRequestIds(); - - // The 3-minute-old ID should be cleaned up - expect(bunkerWithInternals.usedRequestIds.has(requestId2)).toBe(false); - }); - - test("Cleanup runs more frequently", async () => { - // This test verifies that cleanup interval is set to 1 minute - const bunkerWithInternals = protectedBunker as unknown as { - cleanupInterval: NodeJS.Timeout | null; - }; - - // Check that cleanup interval exists - expect(bunkerWithInternals.cleanupInterval).toBeDefined(); - expect(bunkerWithInternals.cleanupInterval).not.toBeNull(); - }); - - test("Old request IDs are properly cleaned up", async () => { - const bunkerWithInternals = protectedBunker as unknown as { - usedRequestIds: Map; - cleanupOldRequestIds: () => void; - }; - - // Add some old request IDs - const oldId1 = generateRequestId(); - const oldId2 = generateRequestId(); - const recentId = generateRequestId(); - - const now = Date.now(); - bunkerWithInternals.usedRequestIds.set(oldId1, now - 300000); // 5 minutes ago - bunkerWithInternals.usedRequestIds.set(oldId2, now - 180000); // 3 minutes ago - bunkerWithInternals.usedRequestIds.set(recentId, now - 30000); // 30 seconds ago - - // Run cleanup - bunkerWithInternals.cleanupOldRequestIds(); - - // Old IDs should be removed, recent one should remain - expect(bunkerWithInternals.usedRequestIds.has(oldId1)).toBe(false); - expect(bunkerWithInternals.usedRequestIds.has(oldId2)).toBe(false); - expect(bunkerWithInternals.usedRequestIds.has(recentId)).toBe(true); - }); - }); - describe("Advanced Memory Management", () => { let managedBunker: NostrRemoteSignerBunker; let signerKeypair: { publicKey: string; privateKey: string }; @@ -623,80 +533,39 @@ describe("[slow] NIP-46 Performance & DoS Protection", () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); - test("Cleanup interval is properly cleared on stop", async () => { - const bunkerWithInternals = managedBunker as unknown as { - cleanupInterval: NodeJS.Timeout | null; - }; - - // Verify cleanup interval exists - expect(bunkerWithInternals.cleanupInterval).toBeDefined(); - - // Stop the bunker - await managedBunker.stop(); - - // Cleanup interval should be cleared - expect(bunkerWithInternals.cleanupInterval).toBeNull(); - }); - - test("rate limiter cleanup restarts with the bunker lifecycle", async () => { - const bunkerWithInternals = managedBunker as unknown as { - rateLimiter: { cleanupInterval: NodeJS.Timeout | null }; - }; + test("bunker operations remain healthy after a stop/start cycle", async () => { + const firstClient = new SimpleNIP46Client([relay.url], { timeout: 5000 }); + try { + await firstClient.connect(managedBunker.getConnectionString()); + await expect(firstClient.ping()).resolves.toBe(true); + } finally { + await firstClient.disconnect(); + } - expect(bunkerWithInternals.rateLimiter.cleanupInterval).not.toBeNull(); await managedBunker.stop(); - expect(bunkerWithInternals.rateLimiter.cleanupInterval).toBeNull(); - await managedBunker.start(); - expect(bunkerWithInternals.rateLimiter.cleanupInterval).not.toBeNull(); - }); - test("All resources are properly cleaned up on stop", async () => { - const bunkerWithInternals = managedBunker as unknown as { - connectedClients: Map< - string, - { permissions: Set; lastSeen: number } - >; - usedRequestIds: Map; - pendingAuthChallenges: Map< - string, - { id: string; clientPubkey: string; timestamp: number } - >; - }; - - // Add some test data - bunkerWithInternals.connectedClients.set("test-client", { - permissions: new Set(), - lastSeen: Date.now(), - }); - bunkerWithInternals.usedRequestIds.set("test-request", Date.now()); - bunkerWithInternals.pendingAuthChallenges.set("test-challenge", { - id: "test", - clientPubkey: "test", - timestamp: Date.now(), + const restartedClient = new SimpleNIP46Client([relay.url], { + timeout: 5000, }); - - // Stop the bunker - await managedBunker.stop(); - - // All data structures should be cleared - expect(bunkerWithInternals.connectedClients.size).toBe(0); - expect(bunkerWithInternals.usedRequestIds.size).toBe(0); - expect(bunkerWithInternals.pendingAuthChallenges.size).toBe(0); + try { + await restartedClient.connect(managedBunker.getConnectionString()); + await expect(restartedClient.ping()).resolves.toBe(true); + } finally { + await restartedClient.disconnect(); + } }); test("Stop method handles errors gracefully", async () => { - const bunkerWithInternals = managedBunker as unknown as { - rateLimiter: { destroy: () => void }; - }; - - // Mock an error in the rate limiter - bunkerWithInternals.rateLimiter.destroy = jest.fn(() => { - throw new Error("Mock rate limiter error"); - }); + const restoreDestroy = replaceNip46RateLimiterDestroy( + managedBunker, + jest.fn(() => { + throw new Error("Mock rate limiter error"); + }), + ); - // Stop should not throw despite the error await expect(managedBunker.stop()).resolves.toBeUndefined(); + restoreDestroy(); }); test("rate limiter should clean up old data", (done) => { diff --git a/tests/nip46/protocol-core.test.ts b/tests/nip46/protocol-core.test.ts index 5dc68c23..62734a52 100644 --- a/tests/nip46/protocol-core.test.ts +++ b/tests/nip46/protocol-core.test.ts @@ -3,6 +3,7 @@ import { NIP46RequestCorrelator } from "../../src/nip46/internal/request-correla import { NIP46ClientEngine } from "../../src/nip46/internal/client-engine"; import { NIP46Wire } from "../../src/nip46/internal/wire"; import { NIP46DiagnosticLogger } from "../../src/nip46/utils/diagnostics"; +import { installNip46ClientEngineLifecycleHooks } from "../../src/testing"; import { NIP46ConnectionError, NIP46Method, @@ -149,13 +150,7 @@ describe("NIP-46 protocol core", () => { test("serializes client connect and disconnect transitions", async () => { const events: string[] = []; const engine = createTestClientEngine(); - const engineInternals = engine as unknown as { - clientKeypair: typeof sender; - prepareConnection: () => Promise; - setupSubscription: () => Promise; - cleanup: () => Promise; - }; - engineInternals.clientKeypair = sender; + Object.assign(engine.clientKeys, sender); let releaseConnection!: () => void; const connectionGate = new Promise((resolve) => { @@ -165,14 +160,16 @@ describe("NIP-46 protocol core", () => { const entered = new Promise((resolve) => { connectionEntered = resolve; }); - engineInternals.prepareConnection = jest.fn(async () => { - events.push("connect-start"); - connectionEntered(); - await connectionGate; - }); - engineInternals.setupSubscription = jest.fn(async () => undefined); - engineInternals.cleanup = jest.fn(async () => { - events.push("cleanup"); + const restoreLifecycle = installNip46ClientEngineLifecycleHooks(engine, { + prepareConnection: jest.fn(async () => { + events.push("connect-start"); + connectionEntered(); + await connectionGate; + }), + setupSubscription: jest.fn(async () => undefined), + cleanup: jest.fn(async () => { + events.push("cleanup"); + }), }); jest.spyOn(engine, "request").mockImplementation(async (method) => { events.push(method); @@ -194,21 +191,13 @@ describe("NIP-46 protocol core", () => { NIP46Method.DISCONNECT, "cleanup", ]); + restoreLifecycle(); }); test("finishes failed-connect cleanup before a queued disconnect", async () => { const events: string[] = []; const engine = createTestClientEngine(); - const engineInternals = engine as unknown as { - clientKeypair: typeof sender; - prepareConnection: () => Promise; - cleanup: () => Promise; - }; - engineInternals.clientKeypair = sender; - engineInternals.prepareConnection = jest.fn(async () => { - events.push("connect-failed"); - throw new Error("expected connect failure"); - }); + Object.assign(engine.clientKeys, sender); let releaseCleanup!: () => void; const cleanupGate = new Promise((resolve) => { @@ -219,14 +208,20 @@ describe("NIP-46 protocol core", () => { firstCleanupEntered = resolve; }); let cleanupCalls = 0; - engineInternals.cleanup = jest.fn(async () => { - cleanupCalls += 1; - events.push(`cleanup-${cleanupCalls}-start`); - if (cleanupCalls === 1) { - firstCleanupEntered(); - await cleanupGate; - } - events.push(`cleanup-${cleanupCalls}-end`); + const restoreLifecycle = installNip46ClientEngineLifecycleHooks(engine, { + prepareConnection: jest.fn(async () => { + events.push("connect-failed"); + throw new Error("expected connect failure"); + }), + cleanup: jest.fn(async () => { + cleanupCalls += 1; + events.push(`cleanup-${cleanupCalls}-start`); + if (cleanupCalls === 1) { + firstCleanupEntered(); + await cleanupGate; + } + events.push(`cleanup-${cleanupCalls}-end`); + }), }); const connecting = captureRejection( @@ -252,5 +247,6 @@ describe("NIP-46 protocol core", () => { "cleanup-2-start", "cleanup-2-end", ]); + restoreLifecycle(); }); }); diff --git a/tests/nip46/replay-guard.test.ts b/tests/nip46/replay-guard.test.ts new file mode 100644 index 00000000..bb818bca --- /dev/null +++ b/tests/nip46/replay-guard.test.ts @@ -0,0 +1,37 @@ +import { NIP46ReplayGuard } from "../../src/nip46/internal/replay-guard"; + +describe("NIP-46 replay guard", () => { + test("rejects duplicate IDs inside the two-minute window", () => { + let now = 1_000; + const guard = new NIP46ReplayGuard({ now: () => now }); + + expect(guard.isReplay("request")).toBe(false); + now += 60_000; + expect(guard.isReplay("request")).toBe(true); + expect(guard.size).toBe(1); + }); + + test("cleanup releases IDs older than the replay window", () => { + let now = 1_000; + const guard = new NIP46ReplayGuard({ now: () => now }); + guard.isReplay("old"); + now += 30_000; + guard.isReplay("recent"); + now += 91_000; + + expect(guard.cleanup()).toBe(1); + expect(guard.isReplay("old")).toBe(false); + expect(guard.isReplay("recent")).toBe(true); + }); + + test("clear resets every retained ID", () => { + const guard = new NIP46ReplayGuard(); + guard.isReplay("first"); + guard.isReplay("second"); + + guard.clear(); + + expect(guard.size).toBe(0); + expect(guard.isReplay("first")).toBe(false); + }); +}); diff --git a/tests/nip47/client-encryption-tracking-simple.test.ts b/tests/nip47/client-encryption-tracking-simple.test.ts index 1b0e3b08..2df3eddd 100644 --- a/tests/nip47/client-encryption-tracking-simple.test.ts +++ b/tests/nip47/client-encryption-tracking-simple.test.ts @@ -1,8 +1,8 @@ /** - * Simplified test for NIP-47 client encryption tracking + * NIP-47 client encryption negotiation and response behavior * - * This test verifies that the client correctly tracks and uses the same - * encryption scheme for decrypting responses as was used for the request. + * These tests verify the negotiated request scheme through the wire event and + * prove the matching response path completes end to end. */ import { generateKeypair } from "../../src/utils/crypto"; @@ -16,26 +16,10 @@ import { NIP47EncryptionScheme, NIP47ConnectionOptions, NIP47Logger, - NIP47Request, } from "../../src/nip47/types"; -import { NostrEvent } from "../../src/types/nostr"; -import { NostrRelay } from "../../src/testing"; +import { dispatchNip47ClientResponse, NostrRelay } from "../../src/testing"; -// Type-safe interface for accessing private client methods in tests -interface ClientWithPrivateMethods { - sendRequest: (request: NIP47Request, expiration?: number) => Promise; - handleResponse: (event: NostrEvent) => Promise; - chooseEncryptionScheme: () => NIP47EncryptionScheme; - pendingRequests: Map< - string, - { - encryptionScheme: NIP47EncryptionScheme; - resolve: (response: unknown) => void; - } - >; -} - -describe("NIP-47: Client encryption tracking (simplified)", () => { +describe("NIP-47: Client encryption negotiation", () => { let relay: NostrRelay; beforeAll(async () => { @@ -49,7 +33,7 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { } }); - test("should track and use the correct encryption scheme for responses", async () => { + test("uses NIP-44 for the request and completes the response roundtrip", async () => { const serviceKeys = await generateKeypair(); const clientKeys = await generateKeypair(); @@ -98,44 +82,6 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { const client = new NostrWalletConnectClient(connectionOptions); - // Track what encryption was used - let requestEncryption: NIP47EncryptionScheme | undefined; - let responseDecryption: NIP47EncryptionScheme | undefined; - - // Create a type-safe wrapper for accessing private methods - const clientWithPrivates = client as unknown as ClientWithPrivateMethods; - - // Spy on the client's sendRequest to see what encryption it uses - const originalSendRequest = clientWithPrivates.sendRequest.bind(client); - clientWithPrivates.sendRequest = jest.fn( - async (request: NIP47Request, expiration?: number) => { - // Check the encryption scheme being used - const chooseEncryption = - clientWithPrivates.chooseEncryptionScheme.bind(client); - requestEncryption = chooseEncryption(); - return originalSendRequest(request, expiration); - }, - ); - - // Spy on handleResponse to see what decryption is used - const originalHandleResponse = - clientWithPrivates.handleResponse.bind(client); - clientWithPrivates.handleResponse = jest.fn(async (event: NostrEvent) => { - // The handleResponse now uses tracked encryption - const pendingRequests = clientWithPrivates.pendingRequests; - - // Get request ID from e-tag - const eTag = event.tags.find((tag: string[]) => tag[0] === "e"); - if (eTag && eTag[1]) { - const pending = pendingRequests.get(eTag[1]); - if (pending) { - responseDecryption = pending.encryptionScheme; - } - } - - return originalHandleResponse(event); - }); - await client.init(); // Wait for capabilities discovery @@ -145,16 +91,20 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { const balance = await client.getBalance(); expect(balance).toBe(50000000); - // Verify that request and response used the same encryption - expect(requestEncryption).toBe(NIP47EncryptionScheme.NIP44_V2); - expect(responseDecryption).toBe(NIP47EncryptionScheme.NIP44_V2); - expect(requestEncryption).toBe(responseDecryption); + const request = relay.cache + .filter((event) => event.kind === 23194) + .reverse() + .find((event) => event.pubkey === client.getPublicKey()); + expect(request?.tags).toContainEqual([ + "encryption", + NIP47EncryptionScheme.NIP44_V2, + ]); await client.disconnect(); await service.disconnect(); }, 10000); - test("should fallback to NIP-04 when NIP-44 is not supported", async () => { + test("falls back to NIP-04 and completes the response roundtrip", async () => { const serviceKeys = await generateKeypair(); const clientKeys = await generateKeypair(); @@ -193,30 +143,17 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { preferredEncryption: NIP47EncryptionScheme.NIP44_V2, }); - // Track encryption - let requestEncryption: NIP47EncryptionScheme | undefined; - - // Use the same type-safe interface - const clientWithPrivates2 = client as unknown as ClientWithPrivateMethods; - - const originalSendRequest = clientWithPrivates2.sendRequest.bind(client); - clientWithPrivates2.sendRequest = jest.fn( - async (request: NIP47Request, expiration?: number) => { - const chooseEncryption = - clientWithPrivates2.chooseEncryptionScheme.bind(client); - requestEncryption = chooseEncryption(); - return originalSendRequest(request, expiration); - }, - ); - await client.init(); await new Promise((resolve) => setTimeout(resolve, 200)); const info = await client.getInfo(); expect(info).toBeDefined(); - // Should have fallen back to NIP-04 - expect(requestEncryption).toBe(NIP47EncryptionScheme.NIP04); + const request = relay.cache + .filter((event) => event.kind === 23194) + .reverse() + .find((event) => event.pubkey === client.getPublicKey()); + expect(request?.tags.some((tag) => tag[0] === "encryption")).toBe(false); await client.disconnect(); await service.disconnect(); @@ -239,21 +176,20 @@ describe("NIP-47: Client encryption tracking (simplified)", () => { relays: [relay.url], logger, }); - const clientWithPrivates = client as unknown as ClientWithPrivateMethods; - clientWithPrivates.pendingRequests.set("request-id", { - encryptionScheme: NIP47EncryptionScheme.NIP04, - resolve: jest.fn(), - }); - - await clientWithPrivates.handleResponse({ - id: "response-id", - pubkey: serviceKeys.publicKey, - created_at: Math.floor(Date.now() / 1000), - kind: 23195, - tags: [["e", "request-id"]], - content: "invalid-encrypted-content", - sig: "", - }); + await dispatchNip47ClientResponse( + client, + "request-id", + NIP47EncryptionScheme.NIP04, + { + id: "response-id", + pubkey: serviceKeys.publicKey, + created_at: Math.floor(Date.now() / 1000), + kind: 23195, + tags: [["e", "request-id"]], + content: "invalid-encrypted-content", + sig: "", + }, + ); expect(errors).toEqual([ expect.stringContaining("Error handling nip04 response event"), diff --git a/tests/nip47/nip47.test.ts b/tests/nip47/nip47.test.ts index 96024d7d..49b5766d 100644 --- a/tests/nip47/nip47.test.ts +++ b/tests/nip47/nip47.test.ts @@ -1,13 +1,11 @@ +import { describe, it, expect, beforeAll, afterAll, jest } from "@jest/globals"; import { - describe, - it, - expect, - beforeAll, - afterAll, - jest, - beforeEach, -} from "@jest/globals"; -import { NostrRelay } from "../../src/testing"; + dispatchNip47ServiceRequest, + NostrRelay, + replaceNip47CapabilityDiscoveryWait, + replaceNip47RequestSender, +} from "../../src/testing"; +import type { NIP47RequestSender } from "../../src/testing"; import { generateKeypair } from "../../src/utils/crypto"; import { NostrWalletConnectClient, @@ -21,19 +19,25 @@ import { generateNWCURL, parseNWCURL, NIP47ErrorCode, + NIP47EventKind, GetInfoResponseResult, PaymentResponseResult, MakeInvoiceResponseResult, SignMessageResponseResult, NIP47Notification, NIP47Logger, + NIP47Response, } from "../../src/nip47"; import { NIP47ClientError } from "../../src/nip47/client"; import { createEvent, createSignedEvent } from "../../src/nip01/event"; -import { encrypt as encryptNIP04 } from "../../src/nip04"; +import { + decrypt as decryptNIP04, + encrypt as encryptNIP04, +} from "../../src/nip04"; import { getUnixTime } from "../../src/utils/time"; import type { NostrEvent } from "../../src/types/nostr"; import { testUtils } from "../types"; +import { validateNIP47Response } from "../../src/nip47/protocol"; // Mock Implementation class MockWalletImplementation implements WalletImplementation { @@ -89,7 +93,10 @@ class MockWalletImplementation implements WalletImplementation { }; } - async lookupInvoice(): Promise { + async lookupInvoice(_params: { + payment_hash?: string; + invoice?: string; + }): Promise { return { type: TransactionType.INCOMING, invoice: "lnbc10n1ptest", @@ -145,60 +152,6 @@ class MockWalletImplementation implements WalletImplementation { } } -// Type for the mock wallet access -type ServiceWithMockAccess = { - walletImpl: { - lookupInvoice: (params: { - payment_hash?: string; - invoice?: string; - }) => Promise; - }; -}; - -// Interface for accessing private members in tests -interface ServiceWithPrivates { - requestEncryption: Map< - string, - import("../../src/nip47/types").NIP47EncryptionScheme - >; - handleEvent: ( - event: import("../../src/types/nostr").NostrEvent, - ) => Promise; - sendErrorResponse: ( - clientPubkey: string, - serviceContextPubkey: string, - errorCode: NIP47ErrorCode, - errorMessage: string, - requestId: string, - method: NIP47Method, - ) => Promise; -} - -interface ClientInitializationState { - initialized: boolean; - waitForCapabilityDiscovery: () => Promise; - subIds: string[]; - client: { - connectToRelays: () => Promise; - subscribe: ( - filters: unknown[], - callback: (event: NostrEvent, relay: string) => void, - ) => string[]; - unsubscribe: (ids: string[]) => void; - disconnectFromRelays: () => void; - }; - handleNotification: (event: NostrEvent) => Promise; - sendRequest: ( - request: { method: NIP47Method }, - expiration?: number, - allowDuringInitialization?: boolean, - ) => Promise<{ - result_type: NIP47Method; - result: unknown; - error: null; - }>; -} - describe("NIP-47 client initialization fallback", () => { const createFallbackClient = () => { const fallbackClient = new NostrWalletConnectClient({ @@ -206,25 +159,44 @@ describe("NIP-47 client initialization fallback", () => { secret: "01".repeat(32), relays: ["wss://relay.example.com"], }); - const internals = fallbackClient as unknown as ClientInitializationState; - internals.waitForCapabilityDiscovery = async () => {}; const subscriptions: string[][] = []; const unsubscriptions: string[][] = []; const callbacks: Array<(event: NostrEvent, relay: string) => void> = []; - internals.client = { - connectToRelays: async () => {}, - subscribe: (_filters, callback) => { + let capabilityWait = async (): Promise => {}; + let requestSender: NIP47RequestSender = async () => { + throw new Error("request sender not configured"); + }; + replaceNip47CapabilityDiscoveryWait(fallbackClient, () => capabilityWait()); + replaceNip47RequestSender(fallbackClient, (...args) => + requestSender(...args), + ); + + const transport = fallbackClient.getNostrClient(); + const connectToRelays = jest + .spyOn(transport, "connectToRelays") + .mockResolvedValue(); + jest + .spyOn(transport, "subscribe") + .mockImplementation((_filters, callback) => { const ids = [`sub-${subscriptions.length + 1}`]; subscriptions.push(ids); callbacks.push(callback); return ids; - }, - unsubscribe: (ids) => unsubscriptions.push([...ids]), - disconnectFromRelays: () => {}, - }; + }); + jest.spyOn(transport, "unsubscribe").mockImplementation((ids) => { + unsubscriptions.push([...ids]); + }); + jest.spyOn(transport, "disconnectFromRelays").mockImplementation(() => {}); + return { fallbackClient, - internals, + connectToRelays, + useCapabilityWait: (wait: () => Promise) => { + capabilityWait = wait; + }, + useRequestSender: (send: NIP47RequestSender) => { + requestSender = send; + }, subscriptions, unsubscriptions, callbacks, @@ -232,11 +204,12 @@ describe("NIP-47 client initialization fallback", () => { }; it("allows explicit getInfo capability discovery during initialization", async () => { - const { fallbackClient, internals, subscriptions } = createFallbackClient(); - internals.sendRequest = async (request, _expiration, allowed) => { + const { fallbackClient, useRequestSender, subscriptions } = + createFallbackClient(); + useRequestSender(async (request, _expiration, allowed) => { expect(request.method).toBe(NIP47Method.GET_INFO); expect(allowed).toBe(true); - expect(internals.initialized).toBe(false); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); return { result_type: NIP47Method.GET_INFO, result: { @@ -245,20 +218,19 @@ describe("NIP-47 client initialization fallback", () => { } as GetInfoResponseResult, error: null, }; - }; + }); await fallbackClient.init(); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); - expect(internals.initialized).toBe(true); expect(subscriptions).toHaveLength(1); }); it("cleans up and atomically retries after malformed capability discovery", async () => { - const { fallbackClient, internals, subscriptions, unsubscriptions } = + const { fallbackClient, useRequestSender, subscriptions, unsubscriptions } = createFallbackClient(); let attempt = 0; - internals.sendRequest = async () => { + useRequestSender(async () => { attempt += 1; return { result_type: NIP47Method.GET_INFO, @@ -274,36 +246,33 @@ describe("NIP-47 client initialization fallback", () => { } as GetInfoResponseResult), error: null, }; - }; + }); await expect(fallbackClient.init()).rejects.toThrow( "Failed to initialize wallet connection", ); - expect(internals.initialized).toBe(false); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); - expect(internals.subIds).toEqual([]); - expect(unsubscriptions).toEqual([["sub-1"]]); + expect(unsubscriptions.length).toBeGreaterThanOrEqual(1); + expect(unsubscriptions.every((ids) => ids[0] === "sub-1")).toBe(true); await fallbackClient.init(); - expect(internals.initialized).toBe(true); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); expect(subscriptions).toEqual([["sub-1"], ["sub-2"]]); - expect(internals.subIds).toEqual(["sub-2"]); }); it("coalesces concurrent and repeated initialization into one subscription", async () => { - const { fallbackClient, internals, subscriptions, callbacks } = + const { fallbackClient, useRequestSender, subscriptions, callbacks } = createFallbackClient(); - internals.sendRequest = async () => ({ + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, result: { methods: [NIP47Method.GET_INFO], notifications: [NIP47NotificationType.PAYMENT_RECEIVED], } as GetInfoResponseResult, error: null, - }); + })); const first = fallbackClient.init(); const concurrent = fallbackClient.init(); @@ -314,53 +283,55 @@ describe("NIP-47 client initialization fallback", () => { expect(subscriptions).toHaveLength(1); expect(callbacks).toHaveLength(1); - let notificationDeliveries = 0; - internals.handleNotification = async () => { - notificationDeliveries += 1; - }; callbacks[0]( { - id: "notification-id", + id: "info-id", pubkey: "02".repeat(32), created_at: Math.floor(Date.now() / 1000), - kind: 23196, + kind: NIP47EventKind.INFO, tags: [], - content: "", + content: NIP47Method.PAY_INVOICE, sig: "", }, "wss://relay.example.com", ); await Promise.resolve(); - expect(notificationDeliveries).toBe(1); + expect(fallbackClient.supportsMethod(NIP47Method.PAY_INVOICE)).toBe(true); }); it.each([0, false, ""])( "rejects a falsy malformed capability result (%p)", async (result) => { - const { fallbackClient, internals, unsubscriptions } = + const { fallbackClient, useRequestSender, unsubscriptions } = createFallbackClient(); - internals.sendRequest = async () => ({ + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, - result, + result: result as unknown as GetInfoResponseResult, error: null, - }); + })); await expect(fallbackClient.init()).rejects.toThrow( "Invalid get_info result", ); - expect(internals.initialized).toBe(false); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); expect(unsubscriptions).toEqual([["sub-1"]]); }, ); it("invalidates an in-flight initialization when disconnected", async () => { - const { fallbackClient, internals, subscriptions } = createFallbackClient(); + const { + fallbackClient, + useCapabilityWait, + useRequestSender, + subscriptions, + unsubscriptions, + } = createFallbackClient(); let releaseCapabilityWait!: () => void; const capabilityWaitGate = new Promise((resolve) => { releaseCapabilityWait = resolve; }); - internals.waitForCapabilityDiscovery = () => capabilityWaitGate; + useCapabilityWait(() => capabilityWaitGate); const initialization = fallbackClient.init(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -375,42 +346,44 @@ describe("NIP-47 client initialization fallback", () => { "Client initialization cancelled by disconnect", ); - expect(internals.initialized).toBe(false); - expect(internals.subIds).toEqual([]); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(false); + expect(unsubscriptions.length).toBeGreaterThanOrEqual(1); + expect(unsubscriptions.every((ids) => ids[0] === "sub-1")).toBe(true); - internals.waitForCapabilityDiscovery = async () => {}; - internals.sendRequest = async () => ({ + useCapabilityWait(async () => {}); + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, result: { methods: [NIP47Method.GET_INFO], } as GetInfoResponseResult, error: null, - }); + })); await fallbackClient.init(); - expect(internals.initialized).toBe(true); + expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); expect(subscriptions).toEqual([["sub-1"], ["sub-2"]]); }); it("prevents a stale attempt from resetting a newer successful initialization", async () => { - const { fallbackClient, internals, subscriptions } = createFallbackClient(); + const { fallbackClient, connectToRelays, useRequestSender, subscriptions } = + createFallbackClient(); let releaseFirstConnection!: () => void; const firstConnectionGate = new Promise((resolve) => { releaseFirstConnection = resolve; }); let connectionAttempt = 0; - internals.client.connectToRelays = async () => { + connectToRelays.mockImplementation(async () => { connectionAttempt += 1; if (connectionAttempt === 1) { await firstConnectionGate; } - }; - internals.sendRequest = async () => ({ + }); + useRequestSender(async () => ({ result_type: NIP47Method.GET_INFO, result: { methods: [NIP47Method.GET_INFO], } as GetInfoResponseResult, error: null, - }); + })); const staleAttempt = fallbackClient.init(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -418,7 +391,6 @@ describe("NIP-47 client initialization fallback", () => { const currentAttempt = fallbackClient.init(); await currentAttempt; - expect(internals.initialized).toBe(true); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); expect(subscriptions).toEqual([["sub-1"]]); @@ -432,9 +404,8 @@ describe("NIP-47 client initialization fallback", () => { "Client initialization cancelled by disconnect", ); - expect(internals.initialized).toBe(true); expect(fallbackClient.supportsMethod(NIP47Method.GET_INFO)).toBe(true); - expect(internals.subIds).toEqual(["sub-1"]); + expect(subscriptions).toEqual([["sub-1"]]); }); }); @@ -445,6 +416,7 @@ describe("NIP-47: Nostr Wallet Connect", () => { let connectionOptions: NIP47ConnectionOptions; let service: NostrWalletService; let client: NostrWalletConnectClient; + let wallet: MockWalletImplementation; beforeAll(async () => { // Start ephemeral relay @@ -463,6 +435,7 @@ describe("NIP-47: Nostr Wallet Connect", () => { }; // Create and initialize service + wallet = new MockWalletImplementation(); service = new NostrWalletService( { relays: [relay.url], @@ -471,7 +444,7 @@ describe("NIP-47: Nostr Wallet Connect", () => { methods: Object.values(NIP47Method), notificationTypes: Object.values(NIP47NotificationType), }, - new MockWalletImplementation(), + wallet, ); await service.init(); @@ -845,21 +818,16 @@ describe("NIP-47: Nostr Wallet Connect", () => { it("should handle not found errors", async () => { jest.setTimeout(10000); - // Mock the wallet implementation to throw a NOT_FOUND error - const serviceMock = service as unknown as ServiceWithMockAccess; - const originalLookup = serviceMock.walletImpl.lookupInvoice; - // Create a more specific mocking that replicates a real NOT_FOUND error const testPaymentHash = "nonexistent_hash_123"; - serviceMock.walletImpl.lookupInvoice = (params: { - payment_hash?: string; - invoice?: string; - }) => { - throw { - code: "NOT_FOUND", - message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, - }; - }; + const lookupSpy = jest + .spyOn(wallet, "lookupInvoice") + .mockImplementation(async (params) => { + throw { + code: "NOT_FOUND", + message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, + }; + }); try { await client.lookupInvoice({ payment_hash: testPaymentHash }); @@ -881,29 +849,23 @@ describe("NIP-47: Nostr Wallet Connect", () => { expect(nip47Error.recoveryHint).toBeDefined(); expect(nip47Error.recoveryHint).toContain("For lookupInvoice"); } finally { - // Restore original implementation - serviceMock.walletImpl.lookupInvoice = originalLookup; + lookupSpy.mockRestore(); } }); it("should handle not found errors with invoice parameter", async () => { jest.setTimeout(10000); - // Mock the wallet implementation to throw a NOT_FOUND error - const serviceMock = service as unknown as ServiceWithMockAccess; - const originalLookup = serviceMock.walletImpl.lookupInvoice; - // Create a test with invoice parameter instead of payment_hash const testInvoice = "lnbc10n1pdummy"; - serviceMock.walletImpl.lookupInvoice = (params: { - payment_hash?: string; - invoice?: string; - }) => { - throw { - code: "NOT_FOUND", - message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, - }; - }; + const lookupSpy = jest + .spyOn(wallet, "lookupInvoice") + .mockImplementation(async (params) => { + throw { + code: "NOT_FOUND", + message: `Invoice not found: Could not find ${params.payment_hash ? "payment_hash" : "invoice"}: ${params.payment_hash || params.invoice} in the wallet's database`, + }; + }); try { await client.lookupInvoice({ invoice: testInvoice }); @@ -922,20 +884,33 @@ describe("NIP-47: Nostr Wallet Connect", () => { /Invoice not found: Could not find invoice: .+ in the wallet's database/, ); } finally { - // Restore original implementation - serviceMock.walletImpl.lookupInvoice = originalLookup; + lookupSpy.mockRestore(); } }); - describe("Request encryption cleanup", () => { - let unauthorizedKeys: { publicKey: string; privateKey: string }; + describe("Request failure behavior", () => { + const findResponse = (requestId: string): NostrEvent | undefined => + relay.cache.find( + (candidate) => + candidate.kind === 23195 && + candidate.tags.some( + (tag) => tag[0] === "e" && tag[1] === requestId, + ), + ); - beforeEach(async () => { - // Generate unauthorized keys for testing - unauthorizedKeys = await generateKeypair(); - }); + const decodeResponse = ( + event: NostrEvent, + recipientPrivateKey: string, + ): NIP47Response => + JSON.parse( + decryptNIP04( + recipientPrivateKey, + serviceKeypair.publicKey, + event.content, + ), + ) as NIP47Response; - it("should clean up requestEncryption map on expired request", async () => { + it("publishes a correlated expiration error for an expired request", async () => { // Create an expired request const request = { method: NIP47Method.GET_INFO, @@ -960,59 +935,22 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - // Access private map for testing - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - - // Handle the event - await serviceWithPrivates.handleEvent(event); + await dispatchNip47ServiceRequest(service, event); - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify the map was not increased (no leak) - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); - }); - - it("should clean up requestEncryption map on unauthorized client", async () => { - const request = { - method: NIP47Method.GET_INFO, - params: {}, - }; - - const eventTemplate = { - kind: 23194, // NIP47EventKind.REQUEST - content: encryptNIP04( - unauthorizedKeys.privateKey, - serviceKeypair.publicKey, - JSON.stringify(request), - ), - tags: [["p", serviceKeypair.publicKey]], - }; - - const event = await createSignedEvent( - createEvent(eventTemplate, unauthorizedKeys.publicKey), - unauthorizedKeys.privateKey, + const responseEvent = findResponse(event.id); + expect(responseEvent).toBeDefined(); + const response = decodeResponse( + responseEvent!, + clientKeypair.privateKey, ); - - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify the map was not increased (no leak) - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); + expect(response.result_type).toBe(NIP47Method.UNKNOWN); + expect(response.error).toMatchObject({ + code: NIP47ErrorCode.REQUEST_EXPIRED, + message: "Request has expired", + }); }); - it("should clean up requestEncryption map on decryption failure", async () => { + it("logs a decryption failure without publishing a response", async () => { const eventTemplate = { kind: 23194, // NIP47EventKind.REQUEST content: "invalid_encrypted_content", // This will fail decryption @@ -1024,20 +962,12 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - // Spy on console.error to verify the error is logged const consoleErrorSpy = jest .spyOn(console, "error") .mockImplementation(() => {}); - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); + await dispatchNip47ServiceRequest(service, event); // Verify the error was logged expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -1045,13 +975,11 @@ describe("NIP-47: Nostr Wallet Connect", () => { expect.any(Error), ); - // Verify the map was cleaned up - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); + expect(findResponse(event.id)).toBeUndefined(); consoleErrorSpy.mockRestore(); }); - it("should clean up requestEncryption map on successful request", async () => { + it("publishes a correlated response for a successful request", async () => { const request = { method: NIP47Method.GET_INFO, params: {}, @@ -1072,22 +1000,19 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; + await dispatchNip47ServiceRequest(service, event); - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Verify the map was cleaned up after successful processing - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); + const responseEvent = findResponse(event.id); + expect(responseEvent).toBeDefined(); + const response = decodeResponse( + responseEvent!, + clientKeypair.privateKey, + ); + expect(response.result_type).toBe(NIP47Method.GET_INFO); + expect(response.error).toBeNull(); }); - it("should clean up requestEncryption map on JSON parse error", async () => { + it("publishes an invalid-request response after a JSON parse error", async () => { const eventTemplate = { kind: 23194, // NIP47EventKind.REQUEST content: encryptNIP04( @@ -1103,24 +1028,12 @@ describe("NIP-47: Nostr Wallet Connect", () => { clientKeypair.privateKey, ); - const serviceWithPrivates = service as unknown as ServiceWithPrivates; - const requestEncryptionMap = serviceWithPrivates.requestEncryption; - const initialSize = requestEncryptionMap.size; - const sendErrorResponseSpy = jest.spyOn( - serviceWithPrivates, - "sendErrorResponse", - ); - // Spy on console.error to verify the error is logged const consoleErrorSpy = jest .spyOn(console, "error") .mockImplementation(() => {}); - // Handle the event - await serviceWithPrivates.handleEvent(event); - - // Wait a bit for async operations - await new Promise((resolve) => setTimeout(resolve, 100)); + await dispatchNip47ServiceRequest(service, event); // Verify the error was logged expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -1128,17 +1041,16 @@ describe("NIP-47: Nostr Wallet Connect", () => { expect.any(Error), ); - // Verify the map was cleaned up - expect(requestEncryptionMap.size).toBe(initialSize); - expect(requestEncryptionMap.has(event.id)).toBe(false); - expect(sendErrorResponseSpy).toHaveBeenCalledWith( - clientKeypair.publicKey, - serviceKeypair.publicKey, - NIP47ErrorCode.INVALID_REQUEST, - "Invalid request: malformed JSON", - event.id, - NIP47Method.UNKNOWN, + const responseEvent = findResponse(event.id); + expect(responseEvent).toBeDefined(); + const response = decodeResponse( + responseEvent!, + clientKeypair.privateKey, ); + expect(response.result_type).toBe(NIP47Method.UNKNOWN); + expect(response.error).toMatchObject({ + code: NIP47ErrorCode.INVALID_REQUEST, + }); consoleErrorSpy.mockRestore(); }); @@ -1147,8 +1059,8 @@ describe("NIP-47: Nostr Wallet Connect", () => { describe("Response Validation", () => { it("should validate response structure according to NIP-47 specification", async () => { - // Access the private validateResponse method for testing - const validateResponse = client["validateResponse"].bind(client); + const validateResponse = (response: unknown) => + validateNIP47Response(response, (message) => new Error(message)); // Valid successful response expect(() => diff --git a/tests/testing/public-test-seams.test.ts b/tests/testing/public-test-seams.test.ts new file mode 100644 index 00000000..943b92db --- /dev/null +++ b/tests/testing/public-test-seams.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from "fs"; +import path from "path"; + +const repoRoot = path.resolve(__dirname, "../.."); + +function read(relativePath: string): string { + return readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +describe("public behavior test seams", () => { + test("removes the shared Nostr and Relay private-shape adapters", () => { + const testSupport = read("tests/types/index.ts"); + const behaviorTests = [ + "tests/integration.test.ts", + "tests/nip01/event/addressable-events.test.ts", + "tests/nip01/event/event-ordering-integration.test.ts", + "tests/nip01/event/nostr-publish.test.ts", + "tests/nip01/nostr.test.ts", + "tests/nip01/relay/filters.test.ts", + "tests/nip01/relay/relay-reconnect.test.ts", + "tests/nip01/relay/relay.test.ts", + "tests/nip01/relay/relayEventStore.test.ts", + ] + .map(read) + .join("\n"); + + for (const legacyAdapter of [ + "NostrInternals", + "getNostrInternals", + "RelayTestAccess", + "asTestRelay", + "NostrPrivateMembers", + "asTestable", + ]) { + expect(testSupport).not.toContain(legacyAdapter); + expect(behaviorTests).not.toContain(legacyAdapter); + } + }); + + test("removes named broad private-shape casts from targeted NIP tests", () => { + const targetedTests = [ + "tests/nip46/core-functionality.test.ts", + "tests/nip46/performance-security.test.ts", + "tests/nip46/protocol-core.test.ts", + "tests/nip47/client-encryption-tracking-simple.test.ts", + "tests/nip47/nip47.test.ts", + ] + .map(read) + .join("\n"); + + for (const privateShape of [ + "BunkerWithInternals", + "ClientWithInternals", + "ClientWithPrivateMethods", + "ClientInitializationState", + "ServiceWithMockAccess", + "ServiceWithPrivates", + "clientWithInternals", + "clientWithPrivates", + "engineInternals", + "serviceWithPrivates", + ]) { + expect(targetedTests).not.toContain(privateShape); + } + }); + + test("keeps testing-entrypoint controls narrow and behavior-oriented", () => { + const testingEntrypoints = [ + "src/testing/behavior-controls.ts", + "src/testing/index.ts", + ] + .map(read) + .join("\n"); + + for (const broadControl of [ + "installNip47ClientInitializationHooks", + "NIP47ClientInitializationHooks", + "NIP47ClientInitializationTransport", + "processNip47ServiceRequest", + "encryptionRetained", + "requestEncryption.has", + ]) { + expect(testingEntrypoints).not.toContain(broadControl); + } + }); +}); diff --git a/tests/types/index.ts b/tests/types/index.ts index d56e530a..884cae39 100644 --- a/tests/types/index.ts +++ b/tests/types/index.ts @@ -1,17 +1,13 @@ import { - Nostr, NostrEvent, PublishOptions, PublishResponse, Relay, RelayEvent, RelayEventCallbacks, - Subscription, } from "../../src"; -import type { RelayConnectionOptions } from "../../src/types/protocol"; import { NostrRelay } from "../../src/testing"; import { normalizeRelayUrl as normalizeRelayUrlUtil } from "../../src/utils/relayUrl"; -import { RelayEventStore } from "../../src/nip01/relayEventStore"; /** * Interface for mocking a Relay in tests @@ -41,18 +37,10 @@ export interface MockRelay { authEvent: NostrEvent, options?: PublishOptions, ): Promise; - getLatestReplaceableEvent?(pubkey: string, kind: number): NostrEvent | undefined; -} - -/** - * Interface for accessing private members of the Nostr class during testing - * DO NOT USE THIS IN PRODUCTION CODE! - */ -export interface NostrInternals { - privateKey: string; - publicKey: string; - relays: Map; - relayOptions?: RelayConnectionOptions; + getLatestReplaceableEvent?( + pubkey: string, + kind: number, + ): NostrEvent | undefined; } /** @@ -62,60 +50,6 @@ export function isMockRelay(relay: Relay | MockRelay): relay is MockRelay { return "publishResults" in relay; } -/** - * Safe cast from Nostr to NostrInternals for testing - */ -export function getNostrInternals(client: Nostr): NostrInternals { - return client as unknown as NostrInternals; -} - -/** - * Interface for accessing private members of Relay class in tests - * This provides type-safe access to internal implementation details - * DO NOT USE THIS IN PRODUCTION CODE! - */ -export interface RelayTestAccess { - // Public properties and methods from Relay - url: string; - connect(): Promise; - disconnect(): void; - getSubscriptionIds(): Set; - setConnectionTimeout(timeout: number): void; - getConnectionTimeout(): number; - - // Private connection related properties - ws: WebSocket | null; - connectionPromise: Promise | null; - autoReconnect: boolean; - maxReconnectAttempts: number; - maxReconnectDelay: number; - reconnectAttempts: number; - reconnectTimer: NodeJS.Timeout | null; - connected: boolean; - - // Private internal state - subscriptions: Map; - eventStore: RelayEventStore; - pendingValidationCounts: Map; - status: string; - - // Private internal methods - handleMessage(message: string[] | unknown[]): void; - validateInboundEvent(event: unknown): Promise; - processReplaceableEvent(event: NostrEvent): void; - processAddressableEvent(event: NostrEvent): void; - flushSubscriptionBuffer(subscriptionId: string): void; - scheduleReconnect(): void; -} - -/** - * Helper function to safely cast a Relay to RelayTestAccess for testing - * This makes the code intention clearer than using "as any" - */ -export function asTestRelay(relay: Relay): RelayTestAccess { - return relay as unknown as RelayTestAccess; -} - /** * Type definitions for Relay event callbacks used in tests. * These are aliases of the library's `RelayEventCallbacks` type