Files
bitchat/bitchatTests/Services/BridgeCourierServiceTests.swift
T
dd6b624cae Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418)
* 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>
2026-07-09 16:44:02 +02:00

327 lines
13 KiB
Swift

//
// BridgeCourierServiceTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
@Suite("Courier over the bridge")
@MainActor
struct BridgeCourierServiceTests {
/// Closure-injected harness around `BridgeCourierService`.
@MainActor
private final class Fixture {
var bridgeOn = true
var relaysConnected = true
var myKey: Data? = Fixture.randomKey()
var localPeers: [(peerID: PeerID, noiseKey: Data)] = []
var held: [CourierEnvelope] = []
var sealResult: CourierEnvelope?
private(set) var publishedEvents: [NostrEvent] = []
private(set) var openedSubscriptions: [[String]] = []
private(set) var closedSubscriptions = 0
private(set) var openedEnvelopes: [CourierEnvelope] = []
private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = []
private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = []
private(set) var heldCooldowns: [TimeInterval] = []
let service: BridgeCourierService
init() {
service = BridgeCourierService()
service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false }
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
service.publishEvent = { [weak self] event in self?.publishedEvents.append(event) }
service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) }
service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 }
service.myNoiseKey = { [weak self] in self?.myKey }
service.localVerifiedPeers = { [weak self] in self?.localPeers ?? [] }
service.sealEnvelope = { [weak self] content, messageID, key in
self?.sealRequests.append((content, messageID, key))
return self?.sealResult
}
service.openEnvelope = { [weak self] envelope in self?.openedEnvelopes.append(envelope) }
service.deliverToPeer = { [weak self] envelope, peer in self?.delivered.append((envelope, peer)) }
service.heldEnvelopes = { [weak self] cooldown in
self?.heldCooldowns.append(cooldown)
return self?.held ?? []
}
service.scheduleTimer = { _, _ in } // timers driven manually
}
static func randomKey() -> Data {
Data((0..<32).map { _ in UInt8.random(in: 0...255) })
}
}
private func makeEnvelope(recipientKey: Data, ciphertext: Data = Data(repeating: 7, count: 64)) -> CourierEnvelope {
CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientKey,
epochDay: CourierEnvelope.epochDay(for: Date())
),
expiry: UInt64((Date().timeIntervalSince1970 + 3600) * 1000),
ciphertext: ciphertext,
copies: 1
)
}
private func makeDropEvent(for envelope: CourierEnvelope) throws -> NostrEvent {
let encoded = try #require(envelope.encode())
let identity = try #require(BridgeCourierService.makeThrowawayIdentity())
return try NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: envelope.recipientTag.hexEncodedString(),
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
senderIdentity: identity
)
}
// MARK: - Sender role
@Test func depositSealsAndPublishesOnce() throws {
let fixture = Fixture()
let recipientKey = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
let messageID = UUID().uuidString
fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey)
fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey)
#expect(fixture.sealRequests.count == 1)
#expect(fixture.publishedEvents.count == 1)
let event = try #require(fixture.publishedEvents.first)
#expect(event.kind == NostrProtocol.EventKind.courierDrop.rawValue)
#expect(event.isValidSignature())
#expect(event.tags.contains { $0.count >= 2 && $0[0] == "x" && $0[1] == fixture.sealResult?.recipientTag.hexEncodedString() })
#expect(event.tags.contains { $0.count >= 2 && $0[0] == "expiration" })
}
@Test func depositRequiresBridgeToggle() {
let fixture = Fixture()
fixture.bridgeOn = false
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: key)
fixture.service.depositDrop(content: "hi", messageID: UUID().uuidString, recipientNoiseKey: key)
#expect(fixture.publishedEvents.isEmpty)
#expect(fixture.sealRequests.isEmpty)
}
@Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() {
let fixture = Fixture()
fixture.relaysConnected = false
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: key)
fixture.service.depositDrop(content: "later", messageID: UUID().uuidString, recipientNoiseKey: key)
#expect(fixture.publishedEvents.isEmpty)
#expect(fixture.service.pendingDrops.count == 1)
fixture.relaysConnected = true
fixture.service.flushPendingDrops()
#expect(fixture.publishedEvents.count == 1)
#expect(fixture.service.pendingDrops.isEmpty)
}
@Test func evictedPendingDropStaysRetryable() {
// Regression: a drop queued while relays are down but then evicted
// (oldest-out at capacity) before it ever published must release its
// sender-side dedup slot, or the router marks it "carried" and can
// never re-deposit it.
let fixture = Fixture()
fixture.relaysConnected = false
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: key)
let firstID = UUID().uuidString
#expect(fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key))
// Fill past capacity so the first drop is evicted.
for i in 1...BridgeCourierService.Limits.maxPendingDrops {
fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key)
}
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
// The evicted first drop is deposit-able again (slot released).
#expect(fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key))
}
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
// An envelope that encodes over the size cap fails identically on
// every attempt; the dedup slot must be consumed so the retry sweep
// doesn't re-run Noise sealing forever.
let fixture = Fixture()
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(
recipientKey: key,
ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1)
)
let messageID = UUID().uuidString
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
#expect(fixture.publishedEvents.isEmpty)
// The retry sweep must not seal the same payload again.
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
#expect(fixture.sealRequests.count == 1)
}
@Test func distinctDropsUseDistinctThrowawayKeys() {
let fixture = Fixture()
let keyA = Fixture.randomKey()
let keyB = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: keyA)
fixture.service.depositDrop(content: "a", messageID: UUID().uuidString, recipientNoiseKey: keyA)
fixture.sealResult = makeEnvelope(recipientKey: keyB)
fixture.service.depositDrop(content: "b", messageID: UUID().uuidString, recipientNoiseKey: keyB)
#expect(fixture.publishedEvents.count == 2)
#expect(fixture.publishedEvents[0].pubkey != fixture.publishedEvents[1].pubkey)
}
@Test func bridgingPublishesHeldEnvelopesWithCooldown() {
let fixture = Fixture()
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
fixture.service.publishHeldEnvelopes()
#expect(fixture.publishedEvents.count == 1)
#expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown])
}
// MARK: - Subscription management
@Test func refreshSubscribesOwnCandidateTags() throws {
let fixture = Fixture()
fixture.service.refresh()
let tags = try #require(fixture.openedSubscriptions.last)
let myKey = try #require(fixture.myKey)
let expected = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).map { $0.hexEncodedString() })
#expect(Set(tags) == expected)
#expect(tags.count == 3) // adjacent UTC days
}
@Test func refreshAlsoWatchesLocalVerifiedPeers() throws {
let fixture = Fixture()
let peerKey = Fixture.randomKey()
fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), peerKey)]
fixture.service.refresh()
let tags = try #require(fixture.openedSubscriptions.last)
#expect(tags.count == 6) // 3 own + 3 watched
}
@Test func refreshClosesSubscriptionWhenBridgeOff() {
let fixture = Fixture()
fixture.service.refresh()
#expect(fixture.openedSubscriptions.count == 1)
fixture.bridgeOn = false
fixture.service.refresh()
#expect(fixture.closedSubscriptions == 1)
}
// MARK: - Inbound drops
@Test func dropForUsIsOpened() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: myKey)
fixture.service.handleDropEvent(try makeDropEvent(for: envelope))
#expect(fixture.openedEnvelopes.count == 1)
#expect(fixture.delivered.isEmpty)
}
@Test func duplicateDropEventOpensOnce() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey))
fixture.service.handleDropEvent(event)
fixture.service.handleDropEvent(event)
#expect(fixture.openedEnvelopes.count == 1)
}
@Test func dropForWatchedLocalPeerIsDelivered() throws {
let fixture = Fixture()
let peerKey = Fixture.randomKey()
let peer = PeerID(str: "aabbccdd00112233")
fixture.localPeers = [(peer, peerKey)]
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: peerKey)
fixture.service.handleDropEvent(try makeDropEvent(for: envelope))
#expect(fixture.delivered.count == 1)
#expect(fixture.delivered.first?.peer == peer)
#expect(fixture.openedEnvelopes.isEmpty)
}
@Test func dropForStrangerIsIgnored() throws {
let fixture = Fixture()
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: Fixture.randomKey())
fixture.service.handleDropEvent(try makeDropEvent(for: envelope))
#expect(fixture.openedEnvelopes.isEmpty)
#expect(fixture.delivered.isEmpty)
}
@Test func mislabeledDropTagIsRejected() throws {
// The event's filterable #x tag must match the envelope's own tag.
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let envelope = makeEnvelope(recipientKey: Fixture.randomKey())
let encoded = try #require(envelope.encode())
let identity = try #require(BridgeCourierService.makeThrowawayIdentity())
let mislabeled = try NostrProtocol.createCourierDropEvent(
envelope: encoded,
recipientTagHex: CourierEnvelope.recipientTag(
noiseStaticKey: myKey,
epochDay: CourierEnvelope.epochDay(for: Date())
).hexEncodedString(), // labeled for us, addressed to a stranger
expiresAt: Date().addingTimeInterval(3600),
senderIdentity: identity
)
fixture.service.handleDropEvent(mislabeled)
#expect(fixture.openedEnvelopes.isEmpty)
#expect(fixture.delivered.isEmpty)
}
@Test func expiredDropIsIgnored() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let expired = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(noiseStaticKey: myKey, epochDay: CourierEnvelope.epochDay(for: Date())),
expiry: UInt64((Date().timeIntervalSince1970 - 60) * 1000),
ciphertext: Data(repeating: 1, count: 32),
copies: 1
)
fixture.service.handleDropEvent(try makeDropEvent(for: expired))
#expect(fixture.openedEnvelopes.isEmpty)
}
}