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.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent c4f8b5b47f
commit 850531fd07
33 changed files with 4191 additions and 106 deletions

View File

@@ -0,0 +1,435 @@
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()
}
}
}