Files
multi-simadmin/apps/ios/Tests/MultiSimAdminTests/AppRoutesContractTests.swift
T
chick 3f6090081f 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)、配对四类失败、路由器、
  传输往返与各类拒绝路径
2026-09-16 18:57:41 +08:00

143 lines
6.7 KiB
Swift

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
}
}
}