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 stderr。403 是**重载**状态(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-G(G 侧:七条写路由全带 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("oops".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)) } /// F5 · 401 的归属必须**逐路由**钉死,不能按"写路由"一刀切。 /// /// 服务器只有一处会自己产出 401:`src/http/git-ops.ts:108`(主机侧 git 凭据失败), /// 而它只被 `stageFiles`/`commit`/`push`/`fetch` 走到。worktree 三条落在 /// `src/http/worktrees.ts`,那里根本不产 401(403 开关 / 400 / 404 / 500), /// `src/server.ts:1095-1183` 也没加。所以在启用访问令牌的主机上,worktree 的 401 /// 只可能是 gate —— 当成 git 失败会把用户卡在"创建 worktree 失败",而不是补令牌。 @Test("worktree 三条的 401 是访问令牌 gate(不是 git 拒绝);git-ops 四条才是路由自身语义") func worktreeRoutes401IsTheAccessTokenGateNotAGitRejection() async throws { // Arrange let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=") let gateBody = Data(#"{"error":"authentication required"}"#.utf8) await fixture.http.queueSuccess( method: "POST", url: try routeURL("/projects/worktree"), status: 401, body: gateBody ) await fixture.http.queueSuccess( method: "DELETE", url: try routeURL("/projects/worktree"), status: 401, body: gateBody ) await fixture.http.queueSuccess( method: "POST", url: try routeURL("/projects/worktree/prune"), status: 401, body: gateBody ) // Act + Assert await #expect(throws: APIClientError.unauthorized) { _ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil) } await #expect(throws: APIClientError.unauthorized) { _ = try await fixture.client.removeWorktree( path: Self.repoPath, worktreePath: "\(Self.repoPath)/wt", force: false ) } await #expect(throws: APIClientError.unauthorized) { _ = try await fixture.client.pruneWorktrees(path: Self.repoPath) } } @Test("git-ops 四条(stage/commit/push/fetch)全部保留路由自身的 401") func allFourGitOpsRoutesKeepTheirOwn401() async throws { // Arrange let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=") let message = "Push authentication required on the host." let body = Data(#"{"ok":false,"error":"\#(message)"}"#.utf8) for path in ["/projects/git/stage", "/projects/git/commit", "/projects/git/push", "/projects/git/fetch"] { await fixture.http.queueSuccess( method: "POST", url: try routeURL(path), status: 401, body: body ) } // Act + Assert — 服务器自己的话术必须原样到达 UI #expect( try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true) == .rejected(status: 401, message: message) ) #expect( try await fixture.client.gitCommit(path: Self.repoPath, message: "m") == .rejected(status: 401, message: message) ) #expect( try await fixture.client.gitPush(path: Self.repoPath) == .rejected(status: 401, message: message) ) #expect( try await fixture.client.gitFetch(path: Self.repoPath) == .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/queue(w2 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) } } }