tv-anarchy/Tests/TVAnarchyCoreTests/MeshJoinTests.swift

152 lines
6.8 KiB
Swift
Raw Normal View History

import XCTest
@testable import TVAnarchyCore
final class MeshJoinTests: XCTestCase {
// MARK: keypair CryptoKit must be byte-compatible with `wg genkey`/`wg pubkey`
/// RFC 7748 §6.1 vector (Alice), cross-checked against `wg pubkey`.
func testKeypairMatchesWireGuardVector() {
let kp = WireGuardKeypair(privateKeyBase64: "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=")
XCTAssertEqual(kp?.publicKey, "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=")
}
func testKeypairRejectsGarbage() {
XCTAssertNil(WireGuardKeypair(privateKeyBase64: "not-base64!"))
XCTAssertNil(WireGuardKeypair(privateKeyBase64: "c2hvcnQ=")) // valid base64, wrong length
}
func testFreshKeypairRoundTrips() {
let kp = WireGuardKeypair()
XCTAssertEqual(WireGuardKeypair(privateKeyBase64: kp.privateKey)?.publicKey, kp.publicKey)
}
// MARK: device names same rule as wg-phone-add
func testDeviceNameValidation() {
XCTAssertTrue(MeshJoin.isValidDeviceName("phone-rachel"))
XCTAssertTrue(MeshJoin.isValidDeviceName("ipad_quinn2"))
XCTAssertFalse(MeshJoin.isValidDeviceName(""))
XCTAssertFalse(MeshJoin.isValidDeviceName("bad name"))
XCTAssertFalse(MeshJoin.isValidDeviceName("phone/../../etc"))
XCTAssertFalse(MeshJoin.isValidDeviceName("phöne"))
}
// MARK: address allocation lowest free .5+, fixed hosts never handed out
func testFreeAddressSkipsUsedSlots() {
let hubConf = """
[Interface]
Address = 10.9.0.1/24
[Peer]
AllowedIPs = 10.9.0.2/32
[Peer]
AllowedIPs = 10.9.0.5/32
# mentions 10.9.0.6 in a comment too
AllowedIPs = 10.9.0.6/32
"""
XCTAssertEqual(MeshJoin.freeAddress(hubConfigText: hubConf), "10.9.0.7")
}
func testFreeAddressStartsAtFive() {
XCTAssertEqual(MeshJoin.freeAddress(hubConfigText: "Address = 10.9.0.1/24"), "10.9.0.5")
}
func testFreeAddressFullSubnet() {
let all = (1...254).map { "10.9.0.\($0)" }.joined(separator: "\n")
XCTAssertNil(MeshJoin.freeAddress(hubConfigText: all))
}
// MARK: config rendering
func testClientConfigShape() {
let conf = MeshJoin.clientConfig(device: "phone-rachel", privateKey: "PRIV", address: "10.9.0.6")
XCTAssertTrue(conf.contains("PrivateKey = PRIV"))
XCTAssertTrue(conf.contains("Address = 10.9.0.6/32"))
XCTAssertTrue(conf.contains("DNS = \(MeshDefaults.meshDNS)"))
XCTAssertTrue(conf.contains("PublicKey = \(MeshDefaults.hubPublicKey)"))
XCTAssertTrue(conf.contains("Endpoint = \(MeshDefaults.hubEndpoint)"))
XCTAssertTrue(conf.contains("AllowedIPs = \(MeshDefaults.meshSubnet), \(MeshDefaults.lanSubnet)"))
}
/// The hub-side probe greps for the exact line `PublicKey = <key>` the
/// rendered block must keep that byte shape or idempotency breaks (both for
/// the app re-running and for wg-phone-add seeing app-enrolled peers).
func testHubPeerBlockShape() {
let block = MeshJoin.hubPeerBlock(device: "phone-rachel", publicKey: "PUB", address: "10.9.0.6")
XCTAssertTrue(block.contains("\nPublicKey = PUB\n"))
XCTAssertTrue(block.contains("\n# phone-rachel\n"))
XCTAssertTrue(block.contains("\nAllowedIPs = 10.9.0.6/32\n"))
XCTAssertTrue(block.hasPrefix("\n[Peer]\n"))
}
// MARK: phone-setup payload (QR 2 parsed by the iOS app's JoinPayload)
func testPhoneSetupURLShape() {
let url = MeshJoin.phoneSetupURL(lanHost: "10.0.0.42", meshHost: "10.9.0.3", port: 8787)
XCTAssertEqual(url?.scheme, "tvanarchy")
XCTAssertEqual(url?.host, "bridge")
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(items?.first { $0.name == "host" }?.value, "10.0.0.42")
XCTAssertEqual(items?.first { $0.name == "fallback" }?.value, "10.9.0.3")
XCTAssertEqual(items?.first { $0.name == "port" }?.value, "8787")
}
func testPhoneSetupURLMeshOnlyAndEmpty() {
// Mesh-only Mac: mesh becomes the primary, no redundant fallback.
let meshOnly = MeshJoin.phoneSetupURL(lanHost: nil, meshHost: "10.9.0.3")
let items = URLComponents(url: meshOnly!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(items?.first { $0.name == "host" }?.value, "10.9.0.3")
XCTAssertNil(items?.first { $0.name == "fallback" })
// No addresses at all nothing to advertise.
XCTAssertNil(MeshJoin.phoneSetupURL(lanHost: nil, meshHost: nil))
}
// MARK: store wg-phone-add-compatible layout, injectable root
func testStoreRoundTripAndLayout() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("mesh-store-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let store = MeshClientStore(root: root)
let kp = WireGuardKeypair()
let client = MeshClient(device: "phone-rachel", privateKey: kp.privateKey,
publicKey: kp.publicKey, address: "10.9.0.6")
try store.save(client)
// Same file layout wg-phone-add reads/writes.
let dir = root.appendingPathComponent("phone-rachel")
for file in ["private.key", "public.key", "address", "phone-rachel.conf"] {
XCTAssertTrue(FileManager.default.fileExists(atPath: dir.appendingPathComponent(file).path), file)
}
// Key material is locked down.
let perms = try FileManager.default.attributesOfItem(
atPath: dir.appendingPathComponent("private.key").path)[.posixPermissions] as? Int
XCTAssertEqual(perms, 0o600)
XCTAssertEqual(store.list(), [client])
XCTAssertEqual(store.keypair(device: "phone-rachel"), kp)
}
func testStoreSkipsHalfEnrolledClients() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("mesh-store-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let dir = root.appendingPathComponent("broken")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try WireGuardKeypair().privateKey.write(
to: dir.appendingPathComponent("private.key"), atomically: true, encoding: .utf8)
// No address file not listable, but the keypair is still reusable.
let store = MeshClientStore(root: root)
XCTAssertTrue(store.list().isEmpty)
XCTAssertNotNil(store.keypair(device: "broken"))
}
func testStoreEmptyRootIsEmpty() {
let store = MeshClientStore(root: FileManager.default.temporaryDirectory
.appendingPathComponent("mesh-store-missing-\(UUID().uuidString)"))
XCTAssertTrue(store.list().isEmpty)
}
}