mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 09:25:19 +00:00
Envelopes are removed from the courier store optimistically, so releasing them on a relayed (multi-hop) announce risks losing carried mail to a speculative flood that never reaches the recipient. Handover now also requires the announce to have arrived directly (full TTL), i.e. an actual encounter with a live link; regression test builds a relayed copy of a genuinely signed announce (TTL is excluded from announce signatures). Also make CourierStore's on-disk location injectable so the persistence test round-trips through a temp directory instead of wiping the real Application Support store, and reattach BLEAnnounceHandler's doc comment to the class it describes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
177 lines
6.6 KiB
Swift
177 lines
6.6 KiB
Swift
//
|
|
// CourierStoreTests.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
struct CourierStoreTests {
|
|
|
|
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
|
|
|
|
private func makeStore(now: Date = baseDate) -> CourierStore {
|
|
CourierStore(persistsToDisk: false, now: { now })
|
|
}
|
|
|
|
/// Store whose clock can be advanced by tests.
|
|
private final class Clock {
|
|
var now: Date
|
|
init(_ now: Date) { self.now = now }
|
|
}
|
|
|
|
private func makeEnvelope(
|
|
recipientKey: Data = Data(repeating: 0xB0, count: 32),
|
|
sealedAt: Date = baseDate,
|
|
lifetime: TimeInterval = 60 * 60,
|
|
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
|
|
) -> CourierEnvelope {
|
|
CourierEnvelope(
|
|
recipientTag: CourierEnvelope.recipientTag(
|
|
noiseStaticKey: recipientKey,
|
|
epochDay: CourierEnvelope.epochDay(for: sealedAt)
|
|
),
|
|
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
|
|
ciphertext: ciphertext
|
|
)
|
|
}
|
|
|
|
private let depositorA = Data(repeating: 0xA1, count: 32)
|
|
private let depositorB = Data(repeating: 0xA2, count: 32)
|
|
|
|
// MARK: - Deposit and handover
|
|
|
|
@Test func depositThenTakeForRecipient() {
|
|
let store = makeStore()
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
|
|
|
#expect(store.deposit(envelope, from: depositorA))
|
|
let taken = store.takeEnvelopes(for: recipientKey)
|
|
#expect(taken == [envelope])
|
|
// Handover removes the envelope.
|
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
|
}
|
|
|
|
@Test func takeIgnoresOtherRecipients() {
|
|
let store = makeStore()
|
|
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
|
|
store.deposit(envelope, from: depositorA)
|
|
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
|
|
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
|
|
}
|
|
|
|
@Test func duplicateDepositIsIdempotent() {
|
|
let store = makeStore()
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
|
#expect(store.deposit(envelope, from: depositorA))
|
|
#expect(store.deposit(envelope, from: depositorA))
|
|
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
|
}
|
|
|
|
// MARK: - Validity
|
|
|
|
@Test func rejectsExpiredAndOversizedAndMalformed() {
|
|
let store = makeStore()
|
|
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
|
|
#expect(!store.deposit(expired, from: depositorA))
|
|
|
|
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
|
|
#expect(!store.deposit(oversized, from: depositorA))
|
|
|
|
let badTag = CourierEnvelope(
|
|
recipientTag: Data(repeating: 0, count: 4),
|
|
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
|
|
ciphertext: Data(repeating: 1, count: 16)
|
|
)
|
|
#expect(!store.deposit(badTag, from: depositorA))
|
|
}
|
|
|
|
@Test func rejectsExpiryBeyondPolicyLifetime() {
|
|
let store = makeStore()
|
|
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
|
|
#expect(!store.deposit(pinned, from: depositorA))
|
|
}
|
|
|
|
// MARK: - Quotas
|
|
|
|
@Test func perDepositorQuota() {
|
|
let store = makeStore()
|
|
for _ in 0..<CourierStore.Limits.maxPerDepositor {
|
|
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
|
}
|
|
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
|
// A different depositor still has room.
|
|
#expect(store.deposit(makeEnvelope(), from: depositorB))
|
|
}
|
|
|
|
@Test func totalQuotaEvictsOldestFirst() {
|
|
let store = makeStore()
|
|
let firstRecipient = Data(repeating: 0xD0, count: 32)
|
|
let first = makeEnvelope(recipientKey: firstRecipient)
|
|
store.deposit(first, from: depositorA)
|
|
|
|
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
|
|
var deposited = 1
|
|
var depositorByte: UInt8 = 1
|
|
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
|
let depositor = Data(repeating: depositorByte, count: 32)
|
|
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
|
#expect(store.deposit(makeEnvelope(), from: depositor))
|
|
deposited += 1
|
|
}
|
|
depositorByte += 1
|
|
}
|
|
|
|
// The first envelope was evicted to make room.
|
|
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
|
|
}
|
|
|
|
// MARK: - Expiry over time
|
|
|
|
@Test func expiredEnvelopesAreNotHandedOver() {
|
|
let clock = Clock(Self.baseDate)
|
|
let store = CourierStore(persistsToDisk: false, now: { clock.now })
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
|
|
|
|
clock.now = Self.baseDate.addingTimeInterval(7200)
|
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
|
}
|
|
|
|
// MARK: - Panic wipe
|
|
|
|
@Test func wipeDropsEverything() {
|
|
let store = makeStore()
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
|
|
store.wipe()
|
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
@Test func persistsAndReloadsAcrossInstances() throws {
|
|
// Isolated on-disk location so the test never touches the real store.
|
|
let fileURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
|
|
.appendingPathComponent("envelopes.json")
|
|
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
|
|
|
|
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
|
|
|
let recipientKey = Data(repeating: 0xE0, count: 32)
|
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
|
#expect(first.deposit(envelope, from: depositorA))
|
|
|
|
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
|
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
|
}
|
|
}
|