Files
bitchat/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift
T
f9032cf2b9 Add mesh diagnostics: /ping, /trace, and topology map (#1377)
* Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing

Three fixes for PR #1377 review:

1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
   panicClearAllData / clearCurrentPublicTimeline detached utility-priority
   tasks delete the real ~/Library/Application Support/files tree, which the
   test process shares. The wipe fires at a nondeterministic time and raced
   the sendImage test's JPEG in files/images/outgoing (write then re-read),
   so prepareImagePacket threw and the test timed out. Both wipes are now
   skipped under tests (existing TestEnvironment.isRunningTests pattern);
   this also stops test runs from deleting the developer's real media.

2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
   packet.senderID let one connected peer rotate forged sender IDs to bypass
   the 5-per-10s budget. The limiter now keys on the ingress link (the
   directly connected peer that delivered the packet); the pong still goes
   to the claimed sender. Regression test proves rotating senders over one
   link exhaust one budget (fails 10 vs 5 pongs on the old code).

3. Codex P2: /ping output arrived up to 10s later and was routed from
   selectedPrivateChatPeer at callback time, misrouting the result after a
   chat switch. The origin conversation is now captured when the command is
   issued (CommandOutputDestination) and deferred output is routed there:
   a DM result lands in the origin chat's history even if deselected, and a
   mesh-timeline result pins to #mesh instead of the active channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Conform DiagnosticsMockContext to sendPublicMessage

CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after
this branch forked, so the diagnostics test mock no longer conformed once
main was merged in. Add the no-op stub.

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>
2026-07-07 15:08:22 +02:00

71 lines
2.5 KiB
Swift

//
// MeshPingPayloadTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct MeshPingPayloadTests {
@Test func encodeDecodeRoundTrip() throws {
let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF])
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
let encoded = payload.encode()
#expect(encoded.count == 9)
#expect(encoded.prefix(8) == nonce)
#expect(encoded.last == 7)
let decoded = try #require(MeshPingPayload.decode(encoded))
#expect(decoded == payload)
}
@Test func decodeToleratesTrailingBytes() throws {
let nonce = Data(repeating: 0x42, count: 8)
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3))
var extended = payload.encode()
extended.append(contentsOf: [0xDE, 0xAD])
let decoded = try #require(MeshPingPayload.decode(extended))
#expect(decoded == payload)
}
@Test func decodeRespectsSliceIndices() throws {
// Data slices keep their parent's indices; decoding must not assume
// startIndex == 0.
let nonce = Data(repeating: 0x11, count: 8)
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5))
let framed = Data([0x00, 0x00]) + payload.encode()
let slice = framed.dropFirst(2)
let decoded = try #require(MeshPingPayload.decode(slice))
#expect(decoded == payload)
}
@Test func rejectsTruncatedPayload() {
#expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil)
#expect(MeshPingPayload.decode(Data()) == nil)
}
@Test func rejectsWrongNonceLength() {
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil)
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil)
}
@Test func hopCountMath() {
// Direct link: no TTL decrement, one hop.
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1)
// One relay in between: two hops.
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2)
// Full TTL consumed.
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7)
// Inconsistent TTLs (received above origin) are rejected.
#expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil)
}
}