diff --git a/packages/net2/__tests__/auth.test.ts b/packages/net2/__tests__/auth.test.ts new file mode 100644 index 0000000..2600ffa --- /dev/null +++ b/packages/net2/__tests__/auth.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { Socket, SocketStatus, SocketEventType } from "../src/socket" +import { makeEvent, CLIENT_AUTH } from "@welshman/util" +import { AuthState, AuthStatus, AuthStateEventType, AuthManager, makeAuthEvent } from "../src/auth" +import EventEmitter from "events" +import { RelayMessage } from "../src/message" + +// Mock dependencies +vi.mock("@welshman/lib", () => ({ + on: (target: any, eventName: string, callback: Function) => { + target.on(eventName, callback) + return () => target.off(eventName, callback) + }, + call: (fn: Function) => fn(), + sleep: vi.fn() +})) + +vi.mock("@welshman/util", () => ({ + makeEvent: vi.fn((kind, opts) => ({ + kind, + id: "test-event-id", + ...opts + })), + CLIENT_AUTH: 24242 +})) + +describe("AuthState", () => { + let socket: Socket & EventEmitter + let authState: AuthState + + beforeEach(() => { + const mockSocket = new EventEmitter() + Object.assign(mockSocket, { + url: "wss://test.relay", + send: vi.fn(), + removeAllListeners: vi.fn() + }) + socket = mockSocket as unknown as Socket + authState = new AuthState(socket) + }) + + afterEach(() => { + authState.cleanup() + vi.clearAllMocks() + }) + + it("should initialize with None status", () => { + expect(authState.status).toBe(AuthStatus.None) + }) + + it("should handle AUTH message from relay", () => { + const message: RelayMessage = ["AUTH", "challenge123"] + socket.emit(SocketEventType.Receive, message) + + expect(authState.challenge).toBe("challenge123") + expect(authState.status).toBe(AuthStatus.Requested) + }) + + it("should handle successful OK message", () => { + authState.request = "request123" + const message: RelayMessage = ["OK", "request123", true, "success"] + socket.emit(SocketEventType.Receive, message) + + expect(authState.status).toBe(AuthStatus.Ok) + expect(authState.details).toBe("success") + }) + + it("should handle failed OK message", () => { + authState.request = "request123" + const message: RelayMessage = ["OK", "request123", false, "forbidden"] + socket.emit(SocketEventType.Receive, message) + + expect(authState.status).toBe(AuthStatus.Forbidden) + expect(authState.details).toBe("forbidden") + }) + + it("should ignore OK messages for different requests", () => { + authState.request = "request123" + const message: RelayMessage = ["OK", "different-request", true, "success"] + socket.emit(SocketEventType.Receive, message) + + expect(authState.status).toBe(AuthStatus.None) + }) + + it("should handle client AUTH message", () => { + const message: RelayMessage = ["AUTH", { id: "123", kind: CLIENT_AUTH }] + socket.emit(SocketEventType.Enqueue, message) + + expect(authState.status).toBe(AuthStatus.PendingResponse) + }) + + it("should reset state on socket close", () => { + authState.challenge = "challenge123" + authState.request = "request123" + authState.details = "details" + authState.status = AuthStatus.PendingResponse + + socket.emit(SocketEventType.Status, SocketStatus.Closed) + + expect(authState.challenge).toBeUndefined() + expect(authState.request).toBeUndefined() + expect(authState.details).toBeUndefined() + expect(authState.status).toBe(AuthStatus.None) + }) + + it("should emit status changes", () => { + const statusSpy = vi.fn() + authState.on(AuthStateEventType.Status, statusSpy) + + authState.setStatus(AuthStatus.Requested) + + expect(statusSpy).toHaveBeenCalledWith(AuthStatus.Requested) + }) + + it("should cleanup properly", () => { + const removeListenersSpy = vi.spyOn(authState, "removeAllListeners") + authState.cleanup() + expect(removeListenersSpy).toHaveBeenCalled() + }) +}) + +describe("AuthManager", () => { + let socket: Socket & EventEmitter + let manager: AuthManager + let signFn: jest.Mock + + beforeEach(() => { + const mockSocket = new EventEmitter() + Object.assign(mockSocket, { + url: "wss://test.relay", + send: vi.fn(), + removeAllListeners: vi.fn(), + attemptToOpen: vi.fn() + }) + socket = mockSocket as unknown as Socket & EventEmitter + signFn = vi.fn() + manager = new AuthManager(socket, { sign: signFn }) + }) + + afterEach(() => { + manager.cleanup() + vi.clearAllMocks() + }) + + it("should create AuthState instance", () => { + expect(manager.state).toBeInstanceOf(AuthState) + }) + + it("should respond automatically when eager is true", () => { + const respondSpy = vi.spyOn(AuthManager.prototype, "respond") + const eagerManager = new AuthManager(socket, { sign: signFn, eager: true }) + + socket.emit(SocketEventType.Receive, ["AUTH", "challenge123"]) + + expect(respondSpy).toHaveBeenCalled() + }) + + it("should not respond automatically when eager is false", () => { + const respondSpy = vi.spyOn(AuthManager.prototype, "respond") + socket.emit(SocketEventType.Receive, ["AUTH", "challenge123"]) + + expect(respondSpy).not.toHaveBeenCalled() + }) + + describe("respond", () => { + it("should throw error if no challenge", async () => { + await expect(manager.respond()).rejects.toThrow("Attempted to authenticate with no challenge") + }) + + it("should throw error if status is not Requested", async () => { + manager.state.challenge = "challenge123" + manager.state.status = AuthStatus.PendingSignature + + await expect(manager.respond()).rejects.toThrow("Attempted to authenticate when auth is already auth:status:pending_signature") + }) + + it("should handle successful sign", async () => { + manager.state.challenge = "challenge123" + manager.state.status = AuthStatus.Requested + const signedEvent = { id: "signed-event-id", kind: CLIENT_AUTH } + signFn.mockResolvedValue(signedEvent) + + await manager.respond() + + expect(manager.state.request).toBe("signed-event-id") + expect(socket.send).toHaveBeenCalledWith(["AUTH", signedEvent]) + }) + + it("should handle denied signature", async () => { + manager.state.challenge = "challenge123" + manager.state.status = AuthStatus.Requested + signFn.mockResolvedValue(null) + + await manager.respond() + + expect(manager.state.status).toBe(AuthStatus.DeniedSignature) + expect(socket.send).not.toHaveBeenCalled() + }) + }) + + describe("attempt", () => { + it("should attempt to open socket", async () => { + await manager.attempt() + expect(socket.attemptToOpen).toHaveBeenCalled() + }) + + it("should wait for challenge", async () => { + const waitForChallengeSpy = vi.spyOn(manager, "waitForChallenge") + await manager.attempt() + expect(waitForChallengeSpy).toHaveBeenCalled() + }) + + it("should respond if challenge received", async () => { + const respondSpy = vi.spyOn(manager, "respond") + manager.state.challenge = "challenge123" + manager.state.status = AuthStatus.Requested + await manager.attempt() + expect(respondSpy).toHaveBeenCalled() + }) + + it("should wait for resolution", async () => { + const waitForResolutionSpy = vi.spyOn(manager, "waitForResolution") + await manager.attempt() + expect(waitForResolutionSpy).toHaveBeenCalled() + }) + }) + + describe("makeAuthEvent", () => { + it("should create auth event with correct tags", () => { + const url = "wss://test.relay" + const challenge = "challenge123" + + makeAuthEvent(url, challenge) + + expect(makeEvent).toHaveBeenCalledWith(CLIENT_AUTH, { + tags: [ + ["relay", url], + ["challenge", challenge] + ] + }) + }) + }) +}) diff --git a/packages/net2/__tests__/pool.test.ts b/packages/net2/__tests__/pool.test.ts new file mode 100644 index 0000000..f76c870 --- /dev/null +++ b/packages/net2/__tests__/pool.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { Socket } from "../src/socket" +import { Pool, makeSocket } from "../src/pool" +import { normalizeRelayUrl } from "@welshman/util" + +// Mock dependencies +vi.mock("@welshman/lib", () => ({ + remove: vi.fn((item, array) => array.filter(x => x !== item)), + on: vi.fn((target, event, callback) => { + if (target.on) { + target.on(event, callback) + } + return () => { + if (target.off) { + target.off(event, callback) + } + } + }), + call: vi.fn(fn => fn()) +})) + +vi.mock("@welshman/util", () => ({ + normalizeRelayUrl: vi.fn(url => url) +})) + +vi.mock("../src/socket", async (importOriginal) => { + const original = await importOriginal() + + return { + ...original, + Socket: vi.fn().mockImplementation((url) => ({ + url, + cleanup: vi.fn(), + _sendQueue: { + start: vi.fn(), + stop: vi.fn() + }, + on: vi.fn(), + off: vi.fn() + })), + } +}) + +describe("makeSocket", () => { + let mockSocket: any + + beforeEach(() => { + mockSocket = { + url: "wss://test.relay", + cleanup: vi.fn(), + _sendQueue: { + start: vi.fn(), + stop: vi.fn() + }, + on: vi.fn(), + off: vi.fn() + } + vi.mocked(Socket).mockReturnValue(mockSocket) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("should create socket with url", () => { + const socket = makeSocket("wss://test.relay", []) + expect(Socket).toHaveBeenCalledWith("wss://test.relay") + }) + + it("should apply custom policies", () => { + const customPolicy = vi.fn(() => () => {}) + const socket = makeSocket("wss://test.relay", [customPolicy]) + expect(customPolicy).toHaveBeenCalledWith(mockSocket) + }) +}) + +describe("Pool", () => { + let pool: Pool + let customMakeSocket: jest.Mock + + beforeEach(() => { + customMakeSocket = vi.fn() + pool = new Pool({ makeSocket: customMakeSocket }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("initialization", () => { + it("should initialize with empty data map", () => { + expect(pool._data.size).toBe(0) + }) + + it("should initialize with empty subscriptions", () => { + expect(pool._subs).toEqual([]) + }) + }) + + describe("has", () => { + it("should return false for non-existent socket", () => { + expect(pool.has("wss://test.relay")).toBe(false) + }) + + it("should return true for existing socket", () => { + const mockSocket = { url: "wss://test.relay" } + customMakeSocket.mockReturnValue(mockSocket) + pool.get("wss://test.relay") + expect(pool.has("wss://test.relay")).toBe(true) + }) + }) + + describe("makeSocket", () => { + it("should use custom makeSocket if provided", () => { + const mockSocket = { url: "wss://test.relay" } + customMakeSocket.mockReturnValue(mockSocket) + + const result = pool.makeSocket("wss://test.relay") + + expect(customMakeSocket).toHaveBeenCalledWith("wss://test.relay") + expect(result).toBe(mockSocket) + }) + + it("should use default makeSocket if none provided", () => { + pool = new Pool({}) + const socket = pool.makeSocket("wss://test.relay") + expect(Socket).toHaveBeenCalledWith("wss://test.relay") + }) + }) + + describe("get", () => { + it("should normalize relay URL", () => { + const mockSocket = { url: "wss://test.relay" } + customMakeSocket.mockReturnValue(mockSocket) + pool.get("wss://test.relay") + expect(normalizeRelayUrl).toHaveBeenCalledWith("wss://test.relay") + }) + + it("should create new socket if none exists", () => { + const mockSocket = { url: "wss://test.relay" } + customMakeSocket.mockReturnValue(mockSocket) + + const socket = pool.get("wss://test.relay") + + expect(customMakeSocket).toHaveBeenCalledWith("wss://test.relay") + expect(socket).toBe(mockSocket) + }) + + it("should return existing socket if it exists", () => { + const mockSocket = { url: "wss://test.relay" } + customMakeSocket.mockReturnValue(mockSocket) + + const firstSocket = pool.get("wss://test.relay") + const secondSocket = pool.get("wss://test.relay") + + expect(customMakeSocket).toHaveBeenCalledTimes(1) + expect(firstSocket).toBe(secondSocket) + }) + + it("should notify subscribers of new sockets", () => { + const sub1 = vi.fn() + const sub2 = vi.fn() + const mockSocket = { url: "wss://test.relay" } + customMakeSocket.mockReturnValue(mockSocket) + + pool.subscribe(sub1) + pool.subscribe(sub2) + pool.get("wss://test.relay") + + expect(sub1).toHaveBeenCalledWith(mockSocket) + expect(sub2).toHaveBeenCalledWith(mockSocket) + }) + + it("should not notify subscribers for existing sockets", () => { + const mockSocket = { url: "wss://test.relay" } + customMakeSocket.mockReturnValue(mockSocket) + pool.get("wss://test.relay") + + const sub = vi.fn() + pool.subscribe(sub) + pool.get("wss://test.relay") + + expect(sub).not.toHaveBeenCalled() + }) + }) + + describe("subscribe", () => { + it("should add subscription", () => { + const sub = vi.fn() + pool.subscribe(sub) + expect(pool._subs).toContain(sub) + }) + + it("should return unsubscribe function", () => { + const sub = vi.fn() + const unsubscribe = pool.subscribe(sub) + + unsubscribe() + + expect(pool._subs).not.toContain(sub) + }) + }) + + describe("remove", () => { + it("should remove and cleanup existing socket", () => { + const mockSocket = { url: "wss://test.relay", cleanup: vi.fn() } + customMakeSocket.mockReturnValue(mockSocket) + + pool.get("wss://test.relay") + pool.remove("wss://test.relay") + + expect(mockSocket.cleanup).toHaveBeenCalled() + expect(pool._data.has("wss://test.relay")).toBe(false) + }) + + it("should do nothing for non-existent socket", () => { + pool.remove("wss://test.relay") + expect(pool._data.has("wss://test.relay")).toBe(false) + }) + }) + + describe("clear", () => { + it("should remove all sockets", () => { + const urls = ["wss://test1.relay", "wss://test2.relay"] + const mockSockets = urls.map(url => ({ url, cleanup: vi.fn() })) + let socketIndex = 0 + customMakeSocket.mockImplementation(() => mockSockets[socketIndex++]) + + urls.forEach(url => pool.get(url)) + pool.clear() + + expect(pool._data.size).toBe(0) + mockSockets.forEach(socket => { + expect(socket.cleanup).toHaveBeenCalled() + }) + }) + + it("should do nothing on empty pool", () => { + expect(() => pool.clear()).not.toThrow() + }) + }) +}) diff --git a/packages/net2/src/pool.ts b/packages/net2/src/pool.ts index 1f300d2..25504c3 100644 --- a/packages/net2/src/pool.ts +++ b/packages/net2/src/pool.ts @@ -6,7 +6,7 @@ import {defaultSocketPolicies} from "./policy.js" export const makeSocket = (url: string, policies = defaultSocketPolicies) => { const socket = new Socket(url) - for (const applyPolicy of defaultSocketPolicies) { + for (const applyPolicy of policies) { applyPolicy(socket) }