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:
@@ -0,0 +1,314 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// B1 · 访问令牌(`WEBTERM_TOKEN`)—— ios-completion §1.1 冻结契约。
|
||||
///
|
||||
/// 服务端事实(`src/http/auth.ts` + `src/server.ts:393-460`):
|
||||
/// - `POST /auth`,body `{"token":"<t>"}`,`Content-Type: application/json`;
|
||||
/// - **`Accept` 必须不含 `text/html`** —— 含则被当成表单走 302,而不是 204/401;
|
||||
/// - 204 **有** `Set-Cookie: webterm_auth=…` ⇒ 令牌正确;
|
||||
/// - 204 **无** `Set-Cookie` ⇒ 该服务器**没开鉴权**(**不得**据此认为"已认证");
|
||||
/// - 401 ⇒ 令牌错;429 ⇒ 限流(10 次/分/IP)。
|
||||
///
|
||||
/// 原生客户端**自己知道令牌**,因此手写 `Cookie: webterm_auth=<t>`,不解析
|
||||
/// `Set-Cookie`、不依赖系统 cookie jar。`Cookie` 与 `Origin` **正交**:令牌
|
||||
/// **不替代** Origin 检查,两者都要带(`server.ts` 先 Origin 再 cookie)。
|
||||
///
|
||||
/// 安全:令牌**绝不进 URL query**(`?token=` bootstrap 只给浏览器用)、绝不进日志。
|
||||
struct AccessTokenTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
/// 合法形状:16–512 个 `[A-Za-z0-9._~+/=-]` 字符(CLAUDE.md / config 校验)。
|
||||
private static let token = "s3cret-token_value.~+/="
|
||||
private static let setCookieValue =
|
||||
"webterm_auth=\(token); Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict"
|
||||
|
||||
private func makeEndpoint(_ base: String = AccessTokenTests.base) throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private func routeURL(_ path: String) throws -> URL {
|
||||
try #require(URL(string: Self.base + path))
|
||||
}
|
||||
|
||||
private func makeClient(
|
||||
token: String?, http: FakeHTTPTransport
|
||||
) throws -> APIClient {
|
||||
APIClient(endpoint: try makeEndpoint(), http: http, accessToken: token)
|
||||
}
|
||||
|
||||
// MARK: - POST /auth 探针:请求形状
|
||||
|
||||
@Test("POST /auth:body 恰为 {\"token\":…}、Content-Type=JSON、Accept 不含 text/html、令牌绝不进 URL")
|
||||
func authProbeRequestShapeIsFrozen() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/auth"), status: 204,
|
||||
headers: ["Set-Cookie": Self.setCookieValue]
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url == (try routeURL("/auth")))
|
||||
#expect(request.url?.query == nil) // 令牌绝不进 URL query
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
// /auth 是变更型 POST(签发 cookie)⇒ 照 Origin-iff-G 规则带 Origin(与 Android 一致)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == (try makeEndpoint()).originHeader)
|
||||
let accept = try #require(request.value(forHTTPHeaderField: "Accept"))
|
||||
#expect(!accept.contains("text/html")) // 含 text/html 会被当成表单走 302
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["token"]))
|
||||
#expect(object["token"] as? String == Self.token)
|
||||
}
|
||||
|
||||
@Test("每个请求都带 Accept: application/json —— 未认证时服务器才回 401 JSON 而不是 302 登录页")
|
||||
func everyRequestAcceptsJSONSoTheGateAnswers401NotARedirect() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: Self.token, http: http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
|
||||
await http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
for request in requests {
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - POST /auth 探针:四种结局互不混淆
|
||||
|
||||
@Test("204 + Set-Cookie ⇒ .valid(令牌正确,可保存)")
|
||||
func probe204WithSetCookieMeansTokenValid() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/auth"), status: 204,
|
||||
headers: ["Set-Cookie": Self.setCookieValue]
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .valid)
|
||||
}
|
||||
|
||||
@Test("204 但无 Set-Cookie ⇒ .authDisabled(服务器没开鉴权),绝不报成 .valid")
|
||||
func probe204WithoutSetCookieMeansAuthDisabledNotAuthenticated() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 204)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .authDisabled)
|
||||
#expect(result != .valid) // 冻结契约:不得据此认为"已认证"
|
||||
}
|
||||
|
||||
@Test("401 ⇒ .invalidToken(不是 .unauthorized —— /auth 自己的 401 是路由语义)")
|
||||
func probe401MeansInvalidTokenNotGateUnauthorized() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/auth"), status: 401,
|
||||
body: Data(#"{"error":"invalid token"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .invalidToken)
|
||||
}
|
||||
|
||||
@Test("429 ⇒ .rateLimited(10 次/分/IP,src/server.ts:399-402)")
|
||||
func probe429MeansRateLimited() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 429)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .rateLimited)
|
||||
}
|
||||
|
||||
@Test("其他状态码(500) ⇒ unexpectedStatus,不猜测")
|
||||
func probeUnexpectedStatusThrows() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 500)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
_ = try await client.probeAccessToken(Self.token)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("形状非法的令牌(过短/越界字符/过长)联网前就拒:malformedToken,零请求")
|
||||
func malformedTokenIsRejectedBeforeAnyNetworkIO() async throws {
|
||||
// Arrange — 服务器启动时就校验 16–512 与字符集,故形状非法者不可能是正确令牌
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
let malformed = [
|
||||
"short", // < 16
|
||||
String(repeating: "a", count: 513), // > 512
|
||||
"has space in it x", // 越界字符:空格
|
||||
"has\r\nCRLF-injection-x", // 越界字符:CRLF(头注入)
|
||||
"中文令牌中文令牌中文令牌中文令牌", // 越界字符:非 ASCII
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for candidate in malformed {
|
||||
await #expect(throws: APIClientError.malformedToken) {
|
||||
_ = try await client.probeAccessToken(candidate)
|
||||
}
|
||||
}
|
||||
#expect(await http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Cookie 与 Origin 正交
|
||||
|
||||
@Test("Cookie iff 配置了令牌:RO 带 Cookie 不带 Origin;G 带 Cookie **并且**带 Origin")
|
||||
func cookieIsOrthogonalToTheOriginRule() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let endpoint = try makeEndpoint()
|
||||
let client = APIClient(endpoint: endpoint, http: http, accessToken: Self.token)
|
||||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
|
||||
await http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
try await client.killSession(id: id)
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
let readOnly = try #require(requests.first)
|
||||
let guarded = try #require(requests.last)
|
||||
#expect(readOnly.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) // RO 仍绝不带 Origin
|
||||
#expect(guarded.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
#expect(guarded.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) // 令牌不替代 Origin
|
||||
}
|
||||
|
||||
@Test("未配置令牌 ⇒ 完全不带 Cookie 头(LAN 零配置行为不变)")
|
||||
func noTokenConfiguredMeansNoCookieHeaderAtAll() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
}
|
||||
|
||||
@Test("配置了形状非法的令牌 ⇒ 任何调用联网前抛 malformedToken(绝不静默发无认证请求)")
|
||||
func malformedConfiguredTokenFailsFastOnEveryCall() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: "bad token", http: http)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.malformedToken) {
|
||||
_ = try await client.liveSessions()
|
||||
}
|
||||
#expect(await http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 401 语义:类型化 .unauthorized,可与网络错区分
|
||||
|
||||
@Test("普通 RO 端点收 401 ⇒ 类型化 .unauthorized(不是 unexpectedStatus/网络错)")
|
||||
func gate401OnReadOnlyEndpointSurfacesAsUnauthorized() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions"), status: 401,
|
||||
body: Data(#"{"error":"authentication required"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await client.liveSessions()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("普通 G 端点收 401 ⇒ 同样是 .unauthorized,且话术非空(UI 引导补令牌)")
|
||||
func gate401OnGuardedEndpointSurfacesAsUnauthorized() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: Self.token, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 401
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
}
|
||||
#expect(!APIClientError.unauthorized.message.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 令牌零泄漏
|
||||
|
||||
@Test("APIClient 的 description/debugDescription 不含令牌明文(零日志泄漏)")
|
||||
func clientDescriptionNeverLeaksTheToken() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: Self.token, http: http)
|
||||
|
||||
// Act
|
||||
let rendered = "\(client)" + String(reflecting: client)
|
||||
|
||||
// Assert
|
||||
#expect(!rendered.contains(Self.token))
|
||||
#expect(rendered.contains("redacted"))
|
||||
}
|
||||
|
||||
@Test("hasAccessToken 只暴露有无,不暴露值")
|
||||
func hasAccessTokenExposesPresenceOnly() async throws {
|
||||
// Arrange + Act
|
||||
let http = FakeHTTPTransport()
|
||||
let withToken = try makeClient(token: Self.token, http: http)
|
||||
let without = try makeClient(token: nil, http: http)
|
||||
|
||||
// Assert
|
||||
#expect(withToken.hasAccessToken)
|
||||
#expect(!without.hasAccessToken)
|
||||
}
|
||||
}
|
||||
@@ -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-G(RO 侧:四个端点一律不带 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 /sessions(claude --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()
|
||||
}
|
||||
}
|
||||
}
|
||||
459
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal file
459
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal 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 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("<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/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,67 @@ struct ProjectsTests {
|
||||
#expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → []
|
||||
}
|
||||
|
||||
@Test("回归:lastActiveMs 来自 fs.stat().mtimeMs —— **带小数**必须解出来(否则真机上排序键永远为 nil)")
|
||||
func lastActiveMsDecodesFractionalStatMtime() async throws {
|
||||
// Arrange — 真实服务器值形如 1785390645813.5327(APFS 纳秒精度);
|
||||
// 旧实现 decode(Int.self) 对小数直接失败,被 try? 吞成 nil。
|
||||
let body = """
|
||||
[{"name":"a","path":"/a","isGit":true,"lastActiveMs":1785390645813.5327,\
|
||||
"lastCommitMs":1720000000000,"ahead":2,"behind":0,"sessions":[]}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||||
|
||||
// Assert
|
||||
let project = try #require(projects.first)
|
||||
#expect(project.lastActiveMs == 1_785_390_645_813)
|
||||
#expect(project.lastCommitMs == 1_720_000_000_000)
|
||||
#expect(project.ahead == 2)
|
||||
#expect(project.behind == 0)
|
||||
}
|
||||
|
||||
@Test("session ref 的 cwd(w6/G7)可选解码:有则解出,缺失 → nil")
|
||||
func sessionRefDecodesOptionalCwd() async throws {
|
||||
// Arrange
|
||||
let body = """
|
||||
[{"name":"a","path":"/a","isGit":true,"sessions":[\
|
||||
{"id":"\(Self.sessionIdString)","status":"idle","clientCount":0,"createdAt":1,\
|
||||
"exited":false,"cwd":"/a/.claude/worktrees/x"}]}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||||
|
||||
// Assert
|
||||
#expect(projects.first?.sessions.first?.cwd == "/a/.claude/worktrees/x")
|
||||
}
|
||||
|
||||
@Test("detail 的 dirtyCount / sync(w6/G1)可选解码,与 worktree/state 用同一 SyncState")
|
||||
func projectDetailDecodesDirtyCountAndSync() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let body = """
|
||||
{"name":"a","path":"/a","isGit":true,"dirty":true,"dirtyCount":7,\
|
||||
"sync":{"upstream":"origin/main","ahead":1,"behind":0,"lastFetchMs":1785390645813.5327},\
|
||||
"worktrees":[],"sessions":[],"hasClaudeMd":false}
|
||||
"""
|
||||
await fixture.http.queueSuccess(
|
||||
url: try routeURL("/projects/detail?path=%2Fa"), body: Data(body.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let detail = try await fixture.client.projectDetail(path: "/a")
|
||||
|
||||
// Assert
|
||||
#expect(detail.dirtyCount == 7)
|
||||
let sync = try #require(detail.sync)
|
||||
#expect(sync.upstream == "origin/main")
|
||||
#expect(sync.ahead == 1)
|
||||
#expect(sync.behind == 0)
|
||||
#expect(sync.lastFetchMs != nil)
|
||||
}
|
||||
|
||||
@Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus")
|
||||
func projectsRejectsNonArrayBodyAndBadStatus() async throws {
|
||||
// Act + Assert — 非数组
|
||||
|
||||
Reference in New Issue
Block a user