Files
nucleic/Tests/NucleicCoreTests/MeshRosterMergeTests.swift
T

178 lines
9.8 KiB
Swift

import Foundation
import Testing
import NucleicProtocol
@testable import NucleicCore
/// Mesh "join": the roster-merge policy — auto-trust an introduced member, reject a forged
/// `(deviceID, key)` pair, never overwrite a pin from gossip, and honor the revocation-tombstone
/// ordering. Pure store-level, so these are hermetic.
@Suite struct MeshRosterMergeTests {
private let selfID = "self-host-id"
/// A self-consistent mac member (`deviceID == sha256(staticPublicKey)`), like a real host.
private func macMember(
label: String, pairedAt: Date, addresses: PeerAddresses? = nil
) -> (member: MeshMember, identity: DeviceIdentity) {
let identity = DeviceIdentity()
let member = MeshMember(
deviceID: identity.hostID, label: label, kind: .mac, capabilities: .hostAndAgents,
staticPublicKey: identity.staticPublicKey, addresses: addresses, pairedAt: pairedAt)
return (member, identity)
}
@Test func autoPinsIntroducedMacMember() async throws {
let store = InMemoryPairedDeviceStore()
let (c, cIdentity) = macMember(
label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000),
addresses: PeerAddresses(lanHint: "c.local:5000"))
let changed = await MeshRosterMerge.apply(
MeshRosterPush(members: [c]), into: store, selfDeviceID: selfID)
#expect(changed)
let pinned = await store.device(cIdentity.hostID)
#expect(pinned?.kind == .mac)
#expect(pinned?.scope == .control)
#expect(pinned?.staticPublicKey == cIdentity.staticPublicKey)
#expect(pinned?.pairedAt == Date(timeIntervalSince1970: 1_000)) // origin birth preserved
#expect(pinned?.addresses?.lanHint == "c.local:5000")
}
/// A record whose `deviceID` isn't `sha256(staticPublicKey)` can't be a real mac identity —
/// reject it, so a compromised member can't inject a mac with a key it controls.
@Test func rejectsForgedMacMember() async throws {
let store = InMemoryPairedDeviceStore()
let forged = MeshMember(
deviceID: "not-a-real-hash", label: "Evil", kind: .mac, capabilities: .hostAndAgents,
staticPublicKey: Data(repeating: 0x09, count: 32), pairedAt: Date())
let changed = await MeshRosterMerge.apply(
MeshRosterPush(members: [forged]), into: store, selfDeviceID: selfID)
#expect(changed == false)
#expect(await store.device("not-a-real-hash") == nil)
}
/// A phone member (id isn't a key hash) is pinned without the hostID check — phones are visible
/// members, and a Mac never dials them (`reconcile` filters on `canHost`).
@Test func pinsPhoneMemberWithoutIntegrityCheck() async throws {
let store = InMemoryPairedDeviceStore()
let phone = MeshMember(
deviceID: "iphone-abc", label: "iPhone", kind: .iphone,
staticPublicKey: Data(repeating: 0x02, count: 32), pairedAt: Date())
_ = await MeshRosterMerge.apply(
MeshRosterPush(members: [phone]), into: store, selfDeviceID: selfID)
#expect(await store.device("iphone-abc")?.kind == .iphone)
}
/// A key change from gossip must never overwrite an existing pin — a genuine rotation needs a
/// fresh scan. Otherwise one bad push could lock you out of a peer forever.
@Test func neverOverwritesExistingPinFromGossip() async throws {
let store = InMemoryPairedDeviceStore()
let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
await store.upsert(PairedDevice(
deviceID: cIdentity.hostID, label: "Mac C", staticPublicKey: cIdentity.staticPublicKey,
scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000), kind: .mac,
capabilities: .hostAndAgents))
// Same deviceID, a *different* key — a forged rotation.
let rotated = MeshMember(
deviceID: cIdentity.hostID, label: "Hijack", kind: .mac, capabilities: .hostAndAgents,
staticPublicKey: Data(repeating: 0xFF, count: 32), pairedAt: Date(timeIntervalSince1970: 2_000))
_ = await MeshRosterMerge.apply(
MeshRosterPush(members: [rotated]), into: store, selfDeviceID: selfID)
// Pin unchanged; the hijack label didn't take either.
#expect(await store.device(cIdentity.hostID)?.staticPublicKey == cIdentity.staticPublicKey)
#expect(await store.device(cIdentity.hostID)?.label == "Mac C")
_ = c
}
@Test func refreshesKnownMemberMetadata() async throws {
let store = InMemoryPairedDeviceStore()
let (c, cIdentity) = macMember(
label: "New Name", pairedAt: Date(timeIntervalSince1970: 1_000),
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 5_000)))
await store.upsert(PairedDevice(
deviceID: cIdentity.hostID, label: "Old Name", staticPublicKey: cIdentity.staticPublicKey,
scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000), kind: .mac,
capabilities: .hostAndAgents,
addresses: PeerAddresses(lanHint: "c.local:1", updatedAt: Date(timeIntervalSince1970: 1_000))))
let changed = await MeshRosterMerge.apply(
MeshRosterPush(members: [c]), into: store, selfDeviceID: selfID)
#expect(changed)
#expect(await store.device(cIdentity.hostID)?.label == "New Name")
#expect(await store.device(cIdentity.hostID)?.addresses?.lanHint == "c.local:6000")
}
/// A tombstone suppresses a member whose membership predates the revocation — it stays dead and
/// isn't re-pinned by a stale gossip.
@Test func tombstoneDominatesStaleMember() async throws {
let store = InMemoryPairedDeviceStore()
let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
let push = MeshRosterPush(
members: [c],
tombstones: [MeshTombstone(deviceID: cIdentity.hostID, revokedAt: Date(timeIntervalSince1970: 2_000))])
_ = await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID)
#expect(await store.device(cIdentity.hostID) == nil)
#expect(await store.isTombstoned(cIdentity.hostID))
}
/// A deliberate re-join (member paired *after* the revocation) beats the tombstone and re-enters.
@Test func rePairedMemberBeatsTombstone() async throws {
let store = InMemoryPairedDeviceStore()
let (c, cIdentity) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 3_000))
let push = MeshRosterPush(
members: [c],
tombstones: [MeshTombstone(deviceID: cIdentity.hostID, revokedAt: Date(timeIntervalSince1970: 2_000))])
_ = await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID)
#expect(await store.device(cIdentity.hostID) != nil)
#expect(await store.isTombstoned(cIdentity.hostID) == false) // stale tombstone cleared
}
@Test func skipsSelfMemberAndSelfTombstone() async throws {
let store = InMemoryPairedDeviceStore()
let selfMember = MeshMember(
deviceID: selfID, label: "Me", kind: .mac, capabilities: .hostAndAgents,
staticPublicKey: Data(repeating: 0x03, count: 32), pairedAt: Date())
_ = await MeshRosterMerge.apply(
MeshRosterPush(
members: [selfMember],
tombstones: [MeshTombstone(deviceID: selfID, revokedAt: Date())]),
into: store, selfDeviceID: selfID)
#expect(await store.device(selfID) == nil)
#expect(await store.isTombstoned(selfID) == false) // we never tombstone ourselves
}
/// Freshness bookkeeping alone — a `lastSeenAt` bump (every hello anywhere produces one) or an
/// `addresses.updatedAt` restamp with identical dial fields — is persisted silently but reports
/// NO change: counting it re-gossiped the roster mesh-wide per hello and echoed between members.
@Test func freshnessOnlyUpdateIsPersistedButNotAChange() async throws {
let store = InMemoryPairedDeviceStore()
let identity = DeviceIdentity()
await store.upsert(PairedDevice(
deviceID: identity.hostID, label: "Mac C", staticPublicKey: identity.staticPublicKey,
scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000),
lastSeenAt: Date(timeIntervalSince1970: 2_000), kind: .mac,
capabilities: .hostAndAgents,
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 2_000))))
let member = MeshMember(
deviceID: identity.hostID, label: "Mac C", kind: .mac, capabilities: .hostAndAgents,
staticPublicKey: identity.staticPublicKey,
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 9_000)),
pairedAt: Date(timeIntervalSince1970: 1_000),
lastSeenAt: Date(timeIntervalSince1970: 9_000))
let changed = await MeshRosterMerge.apply(
MeshRosterPush(members: [member]), into: store, selfDeviceID: selfID)
#expect(changed == false)
// …but the freshness metadata still landed.
#expect(await store.device(identity.hostID)?.lastSeenAt == Date(timeIntervalSince1970: 9_000))
}
/// Re-applying the same roster reports no change — the idempotence the gossip loop relies on to
/// converge instead of ping-ponging.
@Test func reMergeIsIdempotent() async throws {
let store = InMemoryPairedDeviceStore()
let (c, _) = macMember(label: "Mac C", pairedAt: Date(timeIntervalSince1970: 1_000))
let push = MeshRosterPush(members: [c])
#expect(await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID))
#expect(await MeshRosterMerge.apply(push, into: store, selfDeviceID: selfID) == false)
}
}