- 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)、配对四类失败、路由器、 传输往返与各类拒绝路径
143 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|