import APIClient import Foundation import HostRegistry import TestSupport import Testing import UIKit import WireProtocol @testable import WebTerm /// F1 · 令牌门主机上的缩略图管线。 /// /// 缺口:`SessionThumbnailPipeline.live()` 自建了一个**没有令牌源**的 /// `URLSessionHTTPTransport`,于是在开了 `WEBTERM_TOKEN` 的主机上 /// `GET /live-sessions/:id/preview` 一律 401;而本管线按设计**永不抛错** /// (失败即占位图),401 就被静默降级成「每一张缩略图都是占位符」, /// 屏幕上没有任何原因可查。 /// /// 这里钉三件事: /// 1. 令牌真的到了 preview 请求头上(走的是**生产的**盖章代码路径 /// `URLSessionHTTPTransport.authenticated`,不是测试里重写一遍); /// 2. 只读纪律零回归 —— preview 是 RO,**绝不**带 `Origin`(Origin-iff-G); /// 3. 没有令牌 / 令牌不对(401)时依旧**优雅降级**成占位图,不崩不抛。 @MainActor @Suite("SessionThumbnail 令牌接线") struct SessionThumbnailTokenTests { /// 32 个合法字符(§1.1 字符集 `[A-Za-z0-9._~+/=-]`,长度 16–512)。 private static let token = "0123456789abcdefghijklmnopqrstuv" private static let base = "http://192.168.1.20:3000" // MARK: - Fixtures /// 生产的盖章逻辑(`URLSessionHTTPTransport.authenticated`)串在一个可脚本化 /// 的记录器前面:真实 Cookie 代码路径,零网络。 private struct StampingTransport: HTTPTransport { let stamper: URLSessionHTTPTransport let recorder: FakeHTTPTransport func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { try await recorder.send(await stamper.authenticated(request)) } } private func makeEndpoint(_ base: String = SessionThumbnailTokenTests.base) throws -> HostEndpoint { let url = try #require(URL(string: base)) return try #require(HostEndpoint(baseURL: url)) } private func makeStore(token: String?) async throws -> InMemoryHostStore { let store = InMemoryHostStore() _ = try await store.upsert( HostRegistry.Host( id: UUID(), name: "mac", endpoint: try makeEndpoint(), accessToken: try token.map { try AccessToken(validating: $0) } ) ) return store } private func previewURL(_ sessionId: UUID) throws -> URL { try #require( URL(string: "\(Self.base)/live-sessions/\(sessionId.uuidString.lowercased())/preview") ) } private func previewBody(_ sessionId: UUID) throws -> Data { try #require(""" {"id":"\(sessionId.uuidString.lowercased())","cols":80,"rows":24,"data":"hello"} """.data(using: .utf8)) } /// 装配一条**默认生产形状**的管线,只把网络出口换成记录器。 private func makePipeline( store: any HostStore ) -> (pipeline: SessionThumbnailPipeline, recorder: FakeHTTPTransport) { let recorder = FakeHTTPTransport() let transport = StampingTransport( stamper: AppEnvironment.thumbnailTransport( hostStore: store, identityProvider: { nil } ), recorder: recorder ) return (SessionThumbnailPipeline.live(http: transport), recorder) } // MARK: - 令牌门主机 @Test("令牌门主机:preview 请求带上 Cookie: webterm_auth=,且缩略图真渲染出来") func previewRequestCarriesTheAccessTokenCookie() async throws { // Arrange let sessionId = UUID() let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token)) await recorder.queueSuccess( url: try previewURL(sessionId), body: try previewBody(sessionId) ) // Act let image = await pipeline.thumbnail( for: SessionThumbnailRequest( endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 ) ) // Assert let requests = await recorder.recordedRequests #expect(requests.count == 1) #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == "\(AccessTokenCookie.name)=\(Self.token)") #expect(!image.isPlaceholder, "带上令牌后 preview 应当成功并渲染") } @Test("preview 是只读路由:带令牌也绝不带 Origin(Origin-iff-G 零回归)") func previewStaysOriginFree() async throws { // Arrange let sessionId = UUID() let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token)) await recorder.queueSuccess( url: try previewURL(sessionId), body: try previewBody(sessionId) ) // Act _ = await pipeline.thumbnail( for: SessionThumbnailRequest( endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 ) ) // Assert let requests = await recorder.recordedRequests #expect(requests.first?.value(forHTTPHeaderField: "Origin") == nil) } // MARK: - 无令牌 / 令牌不对 @Test("主机没有令牌 → 请求逐字不带 Cookie(LAN 零配置主机零回归)") func tokenlessHostSendsNoCookie() async throws { // Arrange let sessionId = UUID() let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil)) await recorder.queueSuccess( url: try previewURL(sessionId), body: try previewBody(sessionId) ) // Act let image = await pipeline.thumbnail( for: SessionThumbnailRequest( endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 ) ) // Assert let requests = await recorder.recordedRequests #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) #expect(!image.isPlaceholder) } @Test("令牌缺失导致 401 → 降级成占位图,不崩不抛(管线的错误 UI 就是占位图)") func unauthorizedDegradesToPlaceholder() async throws { // Arrange:主机记录里没有令牌,服务器要求令牌 → 401。 let sessionId = UUID() let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil)) await recorder.queueSuccess( url: try previewURL(sessionId), status: 401, body: try #require(#"{"error":"unauthorized"}"#.data(using: .utf8)) ) // Act let image = await pipeline.thumbnail( for: SessionThumbnailRequest( endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 ) ) // Assert #expect(image == .placeholder) #expect(await recorder.recordedRequests.count == 1) } @Test("主机根本不在存储里(存储读不到该 origin)→ 无 Cookie,仍不崩") func unknownOriginDegradesGracefully() async throws { // Arrange:存储里是另一台主机。 let sessionId = UUID() let store = InMemoryHostStore() let otherEndpoint = try makeEndpoint("http://192.168.1.99:3000") _ = try await store.upsert( HostRegistry.Host( id: UUID(), name: "other", endpoint: otherEndpoint, accessToken: try AccessToken(validating: Self.token) ) ) let (pipeline, recorder) = makePipeline(store: store) await recorder.queueSuccess( url: try previewURL(sessionId), body: try previewBody(sessionId) ) // Act let image = await pipeline.thumbnail( for: SessionThumbnailRequest( endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 ) ) // Assert:绝不把别台主机的令牌发出去。 let requests = await recorder.recordedRequests #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) #expect(!image.isPlaceholder) } // MARK: - 组合根装配本身 @Test("thumbnailTransport 就是 App 的同一条盖章传输(按 origin 取本主机令牌)") func thumbnailTransportStampsPerOrigin() async throws { // Arrange let transport = AppEnvironment.thumbnailTransport( hostStore: try await makeStore(token: Self.token), identityProvider: { nil } ) let mine = URLRequest(url: try previewURL(UUID())) let other = URLRequest( url: try #require(URL(string: "http://192.168.1.99:3000/live-sessions")) ) // Act let stamped = await transport.authenticated(mine) let untouched = await transport.authenticated(other) // Assert #expect(stamped.value(forHTTPHeaderField: AccessTokenCookie.header) == "\(AccessTokenCookie.name)=\(Self.token)") #expect(untouched.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) } }