fix(ios,android): close the acceptance gaps the review found, add Android CI
- 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.
This commit is contained in:
231
ios/App/WebTermTests/SessionThumbnailTokenTests.swift
Normal file
231
ios/App/WebTermTests/SessionThumbnailTokenTests.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user