Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.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

460 lines
21 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 · **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)
}
}
}