import CryptoKit import XCTest @testable import MultiSimAdmin /// The upgrade path from a node list written before relay support existed. /// /// This is the riskiest change in the iOS work: `Instance.origin` was non-optional and carried three /// responsibilities at once, and a decoder that mishandles the old shape does not fail one screen — /// it fails the whole store, and every node the user ever added disappears. So the old JSON is /// pinned literally here rather than produced by an encoder that might itself have drifted. final class InstanceEndpointMigrationTests: XCTestCase { private var location: URL! override func setUp() { super.setUp() location = FileManager.default.temporaryDirectory .appendingPathComponent("msa-migration-\(UUID().uuidString).json") } override func tearDown() { try? FileManager.default.removeItem(at: location) super.tearDown() } /// Exactly the shape the previous release wrote: `origin` as a string, no `endpoint` key. private func legacyStore() -> Data { Data(""" { "instances" : [ { "configRevision" : 3, "createdAt" : "2026-08-01T10:00:00Z", "credentialConfigured" : true, "id" : "11111111-2222-4333-8444-555555555555", "name" : "Lab Alpha", "origin" : "http://192.168.3.55:3000", "tags" : ["lab"], "updatedAt" : "2026-08-02T10:00:00Z" } ] } """.utf8) } func testLegacyOriginBecomesADirectEndpoint() async throws { try legacyStore().write(to: location) let repository = FileInstanceRepository(location: location) let instances = try await repository.list(InstanceQuery()) XCTAssertEqual(instances.count, 1, "a legacy store must not lose nodes") let instance = try XCTUnwrap(instances.first) XCTAssertEqual(instance.endpoint, .direct(URL(string: "http://192.168.3.55:3000")!)) XCTAssertEqual(instance.endpoint.directOrigin?.absoluteString, "http://192.168.3.55:3000") XCTAssertFalse(instance.endpoint.isRelay) // Everything else must survive the migration untouched. XCTAssertEqual(instance.name, "Lab Alpha") XCTAssertEqual(instance.tags, ["lab"]) XCTAssertEqual(instance.configRevision, 3) XCTAssertTrue(instance.credentialConfigured) } /// An upgraded node must not be reported as a duplicate of itself, which is what would happen if /// the dedup key changed shape between the read and the write. func testAnUpgradedLegacyNodeIsNotADuplicateOfItself() async throws { try legacyStore().write(to: location) let repository = FileInstanceRepository(location: location) let listed = try await repository.list(InstanceQuery()) let existing = try XCTUnwrap(listed.first) // Re-saving the same address must still be refused... do { _ = try await repository.create( InstanceInput(name: "Again", origin: URL(string: "http://192.168.3.55:3000")!) ) XCTFail("a duplicate origin was accepted") } catch let error as RepositoryError { XCTAssertEqual(error, .duplicateOrigin) } // ...but updating the node itself must succeed. let updated = try await repository.update( existing.id, expectedRevision: existing.configRevision, InstancePatch(name: "Renamed") ) XCTAssertEqual(updated.name, "Renamed") XCTAssertEqual(updated.endpoint, existing.endpoint) } /// After any write, the file carries the new shape and no `origin` key. Leaving both would make /// the old field a second source of truth for the same address. func testWritesEmitOnlyTheNewShape() async throws { try legacyStore().write(to: location) let repository = FileInstanceRepository(location: location) let listed = try await repository.list(InstanceQuery()) let existing = try XCTUnwrap(listed.first) _ = try await repository.update( existing.id, expectedRevision: existing.configRevision, InstancePatch(tags: ["lab", "primary"]) ) let raw = try String(contentsOf: location, encoding: .utf8) XCTAssertTrue(raw.contains("\"endpoint\""), "the new shape must be written") XCTAssertFalse(raw.contains("\"origin\""), "the legacy key must not be written back") } /// The two dedup keyspaces must not collide: a direct node at some address and a relay node are /// different nodes even if a hostname happened to match. func testRelayAndDirectEndpointsAreDistinctIdentities() async throws { let repository = FileInstanceRepository(location: location) _ = try await repository.create( InstanceInput(name: "Direct", origin: URL(string: "https://relay.example")!) ) let relay = try await repository.create(InstanceInput( name: "Relayed", endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e")) )) XCTAssertEqual(relay.endpoint.dedupKey, "relay:nd_7f3a9c2e") let all = try await repository.list(InstanceQuery()) XCTAssertEqual(all.count, 2) // The same node id from the same relay is the same instance, so it is refused. do { _ = try await repository.create(InstanceInput( name: "Again", endpoint: .relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e")) )) XCTFail("the same relay node was added twice") } catch let error as RepositoryError { XCTAssertEqual(error, .duplicateOrigin) } } /// A half-filled relay endpoint must be refused at the repository rather than accepted and then /// failing in the transport as an unreadable response, which is indistinguishable to the user /// from "the node is broken". func testAnIncompleteRelayEndpointIsRefused() async throws { let repository = FileInstanceRepository(location: location) var cases: [RelayEndpoint] = [] var wrongKeySize = Self.relayEndpoint(nodeID: "nd_7f3a9c2e") wrongKeySize.agentSigningKey = Data(repeating: 1, count: 31) cases.append(wrongKeySize) var plainHTTP = Self.relayEndpoint(nodeID: "nd_7f3a9c2e") plainHTTP.relayBaseURL = URL(string: "http://relay.example")! cases.append(plainHTTP) var badNodeID = Self.relayEndpoint(nodeID: "node-1") badNodeID.nodeID = "node-1" cases.append(badNodeID) var badDeviceID = Self.relayEndpoint(nodeID: "nd_7f3a9c2e") badDeviceID.deviceID = "phone" cases.append(badDeviceID) var shortToken = Self.relayEndpoint(nodeID: "nd_7f3a9c2e") shortToken.deviceToken = "abc" cases.append(shortToken) for (index, endpoint) in cases.enumerated() { do { _ = try await repository.create( InstanceInput(name: "Broken \(index)", endpoint: .relay(endpoint)) ) XCTFail("incomplete relay endpoint \(index) was accepted") } catch let error as RepositoryError { XCTAssertEqual(error, .invalidOrigin) } } } func testRelayEndpointRoundTripsThroughTheStore() async throws { let repository = FileInstanceRepository(location: location) let endpoint = Self.relayEndpoint(nodeID: "nd_7f3a9c2e") let created = try await repository.create( InstanceInput(name: "Relayed", endpoint: .relay(endpoint), tags: ["remote"]) ) // A second repository over the same file proves the bytes on disk are what decode, not an // in-memory copy. let reopened = FileInstanceRepository(location: location) let fetched = try await reopened.get(created.id) let loaded = try XCTUnwrap(fetched) XCTAssertEqual(loaded.endpoint, .relay(endpoint)) XCTAssertEqual(loaded.endpoint.relay?.agentSigningKey, endpoint.agentSigningKey) XCTAssertEqual(loaded.endpoint.relay?.agentKxKey, endpoint.agentKxKey) XCTAssertEqual(loaded.endpoint.relay?.deviceToken, endpoint.deviceToken) XCTAssertEqual(loaded.endpoint.relay?.nodeName, "Lab Alpha") } /// The two modes must not share a credential string: in relay mode the phone holds no node /// password, and saying otherwise is a false statement about where the secret lives. func testCredentialWordingDiffersByMode() { let direct = InstanceEndpoint.direct(URL(string: "http://192.168.1.20:3000")!) let relay = InstanceEndpoint.relay(Self.relayEndpoint(nodeID: "nd_7f3a9c2e")) XCTAssertNotEqual(direct.credentialDescription, relay.credentialDescription) XCTAssertTrue(relay.credentialDescription.contains("agent")) } /// Freshness uses the vocabulary that already exists in `packages/contracts`, with the four /// values spelled the same, so the app and the console describe the same state with one word. func testFreshnessUsesTheSharedVocabulary() { XCTAssertEqual(Set(SnapshotFreshness.allCases.map(\.rawValue)), ["fresh", "stale", "expired", "unknown"]) XCTAssertEqual(SnapshotFreshness.classify(age: 2), .fresh) XCTAssertEqual(SnapshotFreshness.classify(age: 40), .stale) XCTAssertEqual(SnapshotFreshness.classify(age: 600), .expired) XCTAssertEqual(SnapshotFreshness.classify(age: nil), .unknown) XCTAssertEqual(SnapshotFreshness.classify(age: -1), .unknown) } static func relayEndpoint(nodeID: String) -> RelayEndpoint { RelayEndpoint( relayBaseURL: URL(string: "https://relay.example")!, nodeID: nodeID, agentSigningKey: Data(repeating: 0xA1, count: 32), agentKxKey: Data(repeating: 0xB2, count: 32), deviceToken: String(repeating: "c3", count: 24), deviceID: "ap_5b1d0000", nodeName: "Lab Alpha" ) } }