Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/RequestBuilderTests.swift
Yaojia Wang 95438cdc12 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
2026-07-04 21:53:41 +02:00

251 lines
10 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 GETliveSessions/preview/events/uiConfig Origin
/// GkillSession/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")
}
}