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,142 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
/// Keeps `contracts/app-routes.json` honest.
|
||||
///
|
||||
/// That file is the relay repository's copy of every upstream path this app can construct, and the
|
||||
/// agent's authorisation policy is pinned to it: `test/policy.test.js` asserts the two sets are
|
||||
/// equal, so the agent can never be wider than the app. The missing link was always the other
|
||||
/// direction — nothing checked that the vendored file still matched `UpstreamRoute`, and a route
|
||||
/// added here without updating it would leave the agent narrower than the app, which fails at
|
||||
/// runtime as "policy_denied" for an operation the UI offers.
|
||||
///
|
||||
/// So the check lives here, next to the source of truth, and the vendored file carries a snapshot.
|
||||
final class AppRoutesContractTests: XCTestCase {
|
||||
/// Every route the app can construct, in the vendored file's shape.
|
||||
///
|
||||
/// Written out by hand rather than derived, because deriving it from `UpstreamRoute` would make
|
||||
/// the test tautological: it would compare the enum against itself. This list is the claim about
|
||||
/// what the app can ask for, and adding a case to the enum without adding it here fails the
|
||||
/// count assertion below.
|
||||
private static let declaredRoutes: [(method: String, path: String)] = [
|
||||
("GET", "/api/health"),
|
||||
("GET", "/api/device"),
|
||||
("GET", "/api/sim"),
|
||||
("GET", "/api/network"),
|
||||
("GET", "/api/network/signal-strength"),
|
||||
("GET", "/api/stats"),
|
||||
("GET", "/api/stats/cpu"),
|
||||
("GET", "/api/connectivity"),
|
||||
("GET", "/api/sms/list"),
|
||||
("POST", "/api/sms/send"),
|
||||
("POST", "/api/sms/batch-delete"),
|
||||
]
|
||||
|
||||
private static let authRoutes: [(method: String, path: String)] = [
|
||||
("POST", "/api/auth/login"),
|
||||
("POST", "/api/auth/logout"),
|
||||
]
|
||||
|
||||
private static let candidatePaths = [
|
||||
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
|
||||
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
|
||||
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
|
||||
].compactMap { $0 }
|
||||
|
||||
private func loadAppRoutes() throws -> [String: Any] {
|
||||
for candidate in Self.candidatePaths {
|
||||
let routes = URL(fileURLWithPath: candidate)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("app-routes.json")
|
||||
guard FileManager.default.fileExists(atPath: routes.path) else { continue }
|
||||
let data = try Data(contentsOf: routes)
|
||||
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw XCTSkip("app-routes.json is not a JSON object")
|
||||
}
|
||||
return object
|
||||
}
|
||||
throw XCTSkip("simadmin-relay/contracts/app-routes.json not found; set MSA_RELAY_VECTORS")
|
||||
}
|
||||
|
||||
/// Each route in the declared list must produce the path and method it claims. This is what
|
||||
/// catches a path edited in `UpstreamRoute` without the snapshot being updated.
|
||||
func testEveryDeclaredRouteMatchesTheEnum() throws {
|
||||
for (method, expectedPath) in Self.declaredRoutes {
|
||||
let route = try XCTUnwrap(
|
||||
Self.sampleRoute(matching: expectedPath),
|
||||
"no UpstreamRoute case produces \(expectedPath)"
|
||||
)
|
||||
XCTAssertEqual(route.method, method, "method for \(expectedPath)")
|
||||
// Query strings are part of the path the agent forwards, so compare the prefix the
|
||||
// route's own literal contributes.
|
||||
XCTAssertTrue(
|
||||
route.path.hasPrefix(expectedPath),
|
||||
"route for \(expectedPath) produced \(route.path)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testAuthRoutesAreNotProxied() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
let auth = try XCTUnwrap(routes["auth"] as? [String])
|
||||
let declared = Set(Self.authRoutes.map(\.path))
|
||||
XCTAssertEqual(Set(auth), declared, "the vendored auth list drifted")
|
||||
|
||||
// The agent must do the logging in itself, so these paths must never appear in the proxied
|
||||
// set — that is what keeps a node password from ever traversing the relay.
|
||||
let proxied = Set(Self.declaredRoutes.map(\.path))
|
||||
XCTAssertTrue(proxied.isDisjoint(with: declared))
|
||||
}
|
||||
|
||||
func testTheVendoredFileMatchesThisList() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
let vendored = try XCTUnwrap(routes["routes"] as? [[String: String]])
|
||||
let vendoredPairs = Set(vendored.compactMap { entry -> String? in
|
||||
guard let method = entry["method"], let path = entry["path"] else { return nil }
|
||||
return "\(method) \(path)"
|
||||
})
|
||||
let declaredPairs = Set(Self.declaredRoutes.map { "\($0.method) \($0.path)" })
|
||||
XCTAssertEqual(
|
||||
vendoredPairs.symmetricDifference(declaredPairs),
|
||||
[],
|
||||
"app-routes.json drifted from the app's route surface; regenerate it deliberately"
|
||||
)
|
||||
}
|
||||
|
||||
/// The app's copy of `policy.dangerous` must match the agent's, or a write would either be
|
||||
/// refused for a missing intent or would carry an intent the agent does not require.
|
||||
func testConfirmationIsRequiredExactlyWhereTheAgentRequiresIt() throws {
|
||||
let routes = try loadAppRoutes()
|
||||
// The agent policy lives beside the app routes; the relay repository's policy test already
|
||||
// asserts the two files agree, so reading the app-routes list here is enough.
|
||||
for (method, path) in Self.declaredRoutes {
|
||||
let route = try XCTUnwrap(Self.sampleRoute(matching: path))
|
||||
let isWrite = method == "POST" && path.hasPrefix("/api/sms/")
|
||||
XCTAssertEqual(
|
||||
route.requiresConfirmation,
|
||||
isWrite,
|
||||
"confirmation intent flag for \(method) \(path)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the representative `UpstreamRoute` for a path. The sample arguments are arbitrary:
|
||||
/// what is under test is the path and method mapping, not the query values.
|
||||
private static func sampleRoute(matching path: String) -> UpstreamRoute? {
|
||||
switch path {
|
||||
case "/api/health": return .health
|
||||
case "/api/device": return .device
|
||||
case "/api/sim": return .sim
|
||||
case "/api/network": return .network
|
||||
case "/api/network/signal-strength": return .signalStrength
|
||||
case "/api/stats": return .stats
|
||||
case "/api/stats/cpu": return .statsCpu
|
||||
case "/api/connectivity": return .connectivity
|
||||
case "/api/sms/list": return .smsList(limit: 50, offset: 0, direction: nil)
|
||||
case "/api/sms/send": return .smsSend(phoneNumber: "+8613800000000", content: "hi")
|
||||
case "/api/sms/batch-delete": return .smsBatchDelete(ids: [1])
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user