Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift
Yaojia Wang 850531fd07 feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four
distinct outcomes from the frozen contract, Cookie/Accept landed at the same
single header choke point that already enforces Origin-iff-G, plus the whole
project-ops surface the server has had since late July and iOS consumed none of:
/projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/
fetch, worktree create/remove/prune, GET /sessions, follow-up queue.

HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain
under the existing SecItemShim conventions (device-only, never synchronizable),
charset/length validated at the boundary, old token-less records still decode.

SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from
the same point that writes Origin, and a 401 handshake is a terminal
.unauthorized -- never entering the backoff loop, since retrying one wrong
shared token is a brute-force generator against the server's 10/min limiter.
2026-07-30 12:45:26 +02:00

436 lines
18 KiB
Swift
Raw 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 Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · git ****ios-completion §1.2 = `src/`
/// - `GET /projects/log?path=[&n=]``src/server.ts:1030-1056` `GitLogResult`
/// `src/types.ts:757-772` w6/G4 `unpushed`/`upstream`
/// - `GET /projects/pr?path=``src/server.ts:1058-1076` `PrStatus`
/// `src/types.ts:617-648` git 200, body availability
/// - `GET /projects/worktree/state?path=``src/server.ts:542-562` `WorktreeState`
/// `src/types.ts:392-408``sync`
/// - `GET /sessions``src/server.ts:479-481` `[HistorySession]`
/// `src/http/history.ts:13-19``claude --resume`
///
/// `/projects/*` `path` 400 git 404
/// 500 RO ** Origin**
struct GitPanelReadTests {
private static let base = "http://192.168.1.5:3000"
private static let repoPath = "/Users/dev/web-terminal"
private static let encodedRepoPath = "%2FUsers%2Fdev%2Fweb-terminal"
private struct Fixture {
let http: FakeHTTPTransport
let client: APIClient
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http))
}
private func routeURL(_ pathAndQuery: String) throws -> URL {
try #require(URL(string: Self.base + pathAndQuery))
}
// MARK: - Origin iff-GRO Origin
@Test("Origin iff-G(RO 侧):log/pr/worktree-state/sessions 四个端点均为 GET 且不带 Origin")
func readOnlyGitEndpointsNeverCarryOrigin() async throws {
// Arrange
let fixture = try makeFixture()
let query = "?path=\(Self.encodedRepoPath)"
await fixture.http.queueSuccess(
url: try routeURL("/projects/log\(query)"), body: Data(#"{"commits":[]}"#.utf8)
)
await fixture.http.queueSuccess(
url: try routeURL("/projects/pr\(query)"), body: Data(#"{"availability":"no-pr"}"#.utf8)
)
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state\(query)"),
body: Data(#"{"path":"\#(Self.repoPath)"}"#.utf8)
)
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data("[]".utf8))
// Act
_ = try await fixture.client.gitLog(path: Self.repoPath)
_ = try await fixture.client.prStatus(path: Self.repoPath)
_ = try await fixture.client.worktreeState(path: Self.repoPath)
_ = try await fixture.client.claudeSessions()
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 4)
for request in requests {
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
}
}
// MARK: - /projects/log
@Test("log 的 path 严格 percent-encode(单点);n 缺省则不带 n 参数")
func logPathIsStrictlyEncodedAndNilCountOmitsTheParam() async throws {
// Arrange
let fixture = try makeFixture()
let raw = "/Users/dev/my proj+α"
let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1"
let expected = try routeURL("/projects/log?path=\(encoded)")
await fixture.http.queueSuccess(url: expected, body: Data(#"{"commits":[]}"#.utf8))
// Act
_ = try await fixture.client.gitLog(path: raw, n: nil)
// Assert
#expect(await fixture.http.recordedRequests.first?.url == expected)
}
@Test("log 的 n 客户端夹到 [1,50](镜像 src/http/git-log.ts GIT_LOG_MAX,服务端仍会再夹一次)")
func logCountIsClampedClientSide() async throws {
// Arrange
let fixture = try makeFixture()
let body = Data(#"{"commits":[]}"#.utf8)
let cases: [(Int, Int)] = [(0, 1), (-7, 1), (10, 10), (999, 50)]
for (_, clamped) in cases {
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)&n=\(clamped)"),
body: body
)
}
// Act
for (requested, _) in cases {
_ = try await fixture.client.gitLog(path: Self.repoPath, n: requested)
}
// Assert FakeHTTPTransport URL ,
let queries = await fixture.http.recordedRequests.compactMap(\.url?.query)
#expect(queries == cases.map { "path=\(Self.encodedRepoPath)&n=\($0.1)" })
}
@Test("GitLogResult 全字段解码(hash/at/subject/unpushed + truncated + upstream,w6/G4)")
func gitLogDecodesFullSample() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"commits":[{"hash":"abc1234","at":1720000000000,"subject":"feat: x","unpushed":true},\
{"hash":"def5678","at":1719990000000,"subject":"fix: y"}],\
"truncated":true,"upstream":"origin/develop"}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let result = try await fixture.client.gitLog(path: Self.repoPath)
// Assert
#expect(result.commits.count == 2)
#expect(result.truncated)
#expect(result.upstream == "origin/develop")
let first = try #require(result.commits.first)
#expect(first.hash == "abc1234")
#expect(first.at == 1_720_000_000_000)
#expect(first.subject == "feat: x")
#expect(first.unpushed == true)
#expect(result.commits.last?.unpushed == nil) // nil( false)
}
@Test("log 容忍:畸形 commit 逐条丢弃、subject 缺省为空、truncated 缺省 false、未知字段忽略")
func gitLogToleratesDegradedShapes() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"commits":[{"hash":"ok1","at":1,"subject":"s"},42,{"at":2},{"hash":"ok2","at":3},\
{"hash":"bad","at":"nope"}],"futureField":{"x":1}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let result = try await fixture.client.gitLog(path: Self.repoPath)
// Assert
#expect(result.commits.map(\.hash) == ["ok1", "ok2"])
#expect(result.commits.last?.subject == "")
#expect(!result.truncated)
#expect(result.upstream == nil)
}
@Test("log 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒")
func gitLogMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/log?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(
url: url, status: status, body: Data(#"{"error":"nope"}"#.utf8)
)
}
// Act + Assert
let expected: [APIClientError] = [
.projectPathInvalid, .projectNotFound, .gitDataUnavailable,
]
for error in expected {
await #expect(throws: error) {
_ = try await fixture.client.gitLog(path: Self.repoPath)
}
#expect(!error.message.isEmpty)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitLog(path: "")
}
#expect(await fixture.http.recordedRequests.count == 3) // path
}
@Test("log 200 但 body 非对象 → invalidResponseBody")
func gitLogRejectsNonObjectBody() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"),
body: Data("[1,2]".utf8)
)
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.gitLog(path: Self.repoPath)
}
}
// MARK: - /projects/pr
@Test("PrStatus 全字段解码(availability=ok + checks 嵌套计数)")
func prStatusDecodesFullSample() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"availability":"ok","number":42,"title":"feat: x","url":"https://github.com/a/b/pull/42",\
"state":"open","isDraft":false,"mergeable":"mergeable","headRefName":"feat/x",\
"baseRefName":"develop","checks":{"total":5,"passing":4,"failing":0,"pending":1}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/pr?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let pr = try await fixture.client.prStatus(path: Self.repoPath)
// Assert
#expect(pr.availability == .ok)
#expect(pr.number == 42)
#expect(pr.state == "open")
#expect(pr.isDraft == false)
#expect(pr.mergeable == "mergeable")
#expect(pr.headRefName == "feat/x")
#expect(pr.baseRefName == "develop")
#expect(pr.checks == PrCheckSummary(total: 5, passing: 4, failing: 0, pending: 1))
}
@Test("PrStatus :/ availability .error;; nil")
func prStatusDegradesUnknownAvailability() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
let bodies = [
#"{"availability":"quantum-ci"}"#, // 未来值 → .error
#"{}"#, // 缺失 → .error
#"{"availability":"not-installed"}"#,
#"{"availability":"unauthenticated"}"#,
#"{"availability":"disabled"}"#,
#"{"availability":"no-pr"}"#,
]
for body in bodies {
await fixture.http.queueSuccess(url: url, body: Data(body.utf8))
}
// Act
var seen: [PrAvailability] = []
for _ in bodies {
seen.append(try await fixture.client.prStatus(path: Self.repoPath).availability)
}
// Assert
#expect(seen == [.error, .error, .notInstalled, .unauthenticated, .disabled, .noPr])
// 非 ok 时兄弟字段一律缺省 → nil(绝不编造 PR 号)
await fixture.http.queueSuccess(url: url, body: Data(#"{"availability":"no-pr"}"#.utf8))
let degraded = try await fixture.client.prStatus(path: Self.repoPath)
#expect(degraded.number == nil)
#expect(degraded.checks == nil)
}
@Test("pr 400/404/500 projectPathInvalid/projectNotFound/gitDataUnavailable; path ")
func prStatusMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(url: url, status: status)
}
// Act + Assert
for error in [APIClientError.projectPathInvalid, .projectNotFound, .gitDataUnavailable] {
await #expect(throws: error) {
_ = try await fixture.client.prStatus(path: Self.repoPath)
}
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.prStatus(path: "")
}
}
// MARK: - /projects/worktree/state
@Test("WorktreeState ;sync.lastFetchMs fs.stat().mtimeMs ****")
func worktreeStateDecodesFullSampleIncludingFractionalMtime() async throws {
// Arrange — 1785390645813.5327 是真实 stat().mtimeMs 的形状(APFS 纳秒精度)
let fixture = try makeFixture()
let body = """
{"path":"\(Self.repoPath)","branch":"develop","dirtyCount":3,\
"sync":{"upstream":"origin/develop","ahead":2,"behind":1,\
"lastFetchMs":1785390645813.5327,"detached":false}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
body: Data(body.utf8)
)
// Act
let state = try await fixture.client.worktreeState(path: Self.repoPath)
// Assert
#expect(state.path == Self.repoPath)
#expect(state.branch == "develop")
#expect(state.dirtyCount == 3)
let sync = try #require(state.sync)
#expect(sync.upstream == "origin/develop")
#expect(sync.ahead == 2)
#expect(sync.behind == 1)
#expect(sync.detached == false)
let lastFetchMs = try #require(sync.lastFetchMs)
#expect(abs(lastFetchMs - 1_785_390_645_813.5327) < 0.001)
}
@Test("WorktreeState : upstream/detached HEAD/ fetch ")
func worktreeStateDegradesEachFieldIndependently() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"path":"\(Self.repoPath)","sync":{"detached":true},"unknownField":1}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
body: Data(body.utf8)
)
// Act
let state = try await fixture.client.worktreeState(path: Self.repoPath)
// Assert
#expect(state.branch == nil)
#expect(state.dirtyCount == nil)
let sync = try #require(state.sync)
#expect(sync.detached == true)
#expect(sync.upstream == nil)
#expect(sync.ahead == nil)
#expect(sync.behind == nil)
#expect(sync.lastFetchMs == nil) // 从未 fetch —— 绝不编造时间戳
}
@Test("worktree/state 400/404/500 projectPathInvalid/worktreeNotFound/gitDataUnavailable; body invalidResponseBody")
func worktreeStateMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(url: url, status: status)
}
await fixture.http.queueSuccess(url: url, body: Data("7".utf8))
// Act + Assert
for error in [APIClientError.projectPathInvalid, .worktreeNotFound, .gitDataUnavailable] {
await #expect(throws: error) {
_ = try await fixture.client.worktreeState(path: Self.repoPath)
}
#expect(!error.message.isEmpty)
}
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.worktreeState(path: Self.repoPath)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.worktreeState(path: "")
}
}
// MARK: - GET /sessionsclaude --resume 历史)
@Test("HistorySession :mtimeMs fs.stat() ****()")
func claudeSessionsDecodeFractionalMtime() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
[{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cwd":"/Users/dev/web-terminal",\
"project":"web-terminal","mtimeMs":1785390645813.5327,"preview":"修一下 CJK locale"},\
{"id":"11111111-2222-4333-8444-555555555555","cwd":"","project":"unknown",\
"mtimeMs":1700000000000,"preview":""}]
"""
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data(body.utf8))
// Act
let sessions = try await fixture.client.claudeSessions()
// Assert
#expect(sessions.count == 2)
let first = try #require(sessions.first)
#expect(first.id == "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b")
#expect(first.cwd == "/Users/dev/web-terminal")
#expect(first.project == "web-terminal")
#expect(abs(first.mtimeMs - 1_785_390_645_813.5327) < 0.001)
#expect(first.preview == " CJK locale")
#expect(sessions.last?.mtimeMs == 1_700_000_000_000) // 整数也要能解
}
@Test("/sessions : body invalidResponseBody")
func claudeSessionsToleratesDegradedShapes() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/sessions")
let degraded = """
[{"id":"keep-me","mtimeMs":1},"nope",{"cwd":"/x"},{"id":"","mtimeMs":2},\
{"id":"also-keep","mtimeMs":3,"extra":true}]
"""
await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
await fixture.http.queueSuccess(url: url, body: Data(#"{"error":"x"}"#.utf8))
// Act
let sessions = try await fixture.client.claudeSessions()
// Assert — id 是 `claude --resume <id>` 的实参,缺失/空 ⇒ 该条无用,丢弃
#expect(sessions.map(\.id) == ["keep-me", "also-keep"])
#expect(sessions.first?.cwd == "")
#expect(sessions.first?.project == "")
#expect(sessions.first?.preview == "")
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.claudeSessions()
}
}
@Test("/sessions 200 unexpectedStatus")
func claudeSessionsRejectsBadStatus() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: try routeURL("/sessions"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await fixture.client.claudeSessions()
}
}
}