mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Add geohash bulletin board: persistent signed notices over mesh sync
New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.
Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.
UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// BoardPacketsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BoardPacketsTests {
|
||||
|
||||
private let authorKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
private func makeSignedPost(
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "water point at the north gate",
|
||||
nickname: String = "ranger",
|
||||
createdAt: UInt64 = 1_700_000_000_000,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000,
|
||||
flags: UInt8 = 0,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardPostPacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
let signature = try signer.signature(for: signingBytes)
|
||||
return BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSignedTombstone(
|
||||
postID: Data,
|
||||
deletedAt: UInt64 = 1_700_000_100_000,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardTombstonePacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt))
|
||||
return BoardTombstonePacket(
|
||||
postID: postID,
|
||||
authorSigningKey: publicKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Round trips
|
||||
|
||||
@Test func postRoundTrip() throws {
|
||||
let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
guard case .post(let roundTripped) = decoded else {
|
||||
Issue.record("expected a post")
|
||||
return
|
||||
}
|
||||
#expect(roundTripped.isUrgent)
|
||||
#expect(roundTripped.geohash == "9q8yy")
|
||||
}
|
||||
|
||||
@Test func meshLocalPostRoundTrip() throws {
|
||||
let post = try makeSignedPost(geohash: "")
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode()))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tombstoneRoundTrip() throws {
|
||||
let post = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: post.postID)
|
||||
let encoded = BoardWire.tombstone(tombstone).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .tombstone(tombstone))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Signature verification
|
||||
|
||||
@Test func forgedPostSignatureFailsVerification() throws {
|
||||
// Signed by an attacker's key but claiming the victim's key as author.
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let victim = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tamperedContentFailsVerification() throws {
|
||||
let post = try makeSignedPost(content: "meet at noon")
|
||||
let tampered = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: "meet at midnight",
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.createdAt,
|
||||
expiresAt: post.expiresAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func forgedTombstoneSignatureFailsVerification() throws {
|
||||
let post = try makeSignedPost()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedTombstone(
|
||||
postID: post.postID,
|
||||
signWith: attacker,
|
||||
claimKey: post.authorSigningKey
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Decode validation
|
||||
|
||||
@Test func rejectsExpiryBeyondSevenDays() throws {
|
||||
let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil)
|
||||
|
||||
let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsExpiryBeforeCreation() throws {
|
||||
let post = try makeSignedPost()
|
||||
let inverted = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: post.content,
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.expiresAt,
|
||||
expiresAt: post.createdAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsOversizedContent() throws {
|
||||
let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil)
|
||||
|
||||
let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsInvalidGeohashCharacters() throws {
|
||||
let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32
|
||||
#expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func toleratesUnknownTLVs() throws {
|
||||
let post = try makeSignedPost()
|
||||
var encoded = BoardWire.post(post).encode()
|
||||
// Append an unknown TLV; decoders must skip it.
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func rejectsTruncatedPayload() throws {
|
||||
let post = try makeSignedPost()
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
#expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Urgent flag peek
|
||||
|
||||
@Test func urgentFlagPeekMatchesFullDecode() throws {
|
||||
let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let calm = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: calm.postID)
|
||||
#expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: Data()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//
|
||||
// BoardStoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// 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
|
||||
|
||||
struct BoardStoreTests {
|
||||
|
||||
private final class MutableClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
init(now: Date) { self.now = now }
|
||||
}
|
||||
|
||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
||||
|
||||
private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore {
|
||||
BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now })
|
||||
}
|
||||
|
||||
private func tempFileURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("board-store-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
private func makePost(
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "note",
|
||||
createdAt: UInt64,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) {
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let key = author.publicKey.rawRepresentation
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0
|
||||
)
|
||||
let post = BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0,
|
||||
signature: try author.signature(for: signingBytes)
|
||||
)
|
||||
let wire = BoardWire.post(post)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post)
|
||||
}
|
||||
|
||||
private func makeTombstone(
|
||||
for post: BoardPostPacket,
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
deletedAt: UInt64,
|
||||
claimKey: Data? = nil
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket) {
|
||||
let tombstone = BoardTombstonePacket(
|
||||
postID: post.postID,
|
||||
authorSigningKey: claimKey ?? author.publicKey.rawRepresentation,
|
||||
deletedAt: deletedAt,
|
||||
signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt))
|
||||
)
|
||||
let wire = BoardWire.tombstone(tombstone)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt))
|
||||
}
|
||||
|
||||
private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Ingest basics
|
||||
|
||||
@Test func ingestStoresAndDeduplicates() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
#expect(store.posts(forGeohash: "").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsAlreadyExpiredPost() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Caps and eviction
|
||||
|
||||
@Test func perAuthorCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
var oldestID: Data?
|
||||
for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPostsPerAuthor)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
@Test func globalCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
|
||||
var oldestID: Data?
|
||||
var author = Curve25519.Signing.PrivateKey()
|
||||
for index in 0..<(BoardStore.Limits.maxPosts + 1) {
|
||||
if index % BoardStore.Limits.maxPostsPerAuthor == 0 {
|
||||
author = Curve25519.Signing.PrivateKey()
|
||||
}
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPosts)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
// MARK: - Expiry sweep
|
||||
|
||||
@Test func expiredPostsAreSwept() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000)
|
||||
let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000)
|
||||
store.ingest(shortLived.wire, packet: shortLived.packet)
|
||||
store.ingest(longLived.wire, packet: longLived.packet)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 2)
|
||||
|
||||
clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later
|
||||
let remaining = store.posts(forGeohash: "9q8yy")
|
||||
#expect(remaining.count == 1)
|
||||
#expect(remaining.first?.postID == longLived.post.postID)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Tombstones
|
||||
|
||||
@Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
|
||||
// Post is gone, tombstone still syncs so the delete propagates.
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
|
||||
// Replayed copy of the deleted post is refused.
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
|
||||
// After the post's original expiry the tombstone is dropped too.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func tombstoneFromWrongKeyIsRejected() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
// Attacker signs with their own key (self-consistent wire, so it
|
||||
// passes signature verification) but targets the victim's post.
|
||||
let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(forged.wire, packet: forged.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
}
|
||||
|
||||
@Test func tombstoneArrivingBeforePostSuppressesIt() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Persistence and wipe
|
||||
|
||||
@Test func persistsAcrossRestart() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let first = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
let deleted = try makePost(author: author, createdAt: baseMs + 1)
|
||||
first.ingest(entry.wire, packet: entry.packet)
|
||||
first.ingest(deleted.wire, packet: deleted.packet)
|
||||
let tombstone = try makeTombstone(for: deleted.post, author: author, deletedAt: baseMs + 1000)
|
||||
first.ingest(tombstone.wire, packet: tombstone.packet)
|
||||
|
||||
let second = makeStore(clock: clock, fileURL: fileURL)
|
||||
let restored = second.posts(forGeohash: "9q8yy")
|
||||
#expect(restored.count == 1)
|
||||
#expect(restored.first?.postID == entry.post.postID)
|
||||
// Post + tombstone both restored into sync.
|
||||
#expect(second.syncCandidates().count == 2)
|
||||
|
||||
// Restart after expiry drops everything.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
let third = makeStore(clock: clock, fileURL: fileURL)
|
||||
#expect(third.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(third.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func wipeClearsMemoryAndDisk() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let store = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
|
||||
store.wipe()
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// GossipSyncBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// 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
|
||||
|
||||
/// Board posts ride gossip sync through a provider that queries the board
|
||||
/// store, so retention (expiry, tombstones, caps) has a single owner.
|
||||
struct GossipSyncBoardTests {
|
||||
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: try #require(Data(hexString: "aabbccddeeff0011")),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0x42]),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
private func quietConfig() -> GossipSyncManager.Config {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
return config
|
||||
}
|
||||
|
||||
@Test func boardRequestIsServedFromProvider() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||
#expect(sent.isRSR)
|
||||
}
|
||||
|
||||
@Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
// Follow with a board request; only its response should arrive, which
|
||||
// also proves the first request produced nothing.
|
||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
#expect(delegate.packets.count == 1)
|
||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||
}
|
||||
|
||||
@Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
|
||||
var config = quietConfig()
|
||||
config.boardSyncIntervalSeconds = 1
|
||||
|
||||
// Without a provider the board schedule stays silent.
|
||||
let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let unwiredDelegate = RecordingBoardDelegate()
|
||||
unwired.delegate = unwiredDelegate
|
||||
unwired._performMaintenanceSynchronously(now: Date())
|
||||
#expect(unwiredDelegate.packets.isEmpty)
|
||||
|
||||
// With a provider, maintenance sends a board-typed request.
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
manager.boardPacketsProvider = { return [] }
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
#expect(delegate.packets.count == 1)
|
||||
let payload = try #require(delegate.packets.first?.payload)
|
||||
let request = try #require(RequestSyncPacket.decode(from: payload))
|
||||
#expect(request.types == .board)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
|
||||
private let lock = NSLock()
|
||||
private var _packets: [BitchatPacket] = []
|
||||
|
||||
var packets: [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _packets
|
||||
}
|
||||
|
||||
func sendPacket(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// SyncTypeFlagsBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// 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
|
||||
|
||||
/// The board sync flag is the first bit outside the original single byte of
|
||||
/// type flags. These tests pin down the wire compatibility contract: the
|
||||
/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
|
||||
/// since type-aware sync, so widening to two bytes must decode everywhere
|
||||
/// and unknown bits must be ignored, not rejected.
|
||||
struct SyncTypeFlagsBoardTests {
|
||||
|
||||
@Test func boardFlagEncodesIntoSecondByte() throws {
|
||||
let data = try #require(SyncTypeFlags.board.toData())
|
||||
// Little-endian: low byte first, board bit (bit 8) in byte 2.
|
||||
#expect(data == Data([0x00, 0x01]))
|
||||
}
|
||||
|
||||
@Test func boardFlagRoundTrips() throws {
|
||||
let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
|
||||
let data = try #require(flags.toData())
|
||||
let decoded = try #require(SyncTypeFlags.decode(data))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(decoded.contains(.boardPost))
|
||||
#expect(!decoded.contains(.fragment))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
|
||||
}
|
||||
|
||||
/// An old decoder is modeled by bits it has no mapping for: the shared
|
||||
/// decode path accepts the bytes and simply maps unknown bits to no
|
||||
/// message type, so a board-only request reads as "nothing I can serve".
|
||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
|
||||
// must decode without error and yield no known types.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
|
||||
#expect(decoded.toMessageTypes().isEmpty)
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
||||
#expect(!decoded.contains(type))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
||||
// Known low-byte flags survive alongside unknown high bits.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketRoundTripsBoardFlag() throws {
|
||||
let request = RequestSyncPacket(
|
||||
p: 4,
|
||||
m: 128,
|
||||
data: Data([0xAB, 0xCD]),
|
||||
types: SyncTypeFlags(messageTypes: [.boardPost])
|
||||
)
|
||||
let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
|
||||
let types = try #require(decoded.types)
|
||||
#expect(types.contains(.boardPost))
|
||||
#expect(!types.contains(.message))
|
||||
}
|
||||
|
||||
@Test func singleByteLegacyEncodingStillDecodes() throws {
|
||||
// Requests from old clients keep the one-byte bitfield.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(!decoded.contains(.boardPost))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user