mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed, well-scoped findings; deeper architectural/security items are tracked separately. - PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s 150ms retry pause left the mic live and streaming for up to 120s, because cancel() no-op'd once `completed` was set. Bail after the sleep if the hold was released, and make cancel() always tear down a late-started capture. - Bridge courier depositDrop reported success and burned the dedup slot before the drop was actually published (evicted/compose-fail = lying 📦 "carried" with no retry). Only consume publishedDropKeys on durable accept; add BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey). - Blocked senders resurfaced via archived "heard here earlier" echoes, the one path that bypassed the live block filter — filter at seed time. - A late optimistic .sent clobbered the router's .carried state; extend ConversationStore.shouldSkipStatusUpdate to a full precedence guard (sending < sent < carried < delivered < read). - Read receipts were permanently burned when the router dropped them (marked sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only record as sent on a successful route, else retry on the next read scan. - MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a peer that never reconnects sat on .sending until relaunch — run it in the 120s bridge sweep. - Sightings tally now rolls over at midnight while idle; wave notification action localized across all 29 locales; bridged anon#tag uses suffix(4) like everything else; makeThrowawayIdentity delegates to NostrIdentity.generate(); .swiftlint.yml excludes .claude worktrees. - Add regression tests: carried→sent no-downgrade, carried→delivered upgrade, evicted pending drop stays retryable. - Bump MARKETING_VERSION to 1.7.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage The stricter no-downgrade guard (delivered/carried never regress to sent) broke two things the earlier commit didn't catch locally (perf tests are skipped in the default run, and Periphery runs only in CI): - PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered assuming both directions apply; the delivered -> sent half is now correctly skipped, so the pass measured 0 updates. Alternate two delivered timestamps instead — every update is real, no downgrade. - Routing the geoDM "not in a location channel" error into the thread removed the only caller of ChatPrivateConversationContext.addSystemMessage, leaving it (and its mock) dead per Periphery. Drop the protocol requirement, the mock impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is compile-time enforced: the context can no longer emit a public system line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge - Read receipts: claim the receipt in sentReadReceipts synchronously before spawning the routing task (chat open runs two read scans in one MainActor stretch, so the async insert let every unread message route twice), and release the claim when the route fails so the retry-on-failed-route behavior is preserved. - Delivery status: extend the no-downgrade guard so the `.sending` stamp a pre-handshake resend emits can no longer clobber carried/delivered/read (the 📦 indicator survived `.sent` but not `.sending`). - Archived echoes: blocking a peer now purges their carried public messages from the gossip archive at block time (UnifiedPeerService and /block), while the fingerprint-to-peerID mapping is still known — the seed-time filter can't resolve offline non-favorite strangers and stays only as defense-in-depth. New Transport hook (default no-op) + GossipSyncManager.removePublicMessages with immediate persist. - Bridge courier: an envelope that can't encode within the drop size caps fails identically on every attempt; consume the dedup slot so the 120s retry sweep stops re-running Noise sealing on it. - MeshSightingsTracker: cache the day-key DateFormatter instead of building one per call. Tests: double-markAsRead dedup + failed-route retry, carried→sending no-downgrade matrix, block-time purge (manager + service wiring), oversize-drop slot consumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Also skip the sent→sending downgrade in the delivery-status guard Codex review follow-up: sendPrivateMessage without an established Noise session emits `.sending` asynchronously, so it can land after the message already reached `.sent` and visibly walk "Sent" back to "Sending...". Treat `.sending` as weaker than `.sent` too — the status was already truthful. `.failed` → `.sending` stays allowed so a retry after a real failure remains visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Retain Codable properties in Periphery scan (same fix as #1421) The noiseKey assign-only false positive fired persistently on this branch (twice, including a rerun) despite the baselined USR. Byte-identical to the fix on fix/announce-replay-link-steal so the branches merge cleanly in either order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
595 lines
22 KiB
Swift
595 lines
22 KiB
Swift
//
|
|
// BridgeServiceTests.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
@Suite("Mesh bridge policy")
|
|
@MainActor
|
|
struct BridgeServiceTests {
|
|
private static let cell = "u4pruy"
|
|
|
|
/// Closure-injected harness around `BridgeService` recording every side
|
|
/// effect, with a controllable clock, location, and connectivity.
|
|
@MainActor
|
|
private final class Fixture {
|
|
private final class ClockBox {
|
|
var now = Date()
|
|
}
|
|
|
|
var relaysConnected = true
|
|
var locationCell: String? = BridgeServiceTests.cell
|
|
var meshAdvertisedCell: String?
|
|
var bridgePeers: [PeerID] = []
|
|
var sendSucceeds = true
|
|
var locallySeenMessageIDs: Set<String> = []
|
|
var nickname = "tester"
|
|
|
|
private(set) var published: [(event: NostrEvent, cell: String)] = []
|
|
|
|
/// Published chat messages only — the fixture's own presence
|
|
/// heartbeats (kind 20001, sent on enable) are filtered out.
|
|
var publishedMessages: [(event: NostrEvent, cell: String)] {
|
|
published.filter { $0.event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue }
|
|
}
|
|
private(set) var broadcasts: [Data] = []
|
|
private(set) var injected: [BridgeService.InboundBridgeMessage] = []
|
|
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
|
|
private(set) var openedSubscriptions: [[String]] = []
|
|
private(set) var closedSubscriptions = 0
|
|
private(set) var enabledChanges: [Bool] = []
|
|
private(set) var locationFixRequests = 0
|
|
private(set) var cellChanges: [String?] = []
|
|
private(set) var scheduledTimers: [(delay: TimeInterval, work: @MainActor () -> Void)] = []
|
|
|
|
private let clock = ClockBox()
|
|
let identity: NostrIdentity
|
|
let defaults: UserDefaults
|
|
let service: BridgeService
|
|
|
|
init(enabled: Bool = true) {
|
|
let suite = "BridgeServiceTests-\(UUID().uuidString)"
|
|
defaults = UserDefaults(suiteName: suite)!
|
|
defaults.removePersistentDomain(forName: suite)
|
|
identity = try! NostrIdentity.generate()
|
|
let clock = clock
|
|
service = BridgeService(defaults: defaults) { clock.now }
|
|
service.publishToRelays = { [weak self] event, cell in
|
|
self?.published.append((event, cell))
|
|
}
|
|
service.openSubscription = { [weak self] cells in
|
|
self?.openedSubscriptions.append(cells)
|
|
}
|
|
service.closeSubscription = { [weak self] in
|
|
self?.closedSubscriptions += 1
|
|
}
|
|
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
|
|
service.locationCell = { [weak self] in self?.locationCell }
|
|
service.requestLocationFix = { [weak self] in self?.locationFixRequests += 1 }
|
|
service.meshAdvertisedCell = { [weak self] in self?.meshAdvertisedCell }
|
|
service.sendToBridgePeer = { [weak self] payload, peer in
|
|
guard let self, self.sendSucceeds else { return false }
|
|
self.uplinkSends.append((payload, peer))
|
|
return true
|
|
}
|
|
service.availableBridgePeers = { [weak self] in self?.bridgePeers ?? [] }
|
|
service.broadcastToMesh = { [weak self] payload in
|
|
self?.broadcasts.append(payload)
|
|
}
|
|
service.injectInbound = { [weak self] message in
|
|
self?.injected.append(message)
|
|
}
|
|
service.isMessageSeenLocally = { [weak self] id in
|
|
self?.locallySeenMessageIDs.contains(id) ?? false
|
|
}
|
|
service.deriveIdentity = { [weak self] _ in
|
|
guard let self else { throw NostrError.invalidEvent }
|
|
return self.identity
|
|
}
|
|
service.myNickname = { [weak self] in self?.nickname ?? "" }
|
|
service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) }
|
|
service.onActiveCellChanged = { [weak self] cell in self?.cellChanges.append(cell) }
|
|
service.scheduleTimer = { [weak self] delay, work in
|
|
self?.scheduledTimers.append((delay, work))
|
|
}
|
|
if enabled {
|
|
service.setEnabled(true)
|
|
}
|
|
}
|
|
|
|
func advance(_ seconds: TimeInterval) {
|
|
clock.now = clock.now.addingTimeInterval(seconds)
|
|
}
|
|
|
|
func fireScheduledTimers() {
|
|
let due = scheduledTimers
|
|
scheduledTimers.removeAll()
|
|
for item in due { item.work() }
|
|
}
|
|
}
|
|
|
|
// MARK: Event helpers
|
|
|
|
private func makeRemoteEvent(
|
|
cell: String = BridgeServiceTests.cell,
|
|
content: String = "hi \(UUID().uuidString.prefix(8))",
|
|
meshMessageID: String? = UUID().uuidString
|
|
) throws -> NostrEvent {
|
|
let identity = try NostrIdentity.generate()
|
|
return try NostrProtocol.createBridgeMeshEvent(
|
|
content: content,
|
|
cell: cell,
|
|
senderIdentity: identity,
|
|
nickname: "remote",
|
|
meshMessageID: meshMessageID
|
|
)
|
|
}
|
|
|
|
private func makePresenceEvent(cell: String = BridgeServiceTests.cell) throws -> NostrEvent {
|
|
try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate())
|
|
}
|
|
|
|
private func carrier(
|
|
_ event: NostrEvent,
|
|
direction: NostrCarrierPacket.Direction,
|
|
cell: String = BridgeServiceTests.cell
|
|
) throws -> NostrCarrierPacket {
|
|
try #require(NostrCarrierPacket(direction: direction, geohash: cell, event: event))
|
|
}
|
|
|
|
// MARK: - Lifecycle & rendezvous
|
|
|
|
@Test func enablingOpensSubscriptionForCellAndNeighbors() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
|
|
#expect(fixture.service.activeCell == Self.cell)
|
|
let cells = try #require(fixture.openedSubscriptions.first)
|
|
#expect(cells.first == Self.cell)
|
|
#expect(cells.count == 9) // own cell + 8 neighbors
|
|
#expect(fixture.service.subscribedCells.count == 9)
|
|
}
|
|
|
|
@Test func missingCellRequestsALocationFix() {
|
|
// Field bug: the bridge waited passively for availableChannels,
|
|
// which only flow while some other feature pumps location. Bridging
|
|
// without a cell must ask for a fix itself.
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.locationCell = nil
|
|
fixture.service.refreshRendezvous()
|
|
|
|
#expect(fixture.locationFixRequests >= 1)
|
|
#expect(fixture.service.activeCell == nil)
|
|
|
|
// The fix lands, channels flow, and the sink re-enters here:
|
|
fixture.locationCell = Self.cell
|
|
fixture.service.refreshRendezvous()
|
|
#expect(fixture.service.activeCell == Self.cell)
|
|
}
|
|
|
|
@Test func noLocationFallsBackToMeshAdvertisedCell() {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.locationCell = nil
|
|
fixture.meshAdvertisedCell = "u4prux"
|
|
fixture.service.refreshRendezvous()
|
|
|
|
#expect(fixture.service.activeCell == "u4prux")
|
|
}
|
|
|
|
@Test func disablingClosesSubscriptionAndClearsState() {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
fixture.service.setEnabled(false)
|
|
|
|
#expect(fixture.closedSubscriptions >= 1)
|
|
#expect(fixture.service.activeCell == nil)
|
|
#expect(fixture.service.bridgedPeerCount == 0)
|
|
}
|
|
|
|
@Test func togglePersistsAcrossInstances() {
|
|
let fixture = Fixture(enabled: true)
|
|
let revived = BridgeService(defaults: fixture.defaults)
|
|
#expect(revived.isEnabled)
|
|
}
|
|
|
|
// MARK: - Outgoing
|
|
|
|
@Test func outgoingPublishesSignedRendezvousEventWithMeshMessageID() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let messageID = UUID().uuidString
|
|
|
|
fixture.service.bridgeOutgoing(content: "hello hill", messageID: messageID)
|
|
|
|
let published = try #require(fixture.published.last)
|
|
#expect(published.cell == Self.cell)
|
|
#expect(published.event.isValidSignature())
|
|
#expect(published.event.content == "hello hill")
|
|
#expect(published.event.tags.contains(["r", Self.cell]))
|
|
#expect(published.event.tags.contains(["m", messageID]))
|
|
#expect(published.event.tags.contains(["n", "tester"]))
|
|
}
|
|
|
|
@Test func nearbyOnlySuppressesTheBridgedCopy() {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
fixture.service.nearbyOnly = true
|
|
|
|
fixture.service.bridgeOutgoing(content: "just us", messageID: UUID().uuidString)
|
|
|
|
#expect(fixture.publishedMessages.isEmpty)
|
|
#expect(fixture.uplinkSends.isEmpty)
|
|
}
|
|
|
|
@Test func outgoingWithoutRelaysDepositsWithBridgePeer() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
fixture.relaysConnected = false
|
|
fixture.bridgePeers = [PeerID(str: "abcdef0123456789")]
|
|
|
|
fixture.service.bridgeOutgoing(content: "no internet here", messageID: UUID().uuidString)
|
|
|
|
#expect(fixture.publishedMessages.isEmpty)
|
|
let sent = try #require(fixture.uplinkSends.first)
|
|
let carrier = try #require(NostrCarrierPacket.decode(sent.payload))
|
|
#expect(carrier.direction == .toBridge)
|
|
#expect(carrier.geohash == Self.cell)
|
|
}
|
|
|
|
@Test func ownRelayBackfilledEventIsIgnoredAfterRestart() throws {
|
|
// Field bug: a relaunch wipes the published-ID cache, and relay
|
|
// backfill then re-delivered the device's own pre-restart events as
|
|
// "bridged". Self-recognition by the deterministic rendezvous pubkey
|
|
// must catch them with no cache state at all.
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let ownOldEvent = try NostrProtocol.createBridgeMeshEvent(
|
|
content: "sent before the restart",
|
|
cell: Self.cell,
|
|
senderIdentity: fixture.identity, // == deriveIdentity(cell)
|
|
nickname: "tester",
|
|
meshMessageID: UUID().uuidString
|
|
)
|
|
|
|
fixture.service.handleRendezvousEvent(ownOldEvent)
|
|
|
|
#expect(fixture.injected.isEmpty)
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
#expect(fixture.service.bridgedPeerCount == 0)
|
|
}
|
|
|
|
@Test func ownEventComingBackFromSubscriptionIsIgnored() {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
fixture.service.bridgeOutgoing(content: "echo me", messageID: UUID().uuidString)
|
|
let ownEvent = fixture.published[0].event
|
|
|
|
fixture.service.handleRendezvousEvent(ownEvent)
|
|
|
|
#expect(fixture.injected.isEmpty)
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
#expect(fixture.service.bridgedPeerCount == 0)
|
|
}
|
|
|
|
// MARK: - Subscription ingress
|
|
|
|
@Test func remoteMessageInjectsAndDownlinks() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleRendezvousEvent(event)
|
|
|
|
#expect(fixture.injected.count == 1)
|
|
#expect(fixture.injected.first?.content == event.content)
|
|
#expect(fixture.service.bridgedPeerCount == 1)
|
|
// Serving duty: after the jitter holdoff, the remote event rides out
|
|
// as a fromBridge broadcast — one switch, no gateway toggle.
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
fixture.fireScheduledTimers()
|
|
let broadcast = try #require(fixture.broadcasts.first)
|
|
let carrier = try #require(NostrCarrierPacket.decode(broadcast))
|
|
#expect(carrier.direction == .fromBridge)
|
|
}
|
|
|
|
@Test func jitterHoldoffSuppressesAlreadyBroadcastEvents() throws {
|
|
// Two gateways, one island: while our drain waits out the jitter,
|
|
// the other gateway's fromBridge broadcast arrives — ours must yield.
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleRendezvousEvent(event) // queued behind jitter
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(event, direction: .fromBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: false
|
|
)
|
|
fixture.fireScheduledTimers()
|
|
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
#expect(fixture.injected.count == 1) // rendered once, either path
|
|
}
|
|
|
|
@Test func neighborCellEventIsAccepted() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let neighbor = try #require(Geohash.neighbors(of: Self.cell).first)
|
|
let event = try makeRemoteEvent(cell: neighbor)
|
|
|
|
fixture.service.handleRendezvousEvent(event)
|
|
|
|
#expect(fixture.injected.count == 1)
|
|
}
|
|
|
|
@Test func outOfRingCellEventIsRejected() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent(cell: "9q8yyk")
|
|
|
|
fixture.service.handleRendezvousEvent(event)
|
|
|
|
#expect(fixture.injected.isEmpty)
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
}
|
|
|
|
@Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let messageID = UUID().uuidString
|
|
fixture.locallySeenMessageIDs = [messageID]
|
|
let event = try makeRemoteEvent(meshMessageID: messageID)
|
|
|
|
fixture.service.handleRendezvousEvent(event)
|
|
|
|
// The island already heard this over radio: no duplicate render, no
|
|
// wasted airtime — but the sender still counts as a (local)
|
|
// participant, never a bridged one.
|
|
#expect(fixture.injected.isEmpty)
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
#expect(fixture.service.bridgedPeerCount == 0)
|
|
}
|
|
|
|
@Test func duplicateSubscriptionEventInjectsOnce() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleRendezvousEvent(event)
|
|
fixture.service.handleRendezvousEvent(event)
|
|
fixture.fireScheduledTimers()
|
|
|
|
#expect(fixture.injected.count == 1)
|
|
#expect(fixture.broadcasts.count == 1)
|
|
}
|
|
|
|
@Test func presenceCountsParticipantWithoutInjection() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
|
|
fixture.service.handleRendezvousEvent(try makePresenceEvent())
|
|
|
|
#expect(fixture.injected.isEmpty)
|
|
#expect(fixture.service.bridgedPeerCount == 1)
|
|
}
|
|
|
|
@Test func staleParticipantsAgeOutOfTheCount() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
fixture.service.handleRendezvousEvent(try makePresenceEvent())
|
|
#expect(fixture.service.bridgedPeerCount == 1)
|
|
|
|
fixture.advance(BridgeService.Limits.participantFreshnessSeconds + 1)
|
|
fixture.service.publishPresence() // any activity recomputes via prune path
|
|
fixture.fireScheduledTimers()
|
|
|
|
#expect(fixture.service.bridgedPeerCount == 0)
|
|
}
|
|
|
|
@Test func staleEventIsRejected() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
fixture.advance(BridgeService.Limits.maxEventAgeSeconds + 60)
|
|
|
|
fixture.service.handleRendezvousEvent(event)
|
|
|
|
#expect(fixture.injected.isEmpty)
|
|
}
|
|
|
|
// MARK: - Downlink budget
|
|
|
|
@Test func downlinkRespectsPerMinuteBudgetAndDrainsLater() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
|
|
for _ in 0..<(BridgeService.Limits.downlinkEventsPerMinute + 5) {
|
|
fixture.service.handleRendezvousEvent(try makeRemoteEvent())
|
|
}
|
|
fixture.fireScheduledTimers() // jitter holdoff elapses
|
|
|
|
#expect(fixture.broadcasts.count == BridgeService.Limits.downlinkEventsPerMinute)
|
|
// Window frees: the re-armed timer drains the backlog.
|
|
fixture.advance(61)
|
|
fixture.fireScheduledTimers()
|
|
#expect(fixture.broadcasts.count == BridgeService.Limits.downlinkEventsPerMinute + 5)
|
|
}
|
|
|
|
// MARK: - Mesh carrier ingress (receiver role)
|
|
|
|
@Test func fromBridgeBroadcastInjectsForMeshOnlyReceiver() throws {
|
|
// Reception is not gated on the toggle: passive radio.
|
|
let fixture = Fixture(enabled: false)
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(event, direction: .fromBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: false
|
|
)
|
|
|
|
#expect(fixture.injected.count == 1)
|
|
#expect(fixture.service.bridgedPeerCount == 1)
|
|
}
|
|
|
|
@Test func fromBridgeBroadcastDedupsAcrossMeshPaths() throws {
|
|
let fixture = Fixture(enabled: false)
|
|
let event = try makeRemoteEvent()
|
|
let packet = try carrier(event, direction: .fromBridge)
|
|
let peer = PeerID(str: "aabbccdd00112233")
|
|
|
|
fixture.service.handleMeshCarrier(packet, from: peer, directedToUs: false)
|
|
fixture.service.handleMeshCarrier(packet, from: peer, directedToUs: false)
|
|
|
|
#expect(fixture.injected.count == 1)
|
|
}
|
|
|
|
@Test func meshCarriedEventIsNeverRebroadcast() throws {
|
|
// Loop rule 1: a second gateway hearing a fromBridge broadcast must
|
|
// not downlink the same event when its own subscription delivers it.
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(event, direction: .fromBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: false
|
|
)
|
|
fixture.service.handleRendezvousEvent(event)
|
|
fixture.fireScheduledTimers()
|
|
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
#expect(fixture.injected.count == 1)
|
|
}
|
|
|
|
@Test func directedFromBridgeIsMalformedAndDropped() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(makeRemoteEvent(), direction: .fromBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: true
|
|
)
|
|
|
|
#expect(fixture.injected.isEmpty)
|
|
}
|
|
|
|
@Test func tamperedCarrierEventIsRejected() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
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 ?? ""
|
|
]
|
|
let forged = try NostrEvent(from: dict)
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(forged, direction: .fromBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: false
|
|
)
|
|
|
|
#expect(fixture.injected.isEmpty)
|
|
}
|
|
|
|
// MARK: - Uplink deposits (gateway role)
|
|
|
|
@Test func validDepositIsPublishedWhenRelaysUp() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(event, direction: .toBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: true
|
|
)
|
|
|
|
#expect(fixture.published.contains { $0.event.id == event.id })
|
|
// Deposits never inject: the depositor's radio broadcast already
|
|
// carried the message to this island.
|
|
#expect(fixture.injected.isEmpty)
|
|
}
|
|
|
|
@Test func depositQueuesWhileRelaysDownAndFlushesOnReconnect() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
fixture.relaysConnected = false
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(event, direction: .toBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: true
|
|
)
|
|
#expect(fixture.publishedMessages.isEmpty)
|
|
#expect(fixture.service.queuedUplinks.count == 1)
|
|
|
|
fixture.relaysConnected = true
|
|
fixture.service.flushQueuedUplinks()
|
|
#expect(fixture.published.contains { $0.event.id == event.id })
|
|
}
|
|
|
|
@Test func depositRequiresBridgeToggle() throws {
|
|
let fixture = Fixture(enabled: false)
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(makeRemoteEvent(), direction: .toBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: true
|
|
)
|
|
|
|
#expect(fixture.publishedMessages.isEmpty)
|
|
#expect(fixture.service.queuedUplinks.isEmpty)
|
|
}
|
|
|
|
@Test func depositRateLimitBoundsPerDepositor() throws {
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let depositor = PeerID(str: "aabbccdd00112233")
|
|
|
|
for _ in 0..<(BridgeService.Limits.uplinkEventsPerMinutePerDepositor + 4) {
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(makeRemoteEvent(), direction: .toBridge),
|
|
from: depositor,
|
|
directedToUs: true
|
|
)
|
|
}
|
|
|
|
#expect(fixture.publishedMessages.count == BridgeService.Limits.uplinkEventsPerMinutePerDepositor)
|
|
}
|
|
|
|
@Test func depositedEventIsNeverDownlinkedBack() throws {
|
|
// Loop rule 2: our own relay subscription redelivering an event we
|
|
// uplinked must not burn airtime broadcasting it back.
|
|
let fixture = Fixture(enabled: true)
|
|
fixture.service.refreshRendezvous()
|
|
let event = try makeRemoteEvent()
|
|
|
|
fixture.service.handleMeshCarrier(
|
|
try carrier(event, direction: .toBridge),
|
|
from: PeerID(str: "aabbccdd00112233"),
|
|
directedToUs: true
|
|
)
|
|
fixture.service.handleRendezvousEvent(event)
|
|
fixture.fireScheduledTimers()
|
|
|
|
#expect(fixture.broadcasts.isEmpty)
|
|
}
|
|
}
|