import APIClient import Foundation import TestSupport import Testing import WireProtocol @testable import WebTerm /// C1 · The pairing probe against a token-gated host, driven through the REAL /// `runPairingProbe` over `FakeHTTPTransport` + `FakeTransport`. /// /// Two things are pinned: /// 1. a 401 is no longer misreported as "Origin rejected" — with the token gate /// live, 401 has TWO causes and one status code, so the copy must name both; /// 2. the candidate token really travels: `Cookie: webterm_auth=` on every /// HTTP leg (RO GET and guarded DELETE alike, §1.1 — the cookie is orthogonal /// to `Origin`, which only the DELETE carries). /// /// These live in the App test target because APIClient's own test suite is B1's /// file ownership; the probe function under test is the package's public one. @Suite("Pairing probe · 401 and the token cookie") struct PairingProbeUnauthorizedTests { private static let base = "http://192.168.1.5:3000" private static let token = "abcdefghijklmnopqrstuvwxyz012345" private static let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341" private func endpoint() throws -> HostEndpoint { let url = try #require(URL(string: Self.base)) return try #require(HostEndpoint(baseURL: url)) } /// Script the whole happy path: RO list → WS attach → guarded kill. private func scriptHappyPath( http: FakeHTTPTransport, ws: FakeTransport ) async throws { await http.queueSuccess( url: try #require(URL(string: "\(Self.base)/live-sessions")), body: Data("[]".utf8) ) await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#) await http.queueSuccess( method: "DELETE", url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")), status: 204 ) } // MARK: - 401 → both remedies @Test("RO 探针收到 401 → 不是「不是 web-terminal」,而是给出令牌+ALLOWED_ORIGINS 两条补救") func readOnly401NamesBothRemedies() async throws { let http = FakeHTTPTransport() let ws = FakeTransport() await http.queueSuccess( url: try #require(URL(string: "\(Self.base)/live-sessions")), status: 401, body: Data(#"{"error":"unauthorized"}"#.utf8) ) let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws) guard case .failure(.originRejected(let hint)) = result else { Issue.record("expected originRejected(hint:), got \(result)") return } #expect(hint.contains("401")) #expect(hint.contains("访问令牌")) #expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)")) // The WS leg is never reached, so no PTY is spawned on a host that // refuses us. #expect(await ws.connectAttempts.isEmpty) } @Test("WS 升级被拒(无法归类)→ 同一条两因两解的话术") func upgradeRejectionNamesBothRemedies() async throws { let http = FakeHTTPTransport() let ws = FakeTransport() await http.queueSuccess( url: try #require(URL(string: "\(Self.base)/live-sessions")), body: Data("[]".utf8) ) await ws.scriptConnectFailure() let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws) guard case .failure(.originRejected(let hint)) = result else { Issue.record("expected originRejected(hint:), got \(result)") return } #expect(hint.contains("访问令牌")) #expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)")) #expect(!hint.contains(":443")) } @Test("守卫 DELETE 收到 401(而非 403)→ 同样是两因两解,不报「主机不可达」") func guardedDelete401NamesBothRemedies() async throws { let http = FakeHTTPTransport() let ws = FakeTransport() await http.queueSuccess( url: try #require(URL(string: "\(Self.base)/live-sessions")), body: Data("[]".utf8) ) await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#) await http.queueSuccess( method: "DELETE", url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")), status: 401 ) let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws) guard case .failure(.originRejected(let hint)) = result else { Issue.record("expected originRejected(hint:), got \(result)") return } #expect(hint.contains("访问令牌")) } // MARK: - The candidate token actually travels @Test("带候选令牌的探针:两条 HTTP 腿都带 Cookie: webterm_auth,且只读腿仍不带 Origin") func tokenTravelsOnBothHTTPLegs() async throws { let http = FakeHTTPTransport() let ws = FakeTransport() try await scriptHappyPath(http: http, ws: ws) let result = await runPairingProbe( endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token ) guard case .success = result else { Issue.record("expected success, got \(result)") return } let requests = await http.recordedRequests #expect(requests.count == 2) for request in requests { #expect( request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)", "every probe leg must carry the token cookie" ) } // Origin-iff-G is untouched by the token (§1.1: orthogonal). let readOnly = try #require(requests.first) let guarded = try #require(requests.last) #expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) #expect(guarded.value(forHTTPHeaderField: "Origin") == Self.base) } @Test("没有候选令牌 → 一个 Cookie 头都不加(LAN 零配置逐字不变)") func noTokenMeansNoCookieHeader() async throws { let http = FakeHTTPTransport() let ws = FakeTransport() try await scriptHappyPath(http: http, ws: ws) _ = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws) for request in await http.recordedRequests { #expect(request.value(forHTTPHeaderField: "Cookie") == nil) } } @Test("令牌不出现在 URL 里(绝不进查询串/日志)") func tokenNeverAppearsInAURL() async throws { let http = FakeHTTPTransport() let ws = FakeTransport() try await scriptHappyPath(http: http, ws: ws) _ = await runPairingProbe( endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token ) for request in await http.recordedRequests { #expect(request.url?.absoluteString.contains(Self.token) == false) } } }