mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: creator-managed encrypted group chat over the mesh Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs: Protocol - MessageType.groupMessage = 0x25: broadcast packets with a cleartext 16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as AEAD AAD), inner Ed25519 sender signature over "bitchat-group-msg-v1"|groupID|messageID|timestamp|content - NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07: creator-signed group state (key, epoch, roster) 1:1 over Noise; signature over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise session peer must BE the creator - SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens 1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients ignore unknown bits and answer with types they know - PeerCapabilities.localSupported now advertises .groups Storage - GroupStore: symmetric keys in the keychain, roster/name/epoch as protected JSON in Application Support; wiped in panicClearAllData() Behavior - Non-members relay 0x25 like any broadcast but cannot read it; group messages join gossip-sync backfill with the public-message window - Receivers drop wrong-epoch envelopes, bad sender signatures, and senders missing from the creator-signed roster - Fire-and-flood delivery (no per-member acks in v1) UI - Groups open as chat windows through the private-chat sheet (virtual "group_" peer IDs); groups section in the people sheet; /group create/invite/remove/leave/list commands; invitees get a system message + notification and the group appears in their people sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes Addresses the Codex review and adversarial-review findings on #1383: - TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to 65535 and truncating, so an oversize group message fails to seal and surfaces send_failed rather than shipping ciphertext recipients drop. - Roster nicknames truncate on a Character boundary (never mid-scalar), so a multi-byte nickname can no longer make the whole signed roster undecodable. - Invites now bump the epoch (rotate the key) like removals, giving every roster change a strictly-increasing epoch so out-of-order invite states no longer last-writer-wins a just-added member back out. - Removing a member now sends them a creator-signed roster-without-them under a throwaway all-zero key (never the rotated key), so their client deactivates the group and surfaces "removed" instead of going silently dark. - /block is enforced in the group receive path: a blocked member's messages are dropped from display and notifications, consistent with every other inbound path. - Media affordances are disabled in group chats (both computed sites) so the composer can't strand a media placeholder that never sends; media-in-groups is a documented v2 item. - Creator signature now covers the group name and the sender signature covers the epoch (wire-format-affecting; needs Android parity before ship). - Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered receipts can never leak into group conversations under a future refactor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
301 lines
11 KiB
Swift
301 lines
11 KiB
Swift
//
|
|
// MeshDiagnosticsTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Tests for /ping and /trace command handling and the topology snapshot
|
|
// types backing the mesh topology map.
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import Foundation
|
|
import Testing
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
@Suite(.serialized)
|
|
struct MeshDiagnosticsTests {
|
|
|
|
// MARK: - Helpers
|
|
|
|
@MainActor
|
|
private func makeProcessor(
|
|
context: DiagnosticsMockContext,
|
|
transport: MockTransport
|
|
) -> CommandProcessor {
|
|
CommandProcessor(
|
|
contextProvider: context,
|
|
meshService: transport,
|
|
identityManager: MockIdentityManager(MockKeychain())
|
|
)
|
|
}
|
|
|
|
/// Waits for the async ping completion (MainActor hop) to land.
|
|
@MainActor
|
|
private func waitForCommandOutput(_ context: DiagnosticsMockContext) async {
|
|
for _ in 0..<100 {
|
|
if !context.commandOutputs.isEmpty { return }
|
|
await Task.yield()
|
|
try? await Task.sleep(nanoseconds: 5_000_000)
|
|
}
|
|
}
|
|
|
|
// MARK: - /ping
|
|
|
|
@MainActor
|
|
@Test func pingWithoutArgumentShowsUsage() {
|
|
let context = DiagnosticsMockContext()
|
|
let processor = makeProcessor(context: context, transport: MockTransport())
|
|
let result = processor.process("/ping")
|
|
switch result {
|
|
case .error(let message):
|
|
#expect(message == "usage: /ping <nickname>")
|
|
default:
|
|
Issue.record("Expected usage error")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Test func pingUnknownPeerFails() {
|
|
let context = DiagnosticsMockContext()
|
|
let processor = makeProcessor(context: context, transport: MockTransport())
|
|
let result = processor.process("/ping @ghost")
|
|
switch result {
|
|
case .error(let message):
|
|
#expect(message == "cannot ping ghost: not found on mesh")
|
|
default:
|
|
Issue.record("Expected error result")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Test func pingGeoDMPeerIsRejected() {
|
|
let context = DiagnosticsMockContext()
|
|
context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899")
|
|
let processor = makeProcessor(context: context, transport: MockTransport())
|
|
let result = processor.process("/ping @alice")
|
|
switch result {
|
|
case .error(let message):
|
|
#expect(message == "cannot ping alice: not found on mesh")
|
|
default:
|
|
Issue.record("Expected error for geo peer")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Test func pingSuccessReportsRttAndHops() async {
|
|
let context = DiagnosticsMockContext()
|
|
let peerID = PeerID(str: "abcd1234abcd1234")
|
|
context.nicknameToPeerID["alice"] = peerID
|
|
let transport = MockTransport()
|
|
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
|
|
let processor = makeProcessor(context: context, transport: transport)
|
|
|
|
let result = processor.process("/ping @alice")
|
|
switch result {
|
|
case .success(let message):
|
|
#expect(message == "pinging alice…")
|
|
default:
|
|
Issue.record("Expected immediate 'pinging' feedback")
|
|
}
|
|
#expect(transport.sentMeshPings == [peerID])
|
|
|
|
await waitForCommandOutput(context)
|
|
#expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"])
|
|
}
|
|
|
|
@MainActor
|
|
@Test func pingDirectPeerReportsSingleHop() async {
|
|
let context = DiagnosticsMockContext()
|
|
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
|
|
let transport = MockTransport()
|
|
transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1)
|
|
let processor = makeProcessor(context: context, transport: transport)
|
|
|
|
_ = processor.process("/ping alice")
|
|
await waitForCommandOutput(context)
|
|
#expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"])
|
|
}
|
|
|
|
@MainActor
|
|
@Test func pingTimeoutReportsNoReply() async {
|
|
let context = DiagnosticsMockContext()
|
|
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
|
|
let transport = MockTransport()
|
|
transport.meshPingResult = nil
|
|
let processor = makeProcessor(context: context, transport: transport)
|
|
|
|
_ = processor.process("/ping @alice")
|
|
await waitForCommandOutput(context)
|
|
#expect(context.commandOutputs == ["no reply from alice"])
|
|
}
|
|
|
|
@MainActor
|
|
@Test func pingOutputRoutesToConversationWhereCommandWasIssued() async {
|
|
let context = DiagnosticsMockContext()
|
|
let alice = PeerID(str: "abcd1234abcd1234")
|
|
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
|
context.nicknameToPeerID["alice"] = alice
|
|
let transport = MockTransport()
|
|
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
|
|
let processor = makeProcessor(context: context, transport: transport)
|
|
|
|
// Issue the ping from bob's DM, then switch chats before the async
|
|
// result lands. The output must follow the origin conversation, not
|
|
// whatever is selected at callback time.
|
|
context.selectedPrivateChatPeer = bob
|
|
_ = processor.process("/ping @alice")
|
|
context.selectedPrivateChatPeer = nil
|
|
|
|
await waitForCommandOutput(context)
|
|
#expect(context.commandOutputDestinations == [.privateChat(bob)])
|
|
}
|
|
|
|
@MainActor
|
|
@Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async {
|
|
let context = DiagnosticsMockContext()
|
|
let alice = PeerID(str: "abcd1234abcd1234")
|
|
context.nicknameToPeerID["alice"] = alice
|
|
let transport = MockTransport()
|
|
transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1)
|
|
let processor = makeProcessor(context: context, transport: transport)
|
|
|
|
_ = processor.process("/ping @alice")
|
|
// Opening a DM afterwards must not swallow the public-timeline result.
|
|
context.selectedPrivateChatPeer = alice
|
|
|
|
await waitForCommandOutput(context)
|
|
#expect(context.commandOutputDestinations == [.meshTimeline])
|
|
}
|
|
|
|
// MARK: - /trace
|
|
|
|
@MainActor
|
|
@Test func traceDirectPeerShowsOneHop() {
|
|
let context = DiagnosticsMockContext()
|
|
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
|
context.nicknameToPeerID["bob"] = bob
|
|
let transport = MockTransport()
|
|
transport.meshPaths[bob] = []
|
|
let processor = makeProcessor(context: context, transport: transport)
|
|
|
|
let result = processor.process("/trace @bob")
|
|
switch result {
|
|
case .success(let message):
|
|
#expect(message == "estimated path: you → bob (1 hop)")
|
|
default:
|
|
Issue.record("Expected success result")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Test func traceMultiHopUsesNicknamesWithShortIDFallback() {
|
|
let context = DiagnosticsMockContext()
|
|
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
|
let alice = PeerID(str: "a11cea11cea11cea")
|
|
let unknown = PeerID(str: "dead00beef001234")
|
|
context.nicknameToPeerID["bob"] = bob
|
|
let transport = MockTransport()
|
|
transport.peerNicknames = [alice: "alice"]
|
|
transport.meshPaths[bob] = [alice, unknown]
|
|
let processor = makeProcessor(context: context, transport: transport)
|
|
|
|
let result = processor.process("/trace bob")
|
|
switch result {
|
|
case .success(let message):
|
|
#expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)")
|
|
default:
|
|
Issue.record("Expected success result")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Test func traceWithoutPathReportsNoKnownPath() {
|
|
let context = DiagnosticsMockContext()
|
|
context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0")
|
|
let processor = makeProcessor(context: context, transport: MockTransport())
|
|
|
|
let result = processor.process("/trace @bob")
|
|
switch result {
|
|
case .success(let message):
|
|
#expect(message == "no known path to bob")
|
|
default:
|
|
Issue.record("Expected success result")
|
|
}
|
|
}
|
|
|
|
// MARK: - Topology snapshot types
|
|
|
|
@Test func topologyEdgeNormalizesEndpointOrder() {
|
|
let a = PeerID(str: "aaaa000000000000")
|
|
let b = PeerID(str: "bbbb000000000000")
|
|
#expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a))
|
|
#expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1)
|
|
}
|
|
|
|
@Test func topologyLayoutPlacesSelfInCenter() {
|
|
let nodes: [MeshTopologyDisplayModel.Node] = [
|
|
.init(id: "self", label: "me", isSelf: true),
|
|
.init(id: "a", label: "alice", isSelf: false),
|
|
.init(id: "b", label: "bob", isSelf: false)
|
|
]
|
|
let size = CGSize(width: 200, height: 200)
|
|
let positions = MeshTopologyView.layout(nodes: nodes, in: size)
|
|
|
|
#expect(positions["self"] == CGPoint(x: 100, y: 100))
|
|
#expect(positions.count == 3)
|
|
// Ring nodes sit on the same radius around the center.
|
|
let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100)
|
|
let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100)
|
|
#expect(abs(radiusA - radiusB) < 0.001)
|
|
#expect(radiusA > 0)
|
|
}
|
|
}
|
|
|
|
/// Minimal CommandContextProvider for diagnostics tests; records deferred
|
|
/// command output so async /ping results can be asserted.
|
|
@MainActor
|
|
private final class DiagnosticsMockContext: CommandContextProvider {
|
|
var nickname: String = "tester"
|
|
var activeChannel: ChannelID = .mesh
|
|
var selectedPrivateChatPeer: PeerID?
|
|
var blockedUsers: Set<String> = []
|
|
let idBridge = NostrIdentityBridge(keychain: MockKeychain())
|
|
|
|
var nicknameToPeerID: [String: PeerID] = [:]
|
|
private(set) var commandOutputs: [String] = []
|
|
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
|
|
|
|
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
|
nicknameToPeerID[nickname]
|
|
}
|
|
|
|
func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] }
|
|
func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil }
|
|
func startPrivateChat(with peerID: PeerID) {}
|
|
func sendPrivateMessage(_ content: String, to peerID: PeerID) {}
|
|
func clearCurrentPublicTimeline() {}
|
|
func clearPrivateChat(_ peerID: PeerID) {}
|
|
func sendPublicRaw(_ content: String) {}
|
|
func sendPublicMessage(_ content: String) {}
|
|
func groupCreate(named name: String) -> CommandResult { .handled }
|
|
func groupInvite(nickname: String) -> CommandResult { .handled }
|
|
func groupRemove(nickname: String) -> CommandResult { .handled }
|
|
func groupLeave() -> CommandResult { .handled }
|
|
func groupList() -> CommandResult { .handled }
|
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
|
|
func addPublicSystemMessage(_ content: String) {}
|
|
func toggleFavorite(peerID: PeerID) {}
|
|
|
|
func currentCommandDestination() -> CommandOutputDestination {
|
|
if let peerID = selectedPrivateChatPeer {
|
|
return .privateChat(peerID)
|
|
}
|
|
return .meshTimeline
|
|
}
|
|
|
|
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
|
|
commandOutputs.append(content)
|
|
commandOutputDestinations.append(destination)
|
|
}
|
|
}
|