- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped — recorded as a withKnownIssue rather than fixed. Height now derives from the content size category (and tracks live changes via registerForTraitChanges); the known-issue marker is gone, replaced by positive assertions including a 12-category no-clip sweep and a guard that stays red if anyone writes the constant back. Honest tradeoff: the keycap font is clamped at .accessibility2, the same policy the design system already applies to dense content, because an unclamped AX5 bar would eat the terminal. A test pins the clamp so the two cannot drift. - Thumbnails silently 401'd on a token-gated host: the pipeline built its own transport with no token source, and by design never throws, so every preview degraded to a placeholder with no signal. Assembled from AppEnvironment now. - Android had zero CI while the token wave shipped 24 files of secret-handling code. The instrumented leg is workflow_dispatch-only and says why in the file: no one has ever seen it green on a runner, and a required leg nobody trusts just produces a false green. - Android persisted a validated token before the host probe succeeded, stranding a secret for a host that never paired. App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
232 lines
8.9 KiB
Swift
232 lines
8.9 KiB
Swift
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=<t>,且缩略图真渲染出来")
|
||
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)
|
||
}
|
||
}
|