152 lines
6.8 KiB
Swift
152 lines
6.8 KiB
Swift
|
|
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)
|
||
|
|
}
|
||
|
|
}
|