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