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,459 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · **G** git + worktree + w2
/// ios-completion §1.2 = `src/`
///
/// - `POST /projects/git/stage``src/server.ts:1184-1218`body `{path,files,stage}`
/// - `POST /projects/git/commit``:1219-1255`body `{path,message}`
/// - `POST /projects/git/push``:1256-1289`body `{path}`
/// - `POST /projects/git/fetch``:1290-1319`body `{path}`
/// - `POST /projects/worktree``:1095-1123`body `{path,branch[,base]}`
/// - `DELETE /projects/worktree``:1124-1153`body `{path,worktreePath,force}`
/// - `POST /projects/worktree/prune``:1154-1183`body `{path}`
/// - `POST /live-sessions/:id/queue``:605-643`body `{text[,appendEnter]}`
///
/// ** `Origin`** body **** `error`
/// `src/http/git-ops.ts` / `worktrees.ts`SEC-M10
/// git stderr403 ****Origin ****
/// 403 message
struct GitWriteTests {
private static let base = "http://192.168.1.5:3000"
private static let repoPath = "/Users/dev/web-terminal"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
private struct Fixture {
let http: FakeHTTPTransport
let endpoint: HostEndpoint
let client: APIClient
}
private func makeFixture(accessToken: String? = nil) throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(
http: http, endpoint: endpoint,
client: APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
)
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func bodyObject(_ request: URLRequest) throws -> [String: Any] {
let body = try #require(request.httpBody)
return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
}
// MARK: - Origin iff-GG Origin,)
@Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type")
func everyGitWriteRouteCarriesByteEqualOrigin() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
)
// Act
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true)
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x")
_ = try await fixture.client.gitPush(path: Self.repoPath)
_ = try await fixture.client.gitFetch(path: Self.repoPath)
_ = try await fixture.client.createWorktree(
path: Self.repoPath, branch: "wt-x", base: nil
)
_ = try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false
)
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 7)
for request in requests {
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
}
}
@Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)")
func gitWriteCarriesCookieAlongsideOrigin() async throws {
// Arrange
let token = "s3cret-token_value.~+/="
let fixture = try makeFixture(accessToken: token)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8)
)
// Act
_ = try await fixture.client.gitPush(path: Self.repoPath)
// Assert
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)")
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
}
// MARK: - body
@Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}")
func gitWriteBodyShapesAreExact() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
)
// Act
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false)
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文")
_ = try await fixture.client.gitPush(path: Self.repoPath)
_ = try await fixture.client.gitFetch(path: Self.repoPath)
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
// Assert
let requests = await fixture.http.recordedRequests
let stage = try bodyObject(try #require(requests.first))
#expect(Set(stage.keys) == Set(["path", "files", "stage"]))
#expect(stage["path"] as? String == Self.repoPath)
#expect(stage["files"] as? [String] == ["a.txt", "b/c.md"])
#expect(stage["stage"] as? Bool == false)
let commit = try bodyObject(requests[1])
#expect(Set(commit.keys) == Set(["path", "message"]))
#expect(commit["message"] as? String == "fix: 修中文")
for index in 2...4 {
let single = try bodyObject(requests[index])
#expect(Set(single.keys) == Set(["path"]))
#expect(single["path"] as? String == Self.repoPath)
}
}
@Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}")
func worktreeBodyShapesAreExact() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
// Act
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil)
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop")
_ = try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "/tmp/wt", force: true
)
// Assert
let requests = await fixture.http.recordedRequests
let withoutBase = try bodyObject(try #require(requests.first))
#expect(Set(withoutBase.keys) == Set(["path", "branch"]))
#expect(withoutBase["branch"] as? String == "wt-a")
let withBase = try bodyObject(requests[1])
#expect(Set(withBase.keys) == Set(["path", "branch", "base"]))
#expect(withBase["base"] as? String == "develop")
let remove = try bodyObject(requests[2])
#expect(Set(remove.keys) == Set(["path", "worktreePath", "force"]))
#expect(remove["worktreePath"] as? String == "/tmp/wt")
#expect(remove["force"] as? Bool == true)
#expect(requests[2].httpMethod == "DELETE") // DELETE **** JSON body
}
// MARK: - 200 payload body
@Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状")
func successPayloadsDecodePerRoute() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/stage"),
body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"),
body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"),
body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/fetch"),
body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree"),
body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "DELETE", url: try routeURL("/projects/worktree"),
body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"),
body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8)
)
// Act + Assert
#expect(
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
== .ok(StageResult(staged: true, count: 2))
)
#expect(
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
== .ok(CommitResult(commit: "abc1234"))
)
#expect(
try await fixture.client.gitPush(path: Self.repoPath)
== .ok(PushResult(branch: "develop", remote: "origin"))
)
let fetched = try await fixture.client.gitFetch(path: Self.repoPath)
guard case .ok(let fetchResult) = fetched else {
Issue.record("fetch 应为 .ok")
return
}
#expect(fetchResult.remote == "origin")
// lastFetchMs fs.stat().mtimeMs ,
#expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001)
#expect(
try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
== .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x"))
)
#expect(
try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "/tmp/wt", force: false
) == .ok(RemoveWorktreeResult(path: "/tmp/wt"))
)
#expect(
try await fixture.client.pruneWorktrees(path: Self.repoPath)
== .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"]))
)
}
@Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛")
func garbledSuccessPayloadDegradesToDefaults() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"),
body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8)
)
// Act + Assert
#expect(
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
== .ok(CommitResult(commit: ""))
)
#expect(
try await fixture.client.pruneWorktrees(path: Self.repoPath)
== .ok(PruneWorktreesResult(pruned: []))
)
}
// MARK: - 429 message
@Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试")
func rateLimitedIsItsOwnOutcome() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), status: 429,
body: Data(#"{"error":"Too many requests."}"#.utf8)
)
// Act
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
// Assert
#expect(outcome == .rateLimited)
}
@Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)")
func failuresCarryTheServerSafeMessageVerbatim() async throws {
// Arrange
let fixture = try makeFixture()
let cases: [(Int, String)] = [
(403, "Git operations are disabled."),
(409, "Nothing staged to commit."),
(400, "Set a git author identity (user.name / user.email) first."),
(500, "Git operation failed."),
]
for (status, message) in cases {
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"), status: status,
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
)
}
// Act + Assert
for (status, message) in cases {
let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
#expect(outcome == .rejected(status: status, message: message))
}
}
@Test("失败 body 无法解析 → .rejected(status, nil),不编造原因")
func unparseableFailureBodyYieldsNilMessage() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree"), status: 500,
body: Data("<html>oops</html>".utf8)
)
// Act
let outcome = try await fixture.client.createWorktree(
path: Self.repoPath, branch: "x", base: nil
)
// Assert
#expect(outcome == .rejected(status: 500, message: nil))
}
@Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401")
func push401IsRouteClassifiedNotTheAccessTokenGate() async throws {
// Arrange src/http/git-ops.ts:108 git 401
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
let message = "Push authentication required on the host."
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), status: 401,
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
)
// Act
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
// Assert gate .unauthorized,
#expect(outcome == .rejected(status: 401, message: message))
}
@Test("空 path 联网前拒(projectPathInvalid),七条写路由一致")
func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws {
// Arrange
let fixture = try makeFixture()
// Act + Assert
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitCommit(path: "", message: "m")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitPush(path: "")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitFetch(path: "")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.pruneWorktrees(path: "")
}
#expect(await fixture.http.recordedRequests.isEmpty)
}
// MARK: - POST /live-sessions/:id/queuew2 pty
@Test("queue 路由:POST /live-sessions/<小写 UUID>/queue,带 Origin,body 恰为 {text,appendEnter}")
func queueRouteShapeIsExact() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
await fixture.http.queueSuccess(
method: "POST", url: url, body: Data(#"{"length":2}"#.utf8)
)
// Act
let depth = try await fixture.client.enqueueFollowup(
sessionId: id, text: "继续", appendEnter: true
)
// Assert
#expect(depth == 2)
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == url) // :id
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
let body = try bodyObject(request)
#expect(Set(body.keys) == Set(["text", "appendEnter"]))
#expect(body["text"] as? String == "继续")
#expect(body["appendEnter"] as? Bool == true)
}
@Test("queue 错误映射:400/403/404/409/413/429/503 各自类型化,话术非空")
func queueMapsEveryServerStatusToATypedError() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
let cases: [(Int, APIClientError)] = [
(400, .queueTextInvalid),
(403, .forbidden),
(404, .sessionNotFound),
(409, .queueFull),
(413, .queueTextTooLarge),
(429, .rateLimited),
(503, .queueDisabled),
(500, .unexpectedStatus(500)),
]
for (status, _) in cases {
await fixture.http.queueSuccess(method: "POST", url: url, status: status)
}
// Act + Assert
for (_, expected) in cases {
await #expect(throws: expected) {
_ = try await fixture.client.enqueueFollowup(
sessionId: id, text: "x", appendEnter: false
)
}
#expect(!expected.message.isEmpty)
}
}
@Test("queue 空 text 联网前拒(镜像服务器 400 规则);200 但 body 无 length → invalidResponseBody")
func queueRejectsEmptyTextAndGarbledSuccessBody() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/live-sessions/\(Self.sessionIdString)/queue"),
body: Data(#"{"ok":true}"#.utf8)
)
// Act + Assert
await #expect(throws: APIClientError.queueTextInvalid) {
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "", appendEnter: true)
}
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "x", appendEnter: false)
}
}
}