feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-8 · 响应模型解码。服务器是**不可信输入源**(plan §4):
|
||||
/// - `LiveSessionInfo` 镜像 src/types.ts:246-256,`telemetry` 可缺失/为 null;
|
||||
/// - 未知 `status` → `.unknown`(新状态值不得让运行中的会话从列表里消失);
|
||||
/// - 畸形条目丢弃、整体不 crash;非数组 body → 显式错误(探针的"端口对吗?"信号);
|
||||
/// - `TimelineEvent` 经 WireProtocol 的 `decodeList` 帮助器,未知 `class` 该条丢弃。
|
||||
struct ModelDecodingTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
|
||||
private static let fullSessionJSON = """
|
||||
{"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":2,\
|
||||
"status":"working","exited":false,"cwd":"/Users/dev/proj","cols":120,"rows":40,\
|
||||
"telemetry":{"contextUsedPct":42.5,"costUsd":1.25,"linesAdded":10,"linesRemoved":2,\
|
||||
"model":"Opus","effort":"high",\
|
||||
"pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\
|
||||
"rate":{"fiveHourPct":12.5,"sevenDayPct":40.0},"at":1720000005000}}
|
||||
"""
|
||||
|
||||
private static let noTelemetrySessionJSON = """
|
||||
{"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":0,\
|
||||
"status":"idle","exited":false,"cwd":null,"cols":80,"rows":24}
|
||||
"""
|
||||
|
||||
private func makeClient(_ http: FakeHTTPTransport) throws -> APIClient {
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return APIClient(endpoint: endpoint, http: http)
|
||||
}
|
||||
|
||||
private func routeURL(_ path: String) throws -> URL {
|
||||
try #require(URL(string: Self.base + path))
|
||||
}
|
||||
|
||||
private func fetchSessions(body: String) async throws -> [LiveSessionInfo] {
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data(body.utf8))
|
||||
return try await client.liveSessions()
|
||||
}
|
||||
|
||||
// MARK: - LiveSessionInfo
|
||||
|
||||
@Test("LiveSessionInfo 全字段样本解码(src/types.ts:246-256 形状,含完整 telemetry)")
|
||||
func liveSessionInfoDecodesFullSample() async throws {
|
||||
// Act
|
||||
let sessions = try await fetchSessions(body: "[\(Self.fullSessionJSON)]")
|
||||
|
||||
// Assert
|
||||
#expect(sessions.count == 1)
|
||||
let session = try #require(sessions.first)
|
||||
#expect(session.id == UUID(uuidString: Self.sessionIdString))
|
||||
#expect(session.createdAt == 1_720_000_000_000)
|
||||
#expect(session.clientCount == 2)
|
||||
#expect(session.status == .working)
|
||||
#expect(session.exited == false)
|
||||
#expect(session.cwd == "/Users/dev/proj")
|
||||
#expect(session.cols == 120)
|
||||
#expect(session.rows == 40)
|
||||
let telemetry = try #require(session.telemetry)
|
||||
#expect(telemetry.contextUsedPct == 42.5)
|
||||
#expect(telemetry.costUsd == 1.25)
|
||||
#expect(telemetry.linesAdded == 10)
|
||||
#expect(telemetry.linesRemoved == 2)
|
||||
#expect(telemetry.model == "Opus")
|
||||
#expect(telemetry.effort == "high")
|
||||
#expect(telemetry.pr?.number == 7)
|
||||
#expect(telemetry.rate?.fiveHourPct == 12.5)
|
||||
#expect(telemetry.at == 1_720_000_005_000)
|
||||
// 与 memberwise 构造的期望值整体相等(Equatable 全字段往返)
|
||||
let expected = LiveSessionInfo(
|
||||
id: try #require(UUID(uuidString: Self.sessionIdString)),
|
||||
createdAt: 1_720_000_000_000,
|
||||
clientCount: 2,
|
||||
status: .working,
|
||||
exited: false,
|
||||
cwd: "/Users/dev/proj",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
telemetry: StatusTelemetry(
|
||||
contextUsedPct: 42.5, costUsd: 1.25, linesAdded: 10, linesRemoved: 2,
|
||||
model: "Opus", effort: "high",
|
||||
pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"),
|
||||
rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 40.0),
|
||||
at: 1_720_000_005_000
|
||||
)
|
||||
)
|
||||
#expect(session == expected)
|
||||
}
|
||||
|
||||
@Test("LiveSessionInfo telemetry 缺失/cwd 为 null → 解码为 nil,其余字段完整")
|
||||
func liveSessionInfoToleratesMissingTelemetryAndNullCwd() async throws {
|
||||
// Act
|
||||
let sessions = try await fetchSessions(body: "[\(Self.noTelemetrySessionJSON)]")
|
||||
|
||||
// Assert
|
||||
let session = try #require(sessions.first)
|
||||
#expect(session.telemetry == nil)
|
||||
#expect(session.cwd == nil)
|
||||
#expect(session.status == .idle)
|
||||
#expect(session.cols == 80)
|
||||
#expect(session.rows == 24)
|
||||
}
|
||||
|
||||
@Test("未知 status 字符串 → .unknown(不丢弃整个会话条目)")
|
||||
func unknownStatusStringMapsToUnknownWithoutDroppingEntry() async throws {
|
||||
// Arrange
|
||||
let body = Self.noTelemetrySessionJSON
|
||||
.replacingOccurrences(of: #""status":"idle""#, with: #""status":"hyperdrive""#)
|
||||
|
||||
// Act
|
||||
let sessions = try await fetchSessions(body: "[\(body)]")
|
||||
|
||||
// Assert
|
||||
#expect(sessions.count == 1)
|
||||
#expect(sessions.first?.status == .unknown)
|
||||
}
|
||||
|
||||
@Test("畸形条目(非对象/坏 UUID/缺必填)逐条丢弃,合法条目保留,不 crash")
|
||||
func malformedEntriesAreDroppedWhileValidOnesSurvive() async throws {
|
||||
// Arrange
|
||||
let badUUID = Self.noTelemetrySessionJSON
|
||||
.replacingOccurrences(of: Self.sessionIdString, with: "not-a-uuid")
|
||||
let body = "[\(Self.fullSessionJSON),42,\(badUUID),{\"id\":\"x\"}]"
|
||||
|
||||
// Act
|
||||
let sessions = try await fetchSessions(body: body)
|
||||
|
||||
// Assert
|
||||
#expect(sessions.count == 1)
|
||||
#expect(sessions.first?.id == UUID(uuidString: Self.sessionIdString))
|
||||
}
|
||||
|
||||
@Test("非数组 body(HTML/对象)→ invalidResponseBody 显式错误(探针的 端口对吗 信号)")
|
||||
func nonArrayBodyThrowsInvalidResponseBody() async throws {
|
||||
// Act + Assert — HTML(路由器管理页之类)
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await self.fetchSessions(body: "<html><body>router admin</body></html>")
|
||||
}
|
||||
// Act + Assert — JSON 但不是数组
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await self.fetchSessions(body: #"{"error":"nope"}"#)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TimelineEvent(经 WireProtocol helper)
|
||||
|
||||
@Test("TimelineEvent 未知 class 值 → 该条丢弃不 crash(服务器视为不可信)")
|
||||
func unknownTimelineClassEntriesAreDroppedNotCrashed() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
let body = """
|
||||
[{"at":1,"class":"tool","toolName":"Bash","label":"ran Bash"},\
|
||||
{"at":2,"class":"quantum","label":"??"},\
|
||||
{"at":3,"class":"waiting","label":"waiting for approval"}]
|
||||
"""
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
|
||||
body: Data(body.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let events = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
|
||||
// Assert
|
||||
#expect(events.count == 2)
|
||||
#expect(events.map(\.class) == ["tool", "waiting"])
|
||||
#expect(events.first?.toolName == "Bash")
|
||||
}
|
||||
|
||||
@Test("events 404 → sessionNotFound")
|
||||
func events404ThrowsSessionNotFound() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
|
||||
status: 404
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.sessionNotFound) {
|
||||
_ = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SessionPreview / UiConfig
|
||||
|
||||
@Test("SessionPreview 全字段解码(GET /live-sessions/:id/preview 形状)")
|
||||
func sessionPreviewDecodesAllFields() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
let body = #"{"id":"\#(Self.sessionIdString)","cols":100,"rows":30,"data":"hello"}"#
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
|
||||
body: Data(body.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let preview = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
|
||||
// Assert
|
||||
let expected = SessionPreview(
|
||||
id: try #require(UUID(uuidString: Self.sessionIdString)),
|
||||
cols: 100, rows: 30, data: "hello"
|
||||
)
|
||||
#expect(preview == expected)
|
||||
}
|
||||
|
||||
@Test("preview 200 但 body 非预期形状 → invalidResponseBody")
|
||||
func previewGarbageBodyThrowsInvalidResponseBody() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
|
||||
body: Data("not json".utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("preview 404 → sessionNotFound")
|
||||
func preview404ThrowsSessionNotFound() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
|
||||
status: 404
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.sessionNotFound) {
|
||||
_ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("UiConfig 解码 allowAutoMode(GET /config/ui,src/types.ts:503)")
|
||||
func uiConfigDecodesAllowAutoMode() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/config/ui"),
|
||||
body: Data(#"{"allowAutoMode":true}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let config = try await client.uiConfig()
|
||||
|
||||
// Assert
|
||||
#expect(config == UiConfig(allowAutoMode: true))
|
||||
}
|
||||
|
||||
@Test("uiConfig 200 但 body 非预期形状 → invalidResponseBody")
|
||||
func uiConfigGarbageBodyThrowsInvalidResponseBody() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
await http.queueSuccess(url: try routeURL("/config/ui"), body: Data("[]".utf8))
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await client.uiConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("liveSessions 非 200(500) → unexpectedStatus(500)")
|
||||
func liveSessions500ThrowsUnexpectedStatus() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), status: 500)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
_ = try await client.liveSessions()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 话术
|
||||
|
||||
@Test("decisionRejected 话术明示 token 已过期/已被处理(RED 清单:403 → token 过期话术)")
|
||||
func decisionRejectedMessageMentionsTokenExpiry() {
|
||||
#expect(APIClientError.decisionRejected.message.contains("过期"))
|
||||
}
|
||||
|
||||
@Test("APIClientError 每个 case 都有非空 UI 话术(plan §4:错误处理全面显式)")
|
||||
func everyAPIClientErrorCaseHasNonEmptyMessage() {
|
||||
// Arrange
|
||||
let allCases: [APIClientError] = [
|
||||
.invalidRequest, .invalidResponseBody, .sessionNotFound,
|
||||
.forbidden, .decisionRejected, .rateLimited, .unexpectedStatus(500),
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for errorCase in allCases {
|
||||
#expect(!errorCase.message.isEmpty)
|
||||
}
|
||||
#expect(APIClientError.unexpectedStatus(500).message.contains("500"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
@testable import APIClient
|
||||
|
||||
/// T-iOS-8 · 两步配对探针(plan §3.4):
|
||||
/// ① `GET /live-sessions`(无 Origin,验可达 + 形状)
|
||||
/// ② WS attach(null) → adopt attached → **立即 DELETE kill(带 Origin,不留孤儿会话)**。
|
||||
/// 每种失败模式逐项映射 `PairingError`;超时经注入 clock/deadline(FakeClock,零真实等待)。
|
||||
///
|
||||
/// 注:§3.4 经 2026-07-04 契约裁定改为返回 `Result<HostEndpoint, PairingError>`
|
||||
///(Host{id,name} 由 T-iOS-12 VM 构造)。失败分支经 `@testable` 测内部核心
|
||||
/// `runPairingProbeCore`(可注入 FakeClock,零真实等待);公开包装
|
||||
/// `runPairingProbe`(ContinuousClock + Tunables.pairingProbeTimeout)另有
|
||||
/// 全通冒烟测试兜签名与默认 deadline 接线。
|
||||
struct PairingProbeTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let sessionIdString = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
private static let attachedFrame =
|
||||
#"{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"#
|
||||
|
||||
private struct Fixture {
|
||||
let endpoint: HostEndpoint
|
||||
let http: FakeHTTPTransport
|
||||
let ws: FakeTransport
|
||||
let liveSessionsURL: URL
|
||||
let killURL: URL
|
||||
}
|
||||
|
||||
private func makeFixture(base: String = PairingProbeTests.base) throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
return Fixture(
|
||||
endpoint: endpoint,
|
||||
http: FakeHTTPTransport(),
|
||||
ws: FakeTransport(),
|
||||
liveSessionsURL: try #require(URL(string: base + "/live-sessions")),
|
||||
killURL: try #require(URL(string: base + "/live-sessions/\(Self.sessionIdString)"))
|
||||
)
|
||||
}
|
||||
|
||||
/// 非超时用例统一 timeout: nil —— 探针不进入 race,全路径确定性完成。
|
||||
private func runProbe(_ fixture: Fixture, timeout: Duration? = nil,
|
||||
clock: FakeClock = FakeClock()) async -> Result<HostEndpoint, PairingError> {
|
||||
await runPairingProbeCore(
|
||||
endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws,
|
||||
clock: clock, timeout: timeout
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 探针①失败分支
|
||||
|
||||
@Test("探针①连接拒绝 → hostUnreachable,且不触碰 WS(不发任何升级请求)")
|
||||
func stepOneConnectionRefusedMapsToHostUnreachable() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueFailure(
|
||||
url: fixture.liveSessionsURL, error: URLError(.cannotConnectToHost)
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = await runProbe(fixture)
|
||||
|
||||
// Assert
|
||||
guard case .failure(.hostUnreachable) = result else {
|
||||
Issue.record("expected hostUnreachable, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(await fixture.ws.connectAttempts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("探针①返回 HTML → httpOkButNotWebTerminal(端口对吗?)")
|
||||
func stepOneHTMLBodyMapsToNotWebTerminal() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
url: fixture.liveSessionsURL,
|
||||
body: Data("<html><body>router admin</body></html>".utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
|
||||
}
|
||||
|
||||
@Test("探针① 404 → httpOkButNotWebTerminal(HTTP 有应答但不是 web-terminal)")
|
||||
func stepOne404MapsToNotWebTerminal() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, status: 404)
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
|
||||
}
|
||||
|
||||
@Test("探针① ATS 拦明文(-1022) → atsBlocked(host)")
|
||||
func stepOneATSBlockMapsToAtsBlocked() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let ats = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorAppTransportSecurityRequiresSecureConnection
|
||||
)
|
||||
await fixture.http.queueFailure(url: fixture.liveSessionsURL, error: ats)
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(fixture) == .failure(.atsBlocked(host: "192.168.1.5")))
|
||||
}
|
||||
|
||||
@Test("探针① POSIX Network-down + LAN IP → localNetworkDenied(本地网络权限被拒)")
|
||||
func stepOnePosixNetworkDownOnLANMapsToLocalNetworkDenied() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueFailure(
|
||||
url: fixture.liveSessionsURL, error: Self.networkDownError()
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(fixture) == .failure(.localNetworkDenied))
|
||||
}
|
||||
|
||||
@Test("探针① POSIX Network-down + 公网 host → hostUnreachable(不误报本地网络权限)")
|
||||
func stepOnePosixNetworkDownOnPublicHostMapsToHostUnreachable() async throws {
|
||||
// Arrange
|
||||
let publicBase = "http://203.0.113.7:3000"
|
||||
let fixture = try makeFixture(base: publicBase)
|
||||
await fixture.http.queueFailure(
|
||||
url: fixture.liveSessionsURL, error: Self.networkDownError()
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = await runProbe(fixture)
|
||||
|
||||
// Assert
|
||||
guard case .failure(.hostUnreachable) = result else {
|
||||
Issue.record("expected hostUnreachable, got \(result)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("探针① TLS 失败(-1200) → tlsFailure;URLSession 层超时(-1001) → timeout")
|
||||
func stepOneTLSAndURLTimeoutClassification() async throws {
|
||||
// Arrange — TLS
|
||||
let tlsFixture = try makeFixture()
|
||||
await tlsFixture.http.queueFailure(
|
||||
url: tlsFixture.liveSessionsURL, error: URLError(.secureConnectionFailed)
|
||||
)
|
||||
// Arrange — URLSession 超时
|
||||
let timeoutFixture = try makeFixture()
|
||||
await timeoutFixture.http.queueFailure(
|
||||
url: timeoutFixture.liveSessionsURL, error: URLError(.timedOut)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(tlsFixture) == .failure(.tlsFailure))
|
||||
#expect(await runProbe(timeoutFixture) == .failure(.timeout))
|
||||
}
|
||||
|
||||
// MARK: - 探针②失败分支
|
||||
|
||||
@Test("探针② WS 升级被拒(401) → originRejected,hint 含 ALLOWED_ORIGINS=<拨号 origin>")
|
||||
func stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() async throws {
|
||||
// Arrange — ① 通过,② 升级失败(401 在传输层是无特定形状的连接错误)
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
await fixture.ws.scriptConnectFailure()
|
||||
|
||||
// Act
|
||||
let result = await runProbe(fixture)
|
||||
|
||||
// Assert
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
|
||||
#expect(!hint.contains(":443"))
|
||||
}
|
||||
|
||||
@Test("originRejected hint 对 https 默认端口不写 :443(与拨号 URL 一致,无端口迷信)")
|
||||
func originRejectedHintOmitsDefaultPortForHTTPS() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(base: "https://mac.tailnet.ts.net")
|
||||
await fixture.http.queueSuccess(
|
||||
url: try #require(URL(string: "https://mac.tailnet.ts.net/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await fixture.ws.scriptConnectFailure()
|
||||
|
||||
// Act
|
||||
let result = await runProbe(fixture)
|
||||
|
||||
// Assert
|
||||
guard case .failure(.originRejected(let hint)) = result else {
|
||||
Issue.record("expected originRejected, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
|
||||
#expect(!hint.contains(":443"))
|
||||
}
|
||||
|
||||
@Test("探针②流在 attached 前结束 → httpOkButNotWebTerminal(升级已成功,非 Origin 问题)")
|
||||
func stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() async throws {
|
||||
// Arrange — 预先入队的 finish 会在 connect 时立刻灌入新连接
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
await fixture.ws.finishFrames()
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
|
||||
}
|
||||
|
||||
// MARK: - 全通 + kill 往返
|
||||
|
||||
@Test("探针全通 → success,attach 首帧 sessionId 为显式 null,attached 后必发带 Origin 的 kill(不留孤儿会话)")
|
||||
func fullProbeSuccessKillsProbeSessionWithOrigin() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
|
||||
await fixture.ws.emit(frame: Self.attachedFrame)
|
||||
|
||||
// Act
|
||||
let result = await runProbe(fixture)
|
||||
|
||||
// Assert — 成功载荷
|
||||
#expect(result == .success(fixture.endpoint))
|
||||
// Assert — attach(null) 是唯一 WS 帧,sessionId 键必在且为 null
|
||||
let sentFrames = await fixture.ws.sentFrames
|
||||
#expect(sentFrames.count == 1)
|
||||
let frame = try #require(sentFrames.first)
|
||||
let object = try #require(
|
||||
try JSONSerialization.jsonObject(with: Data(frame.utf8)) as? [String: Any]
|
||||
)
|
||||
#expect(object["type"] as? String == "attach")
|
||||
#expect(object["sessionId"] is NSNull)
|
||||
// Assert — kill 在 attached 之后立刻发出,带逐字符 Origin
|
||||
let requests = await fixture.http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
let kill = try #require(requests.last)
|
||||
#expect(kill.httpMethod == "DELETE")
|
||||
#expect(kill.url == fixture.killURL)
|
||||
#expect(kill.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
// Assert — WS 已关闭(探针不持连接)
|
||||
#expect(await fixture.ws.closeCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("公开包装 runPairingProbe:全通 → success(endpoint)(§3.4 裁定签名;默认 deadline 走 Tunables.pairingProbeTimeout,快路径不触发)")
|
||||
func publicWrapperHappyPathReturnsEndpoint() async throws {
|
||||
// Arrange — 与全通用例同布景;fakes 即时应答,ContinuousClock race 不等待
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
|
||||
await fixture.ws.emit(frame: Self.attachedFrame)
|
||||
|
||||
// Act
|
||||
let result = await runPairingProbe(
|
||||
endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(result == .success(fixture.endpoint))
|
||||
#expect(await fixture.ws.closeCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("attached 前收到 output 帧仍成功(不可信服务器容忍:跳过非 attached 帧)")
|
||||
func outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
|
||||
await fixture.ws.emit(frame: #"{"type":"output","data":"\u001b[0mreplay"}"#)
|
||||
await fixture.ws.emit(frame: Self.attachedFrame)
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(fixture) == .success(fixture.endpoint))
|
||||
}
|
||||
|
||||
@Test("kill 被 G 守卫拒(403) → originRejected(HTTP 侧 Origin 校验也是配对必验项)")
|
||||
func killRejectedByOriginGuardMapsToOriginRejected() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 403)
|
||||
await fixture.ws.emit(frame: Self.attachedFrame)
|
||||
|
||||
// Act
|
||||
let result = await runProbe(fixture)
|
||||
|
||||
// Assert
|
||||
guard case .failure(.originRejected) = result else {
|
||||
Issue.record("expected originRejected, got \(result)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("kill 返回 404(会话已自行退出) → 仍算全通(目标态就是会话不存在)")
|
||||
func killReturning404StillCountsAsSuccess() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 404)
|
||||
await fixture.ws.emit(frame: Self.attachedFrame)
|
||||
|
||||
// Act + Assert
|
||||
#expect(await runProbe(fixture) == .success(fixture.endpoint))
|
||||
}
|
||||
|
||||
// MARK: - classify 分支(POSIX 形状)
|
||||
|
||||
@Test("POSIX ETIMEDOUT → timeout;POSIX ECONNREFUSED → hostUnreachable")
|
||||
func posixTimeoutAndRefusedClassification() async throws {
|
||||
// Arrange
|
||||
let timeoutFixture = try makeFixture()
|
||||
await timeoutFixture.http.queueFailure(
|
||||
url: timeoutFixture.liveSessionsURL,
|
||||
error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ETIMEDOUT.rawValue))
|
||||
)
|
||||
let refusedFixture = try makeFixture()
|
||||
await refusedFixture.http.queueFailure(
|
||||
url: refusedFixture.liveSessionsURL,
|
||||
error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ECONNREFUSED.rawValue))
|
||||
)
|
||||
|
||||
// Act
|
||||
let timeoutResult = await runProbe(timeoutFixture)
|
||||
let refusedResult = await runProbe(refusedFixture)
|
||||
|
||||
// Assert
|
||||
#expect(timeoutResult == .failure(.timeout))
|
||||
guard case .failure(.hostUnreachable) = refusedResult else {
|
||||
Issue.record("expected hostUnreachable, got \(refusedResult)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("LAN/私网 host 判定表:RFC1918+link-local+loopback+CGNAT+.local 为真,公网/畸形为假")
|
||||
func privateOrLocalHostPredicateTable() {
|
||||
// Assert — LAN 级(localNetworkDenied 诊断适用)
|
||||
let lanHosts = [
|
||||
"localhost", "mac-mini.local", "10.0.0.5", "172.20.10.1",
|
||||
"192.168.0.9", "169.254.1.1", "100.100.1.1", "127.0.0.1",
|
||||
]
|
||||
for host in lanHosts {
|
||||
#expect(PairingError.isPrivateOrLocalHost(host), "\(host) 应判为私网级")
|
||||
}
|
||||
// Assert — 非 LAN(不得误报本地网络权限)
|
||||
let publicHosts = [
|
||||
"8.8.8.8", "203.0.113.7", "mac.tailnet.ts.net",
|
||||
"256.1.1.1", "172.32.0.1", "100.128.0.1", "1.2.3",
|
||||
]
|
||||
for host in publicHosts {
|
||||
#expect(!PairingError.isPrivateOrLocalHost(host), "\(host) 不应判为私网级")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 超时(注入 FakeClock,零真实等待)
|
||||
|
||||
@Test("超时:探针卡在等待 attached 时,注入的 FakeClock 到期 → .timeout")
|
||||
func probeTimesOutViaInjectedClockDeadline() async throws {
|
||||
// Arrange — ① 通过;② 连接成功但服务器永不回 attached → 探针挂起
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
|
||||
let clock = FakeClock()
|
||||
let probeTimeout: Duration = .seconds(10)
|
||||
|
||||
// Act — 等 deadline sleeper 停好(确定性栅栏),再推进时钟
|
||||
async let result = runProbe(fixture, timeout: probeTimeout, clock: clock)
|
||||
await clock.waitForSleepers(count: 1)
|
||||
clock.advance(by: probeTimeout)
|
||||
|
||||
// Assert
|
||||
#expect(await result == .failure(.timeout))
|
||||
}
|
||||
|
||||
// MARK: - fixtures
|
||||
|
||||
private static func networkDownError() -> NSError {
|
||||
let posixDown = NSError(
|
||||
domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ENETDOWN.rawValue)
|
||||
)
|
||||
return NSError(
|
||||
domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet,
|
||||
userInfo: [NSUnderlyingErrorKey: posixDown]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-8 · Origin-iff-G 铁律(plan §3.4/§5.1,安全语义):
|
||||
/// `Origin` header 出现**当且仅当(iff)** G(变更)端点。
|
||||
/// RO GET(liveSessions/preview/events/uiConfig)一律不带 Origin;
|
||||
/// G(killSession/hookDecision)必带且**逐字符等于** `endpoint.originHeader`。
|
||||
/// 这样服务器一旦把某 RO 端点改成 G,集成测试立刻红,而不是靠巧合通过。
|
||||
struct RequestBuilderTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
private static let emptyArrayBody = Data("[]".utf8)
|
||||
private static let previewBody = Data(
|
||||
#"{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cols":80,"rows":24,"data":"hi"}"#.utf8
|
||||
)
|
||||
private static let uiConfigBody = Data(#"{"allowAutoMode":false}"#.utf8)
|
||||
|
||||
private func makeEndpoint(_ base: String = RequestBuilderTests.base) throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private func routeURL(_ path: String, base: String = RequestBuilderTests.base) throws -> URL {
|
||||
try #require(URL(string: base + path))
|
||||
}
|
||||
|
||||
private func makeSessionId() throws -> UUID {
|
||||
try #require(UUID(uuidString: Self.sessionIdString))
|
||||
}
|
||||
|
||||
// MARK: - RO 侧(iff-G:无 Origin)
|
||||
|
||||
@Test("Origin iff-G(RO 侧):liveSessions/preview/events/uiConfig 四个 RO GET 一律不带 Origin")
|
||||
func readOnlyEndpointsNeverCarryOriginHeader() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let id = try makeSessionId()
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
|
||||
body: Self.previewBody
|
||||
)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
|
||||
body: Self.emptyArrayBody
|
||||
)
|
||||
await http.queueSuccess(url: try routeURL("/config/ui"), body: Self.uiConfigBody)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
_ = try await client.preview(id: id)
|
||||
_ = try await client.events(id: id)
|
||||
_ = try await client.uiConfig()
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 4)
|
||||
for request in requests {
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - G 侧(iff-G:必带 Origin,逐字符相等)
|
||||
|
||||
@Test("Origin iff-G(G 侧):killSession 为 DELETE /live-sessions/:id(小写 UUID),Origin 逐字符等于 endpoint.originHeader")
|
||||
func killSessionCarriesByteEqualOriginHeader() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let id = try makeSessionId()
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 204)
|
||||
|
||||
// Act
|
||||
try await client.killSession(id: id)
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 1)
|
||||
let request = try #require(requests.first)
|
||||
#expect(request.httpMethod == "DELETE")
|
||||
#expect(request.url == url) // 路径用小写 UUID —— 服务器按字符串精确匹配
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
|
||||
}
|
||||
|
||||
@Test("Origin iff-G(G 侧):hookDecision 为 POST /hook/decision,Origin 逐字符等于 endpoint.originHeader")
|
||||
func hookDecisionCarriesByteEqualOriginHeader() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/hook/decision")
|
||||
await http.queueSuccess(method: "POST", url: url, status: 204)
|
||||
|
||||
// Act
|
||||
try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1")
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url == url)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
|
||||
}
|
||||
|
||||
@Test("G 端点 Origin 对 https 默认端口省略 :443(无端口迷信,plan §5.1)")
|
||||
func httpsDefaultPortOriginOmitsPort443() async throws {
|
||||
// Arrange
|
||||
let base = "https://mac.tailnet.ts.net"
|
||||
let endpoint = try makeEndpoint(base)
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)", base: base)
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 204)
|
||||
|
||||
// Act
|
||||
try await client.killSession(id: try makeSessionId())
|
||||
|
||||
// Assert
|
||||
let origin = try #require(
|
||||
await http.recordedRequests.first?.value(forHTTPHeaderField: "Origin")
|
||||
)
|
||||
#expect(origin == "https://mac.tailnet.ts.net")
|
||||
#expect(!origin.contains(":443"))
|
||||
}
|
||||
|
||||
// MARK: - hookDecision body 形状与错误映射
|
||||
|
||||
@Test("hookDecision body 形状恰为 {sessionId,decision,token} 且 Content-Type 为 JSON(服务器限 4KB body、10 次/分/IP)")
|
||||
func hookDecisionBodyShapeIsExactlySessionIdDecisionToken() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 204)
|
||||
|
||||
// Act
|
||||
try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1")
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["sessionId", "decision", "token"]))
|
||||
#expect(object["sessionId"] as? String == Self.sessionIdString) // 小写 UUID
|
||||
#expect(object["decision"] as? String == "allow")
|
||||
#expect(object["token"] as? String == "tok-1")
|
||||
}
|
||||
|
||||
@Test("hookDecision 403 → decisionRejected 显式错误(token 已过期/已被处理,src/server.ts:522-525)")
|
||||
func hookDecision403ThrowsExplicitTokenRejectedError() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 403)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.decisionRejected) {
|
||||
try await client.hookDecision(
|
||||
sessionId: try self.makeSessionId(), decision: .deny, token: "expired"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("hookDecision 429 → rateLimited(≤10 次/分/IP,src/server.ts:72)")
|
||||
func hookDecision429ThrowsRateLimited() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 429)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.rateLimited) {
|
||||
try await client.hookDecision(
|
||||
sessionId: try self.makeSessionId(), decision: .allow, token: "tok"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("hookDecision 400(非法 body) → unexpectedStatus(400)")
|
||||
func hookDecision400ThrowsUnexpectedStatus() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 400)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unexpectedStatus(400)) {
|
||||
try await client.hookDecision(
|
||||
sessionId: try self.makeSessionId(), decision: .allow, token: "tok"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("killSession 意外状态码(500) → unexpectedStatus(500)")
|
||||
func killSession500ThrowsUnexpectedStatus() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 500)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
try await client.killSession(id: try self.makeSessionId())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("killSession 404 → sessionNotFound(会话已退出/被清理)")
|
||||
func killSession404ThrowsSessionNotFound() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint()
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
|
||||
await http.queueSuccess(method: "DELETE", url: url, status: 404)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.sessionNotFound) {
|
||||
try await client.killSession(id: try self.makeSessionId())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("baseURL 带尾斜杠时请求 URL 归一化(不产生 //live-sessions)")
|
||||
func trailingSlashBaseURLNormalizesRequestPath() async throws {
|
||||
// Arrange
|
||||
let endpoint = try makeEndpoint("http://192.168.1.5:3000/")
|
||||
let http = FakeHTTPTransport()
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.url?.absoluteString == "http://192.168.1.5:3000/live-sessions")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user