feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-8 · Origin-iff-G 铁律(plan §3.4/§5.1,安全语义):
|
||||
/// `Origin` header 出现**当且仅当(iff)** G(变更)端点。
|
||||
/// RO GET(liveSessions/preview/events/uiConfig)一律不带 Origin;
|
||||
/// G(killSession/hookDecision)必带且**逐字符等于** `endpoint.originHeader`。
|
||||
/// 这样服务器一旦把某 RO 端点改成 G,集成测试立刻红,而不是靠巧合通过。
|
||||
struct RequestBuilderTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
private static let emptyArrayBody = Data("[]".utf8)
|
||||
private static let previewBody = Data(
|
||||
#"{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cols":80,"rows":24,"data":"hi"}"#.utf8
|
||||
)
|
||||
private static let uiConfigBody = Data(#"{"allowAutoMode":false}"#.utf8)
|
||||
|
||||
private func makeEndpoint(_ base: String = RequestBuilderTests.base) throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private func routeURL(_ path: String, base: String = RequestBuilderTests.base) throws -> URL {
|
||||
try #require(URL(string: base + path))
|
||||
}
|
||||
|
||||
private func makeSessionId() throws -> UUID {
|
||||
try #require(UUID(uuidString: Self.sessionIdString))
|
||||
}
|
||||
|
||||
// MARK: - RO 侧(iff-G:无 Origin)
|
||||
|
||||
@Test("Origin iff-G(RO 侧):liveSessions/preview/events/uiConfig 四个 RO GET 一律不带 Origin")
|
||||
func readOnlyEndpointsNeverCarryOriginHeader() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let id = try makeSessionId()
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
|
||||
body: Self.previewBody
|
||||
)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
|
||||
body: Self.emptyArrayBody
|
||||
)
|
||||
await http.queueSuccess(url: try routeURL("/config/ui"), body: Self.uiConfigBody)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
_ = try await client.preview(id: id)
|
||||
_ = try await client.events(id: id)
|
||||
_ = try await client.uiConfig()
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 4)
|
||||
for request in requests {
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - G 侧(iff-G:必带 Origin,逐字符相等)
|
||||
|
||||
@Test("Origin iff-G(G 侧):killSession 为 DELETE /live-sessions/:id(小写 UUID),Origin 逐字符等于 endpoint.originHeader")
|
||||
func killSessionCarriesByteEqualOriginHeader() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let id = try makeSessionId()
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 204)
|
||||
|
||||
// Act
|
||||
try await client.killSession(id: id)
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 1)
|
||||
let request = try #require(requests.first)
|
||||
#expect(request.httpMethod == "DELETE")
|
||||
#expect(request.url == url) // 路径用小写 UUID —— 服务器按字符串精确匹配
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
|
||||
}
|
||||
|
||||
@Test("Origin iff-G(G 侧):hookDecision 为 POST /hook/decision,Origin 逐字符等于 endpoint.originHeader")
|
||||
func hookDecisionCarriesByteEqualOriginHeader() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/hook/decision")
|
||||
await http.queueSuccess(method: "POST", url: url, status: 204)
|
||||
|
||||
// Act
|
||||
try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1")
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url == url)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
|
||||
}
|
||||
|
||||
@Test("G 端点 Origin 对 https 默认端口省略 :443(无端口迷信,plan §5.1)")
|
||||
func httpsDefaultPortOriginOmitsPort443() async throws {
|
||||
// Arrange
|
||||
let base = "https://mac.tailnet.ts.net"
|
||||
let endpoint = try makeEndpoint(base)
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)", base: base)
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 204)
|
||||
|
||||
// Act
|
||||
try await client.killSession(id: try makeSessionId())
|
||||
|
||||
// Assert
|
||||
let origin = try #require(
|
||||
await http.recordedRequests.first?.value(forHTTPHeaderField: "Origin")
|
||||
)
|
||||
#expect(origin == "https://mac.tailnet.ts.net")
|
||||
#expect(!origin.contains(":443"))
|
||||
}
|
||||
|
||||
// MARK: - hookDecision body 形状与错误映射
|
||||
|
||||
@Test("hookDecision body 形状恰为 {sessionId,decision,token} 且 Content-Type 为 JSON(服务器限 4KB body、10 次/分/IP)")
|
||||
func hookDecisionBodyShapeIsExactlySessionIdDecisionToken() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 204)
|
||||
|
||||
// Act
|
||||
try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1")
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["sessionId", "decision", "token"]))
|
||||
#expect(object["sessionId"] as? String == Self.sessionIdString) // 小写 UUID
|
||||
#expect(object["decision"] as? String == "allow")
|
||||
#expect(object["token"] as? String == "tok-1")
|
||||
}
|
||||
|
||||
@Test("hookDecision 403 → decisionRejected 显式错误(token 已过期/已被处理,src/server.ts:522-525)")
|
||||
func hookDecision403ThrowsExplicitTokenRejectedError() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 403)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.decisionRejected) {
|
||||
try await client.hookDecision(
|
||||
sessionId: try self.makeSessionId(), decision: .deny, token: "expired"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("hookDecision 429 → rateLimited(≤10 次/分/IP,src/server.ts:72)")
|
||||
func hookDecision429ThrowsRateLimited() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 429)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.rateLimited) {
|
||||
try await client.hookDecision(
|
||||
sessionId: try self.makeSessionId(), decision: .allow, token: "tok"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("hookDecision 400(非法 body) → unexpectedStatus(400)")
|
||||
func hookDecision400ThrowsUnexpectedStatus() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 400)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unexpectedStatus(400)) {
|
||||
try await client.hookDecision(
|
||||
sessionId: try self.makeSessionId(), decision: .allow, token: "tok"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("killSession 意外状态码(500) → unexpectedStatus(500)")
|
||||
func killSession500ThrowsUnexpectedStatus() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 500)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
try await client.killSession(id: try self.makeSessionId())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("killSession 404 → sessionNotFound(会话已退出/被清理)")
|
||||
func killSession404ThrowsSessionNotFound() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 404)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.sessionNotFound) {
|
||||
try await client.killSession(id: try self.makeSessionId())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("baseURL 带尾斜杠时请求 URL 归一化(不产生 //live-sessions)")
|
||||
func trailingSlashBaseURLNormalizesRequestPath() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint("http://192.168.1.5:3000/")
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.url?.absoluteString == "http://192.168.1.5:3000/live-sessions")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user