// // GatewayServiceTests.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // import BitFoundation import Foundation import Testing @testable import bitchat @Suite("Gateway mode policy") @MainActor struct GatewayServiceTests { nonisolated private static let geohash = "u4pruy" /// Closure-injected harness around `GatewayService` recording every /// side effect, with a controllable clock and relay connectivity. @MainActor private final class Fixture { private final class ClockBox { var now = Date() } var relaysConnected = true var currentGeohash: String? = GatewayServiceTests.geohash var gatewayPeers: [PeerID] = [] var sendToGatewaySucceeds = true private(set) var published: [(event: NostrEvent, geohash: String)] = [] private(set) var broadcasts: [Data] = [] private(set) var injected: [NostrEvent] = [] private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] private(set) var enabledChanges: [Bool] = [] private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] private let clock = ClockBox() let defaults: UserDefaults let service: GatewayService init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") { defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let clock = clock service = GatewayService(defaults: defaults) { clock.now } service.publishToRelays = { [weak self] event, geohash in self?.published.append((event, geohash)) } service.broadcastToMesh = { [weak self] payload in self?.broadcasts.append(payload) } service.sendToGatewayPeer = { [weak self] payload, peer in guard let self, self.sendToGatewaySucceeds else { return false } self.uplinkSends.append((payload, peer)) return true } service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] } service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } service.currentGeohash = { [weak self] in self?.currentGeohash } service.injectInbound = { [weak self] event in self?.injected.append(event) } service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } // Capture drain timers instead of arming a real Task so the drain // is deterministic under the fake clock. service.scheduleDrainTimer = { [weak self] delay, work in self?.scheduledDrains.append((delay, work)) } if enabled { service.setEnabled(true) } } func advance(_ seconds: TimeInterval) { clock.now = clock.now.addingTimeInterval(seconds) } /// Fires every currently-scheduled drain timer (simulating the window /// freeing), as the real Task would after its delay. func fireScheduledDrains() { let due = scheduledDrains scheduledDrains.removeAll() for item in due { item.work() } } } // MARK: Event helpers private func makeEvent( geohash: String = GatewayServiceTests.geohash, content: String = "hello \(UUID().uuidString.prefix(8))" ) throws -> NostrEvent { let identity = try NostrIdentity.generate() return try NostrProtocol.createEphemeralGeohashEvent( content: content, geohash: geohash, senderIdentity: identity, nickname: "tester" ) } /// A copy of `event` with tampered content but the original ID and /// signature — what a forging gateway or mesh peer would produce. private func forge(_ event: NostrEvent) throws -> NostrEvent { let dict: [String: Any] = [ "id": event.id, "pubkey": event.pubkey, "created_at": event.created_at, "kind": event.kind, "tags": event.tags, "content": event.content + " (tampered)", "sig": event.sig ?? "" ] return try NostrEvent(from: dict) } private func carrierPayload( _ event: NostrEvent, direction: NostrCarrierPacket.Direction = .toGateway, geohash: String = GatewayServiceTests.geohash ) throws -> Data { let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event)) return try #require(packet.encode()) } private func deposit( _ event: NostrEvent, into fixture: Fixture, from depositor: PeerID = PeerID(str: "1122334455667788"), geohash: String = GatewayServiceTests.geohash ) throws { let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash) fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true) } // MARK: - Uplink verification gates @Test("publishes a verified deposit to the geo relays") func verifiedDepositPublished() throws { let fixture = Fixture() let event = try makeEvent() try deposit(event, into: fixture) #expect(fixture.published.count == 1) #expect(fixture.published.first?.event.id == event.id) #expect(fixture.published.first?.geohash == Self.geohash) // Viewing the same geohash: the carried message shows on our own timeline. #expect(fixture.injected.map(\.id) == [event.id]) } @Test("rejects a forged signature") func forgedSignatureRejected() throws { let fixture = Fixture() let forged = try forge(try makeEvent()) try deposit(forged, into: fixture) #expect(fixture.published.isEmpty) #expect(fixture.injected.isEmpty) } @Test("rejects wrong kind, geohash mismatch, and stale events") func structuralGates() throws { let fixture = Fixture() // Wrong kind (kind-1 text note instead of kind-20000 ephemeral). let identity = try NostrIdentity.generate() let note = try NostrProtocol.createGeohashTextNote( content: "note", geohash: Self.geohash, senderIdentity: identity ) try deposit(note, into: fixture) #expect(fixture.published.isEmpty) // Carrier geohash disagreeing with the event's #g tag. let mismatched = try makeEvent(geohash: "9q8yyk") try deposit(mismatched, into: fixture, geohash: Self.geohash) #expect(fixture.published.isEmpty) // Stale event (beyond accepted clock skew). let stale = try makeEvent() fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60) try deposit(stale, into: fixture) #expect(fixture.published.isEmpty) } @Test("does nothing while the toggle is off") func disabledGatewayIgnoresDeposits() throws { let fixture = Fixture(enabled: false) try deposit(try makeEvent(), into: fixture) #expect(fixture.published.isEmpty) #expect(fixture.service.queuedUplinks.isEmpty) fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash) #expect(fixture.broadcasts.isEmpty) } // MARK: - Uplink quotas and rate limit @Test("rate-limits deposits per depositor per minute") func uplinkRateLimit() throws { let fixture = Fixture() let depositor = PeerID(str: "aabbccddeeff0011") for _ in 0..