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