feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user