Files
web-terminal/ios/App/WebTermTests/SessionThumbnailTokenTests.swift
Yaojia Wang 5cc755b0b6 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.
2026-07-30 16:46:20 +02:00

232 lines
8.9 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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._~+/=-]` 16512
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 是只读路由:带令牌也绝不带 OriginOrigin-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("主机没有令牌 → 请求逐字不带 CookieLAN 零配置主机零回归)")
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)
}
}