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.
351 lines
15 KiB
Swift
351 lines
15 KiB
Swift
import Foundation
|
||
import Testing
|
||
import TestSupport
|
||
import WireProtocol
|
||
import APIClient
|
||
|
||
/// T-iOS-38 · Projects 契约增量(RO,无 Origin):
|
||
/// - `GET /projects`(src/server.ts:262-269)→ `[ProjectInfo]`(src/types.ts:273-281 实际形状:
|
||
/// name/path/isGit 必填,branch/dirty/lastActiveMs 可选,sessions 为 ProjectSessionRef[];
|
||
/// 无 namespace 字段 —— namespace 是 web 端分组概念,只出现在 prefs.collapsed 的 key 里);
|
||
/// - `GET /projects/detail?path=`(src/server.ts:293-310)→ `ProjectDetail`;
|
||
/// path 的 percent-encoding **只在 builder 一处**处理;400/404/500 `{error}` → 显式类型化错误。
|
||
/// 服务器是不可信输入源:未知字段忽略、畸形条目逐条丢弃、绝不 crash。
|
||
struct ProjectsTests {
|
||
private static let base = "http://192.168.1.5:3000"
|
||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||
|
||
private static let fullProjectJSON = """
|
||
{"name":"web-terminal","path":"/Users/dev/web-terminal","isGit":true,\
|
||
"branch":"main","dirty":true,"lastActiveMs":1720000000000,\
|
||
"sessions":[{"id":"\(sessionIdString)","title":"web-terminal","status":"working",\
|
||
"clientCount":2,"createdAt":1719990000000,"exited":false}]}
|
||
"""
|
||
|
||
private static let minimalProjectJSON = """
|
||
{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[]}
|
||
"""
|
||
|
||
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))
|
||
}
|
||
|
||
private func fetchProjects(_ fixture: Fixture, body: String) async throws -> [ProjectInfo] {
|
||
await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data(body.utf8))
|
||
return try await fixture.client.projects()
|
||
}
|
||
|
||
// MARK: - Origin iff-G(两个新 RO 端点都不带 Origin)
|
||
|
||
@Test("Origin iff-G(RO 侧):GET /projects 与 GET /projects/detail 均不带 Origin")
|
||
func projectsEndpointsAreReadOnlyWithoutOrigin() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data("[]".utf8))
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
|
||
body: Data(Self.detailJSON.utf8)
|
||
)
|
||
|
||
// Act
|
||
_ = try await fixture.client.projects()
|
||
_ = try await fixture.client.projectDetail(path: "/Users/dev/notes")
|
||
|
||
// Assert
|
||
let requests = await fixture.http.recordedRequests
|
||
#expect(requests.count == 2)
|
||
for request in requests {
|
||
#expect(request.httpMethod == "GET")
|
||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - /projects 解码
|
||
|
||
@Test("ProjectInfo 全字段样本解码(src/types.ts:273-281 实际形状,含 sessions ref)")
|
||
func projectInfoDecodesFullSample() async throws {
|
||
// Act
|
||
let projects = try await fetchProjects(try makeFixture(), body: "[\(Self.fullProjectJSON)]")
|
||
|
||
// Assert
|
||
let expected = ProjectInfo(
|
||
name: "web-terminal",
|
||
path: "/Users/dev/web-terminal",
|
||
isGit: true,
|
||
branch: "main",
|
||
dirty: true,
|
||
lastActiveMs: 1_720_000_000_000,
|
||
sessions: [ProjectSessionRef(
|
||
id: try #require(UUID(uuidString: Self.sessionIdString)),
|
||
title: "web-terminal",
|
||
status: .working,
|
||
clientCount: 2,
|
||
createdAt: 1_719_990_000_000,
|
||
exited: false
|
||
)]
|
||
)
|
||
#expect(projects == [expected])
|
||
}
|
||
|
||
@Test("可选字段缺省(branch/dirty/lastActiveMs)与未知字段(向前兼容)均容忍,不丢条目")
|
||
func optionalFieldsDegradeAndUnknownFieldsAreIgnored() async throws {
|
||
// Arrange — 未知字段模拟未来服务器增量
|
||
let body = """
|
||
[{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[],\
|
||
"futureField":{"nested":true},"favouriteRank":3}]
|
||
"""
|
||
|
||
// Act
|
||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||
|
||
// Assert
|
||
let project = try #require(projects.first)
|
||
#expect(project.branch == nil)
|
||
#expect(project.dirty == nil)
|
||
#expect(project.lastActiveMs == nil)
|
||
#expect(project.sessions.isEmpty)
|
||
}
|
||
|
||
@Test("畸形条目(缺必填/类型错/非对象)逐条丢弃,合法条目保留,不 crash")
|
||
func malformedProjectEntriesAreDroppedWhileValidOnesSurvive() async throws {
|
||
// Arrange
|
||
let body = """
|
||
[\(Self.fullProjectJSON),42,{"name":"x"},\
|
||
{"name":1,"path":"/p","isGit":true,"sessions":[]},\(Self.minimalProjectJSON)]
|
||
"""
|
||
|
||
// Act
|
||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||
|
||
// Assert
|
||
#expect(projects.map(\.name) == ["web-terminal", "notes"])
|
||
}
|
||
|
||
@Test("session ref:未知 status → .unknown;畸形 ref 丢弃但项目保留;sessions 缺失 → 空数组")
|
||
func sessionRefsAreTolerantlyDecoded() async throws {
|
||
// Arrange
|
||
let body = """
|
||
[{"name":"a","path":"/a","isGit":true,"sessions":[\
|
||
{"id":"\(Self.sessionIdString)","status":"hyperdrive","clientCount":0,\
|
||
"createdAt":1,"exited":false},\
|
||
{"id":"not-a-uuid","status":"idle","clientCount":0,"createdAt":2,"exited":true}]},\
|
||
{"name":"b","path":"/b","isGit":false}]
|
||
"""
|
||
|
||
// Act
|
||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||
|
||
// Assert
|
||
#expect(projects.count == 2)
|
||
let first = try #require(projects.first)
|
||
#expect(first.sessions.count == 1) // 坏 UUID 的 ref 被丢弃
|
||
#expect(first.sessions.first?.status == .unknown)
|
||
#expect(first.sessions.first?.title == nil)
|
||
#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 — 非数组
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await self.fetchProjects(try self.makeFixture(), body: #"{"error":"x"}"#)
|
||
}
|
||
// Act + Assert — 非 200
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(url: try routeURL("/projects"), status: 500)
|
||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||
_ = try await fixture.client.projects()
|
||
}
|
||
}
|
||
|
||
// MARK: - /projects/detail:percent-encoding 单点处理
|
||
|
||
@Test("detail 的 path percent-encoding 只在 builder 一处:空格/+/&/=/非 ASCII 全部严格编码")
|
||
func detailPathIsStrictlyPercentEncodedOnceInBuilder() async throws {
|
||
// Arrange — '+' 必须编码为 %2B(Express qs 会把裸 '+' 解成空格);α = %CE%B1
|
||
let fixture = try makeFixture()
|
||
let rawPath = "/Users/dev/my proj+α&x=1"
|
||
let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1%26x%3D1"
|
||
let expectedURL = try routeURL("/projects/detail?path=\(encoded)")
|
||
await fixture.http.queueSuccess(url: expectedURL, body: Data(Self.detailJSON.utf8))
|
||
|
||
// Act
|
||
_ = try await fixture.client.projectDetail(path: rawPath)
|
||
|
||
// Assert — FakeHTTPTransport 按整 URL 精确匹配,能走到这里即编码逐字节正确
|
||
let request = try #require(await fixture.http.recordedRequests.first)
|
||
#expect(request.url == expectedURL)
|
||
}
|
||
|
||
// MARK: - /projects/detail 解码与错误映射
|
||
|
||
private static let detailJSON = """
|
||
{"name":"notes","path":"/Users/dev/notes","isGit":true,"branch":"main","dirty":false,\
|
||
"worktrees":[{"path":"/Users/dev/notes","branch":"main","head":"abc1234",\
|
||
"isMain":true,"isCurrent":true},\
|
||
{"path":"/Users/dev/notes-wt","isMain":false,"isCurrent":false,"locked":true}],\
|
||
"sessions":[],"hasClaudeMd":true,"claudeMd":"# Notes"}
|
||
"""
|
||
|
||
@Test("ProjectDetail 全字段解码(worktrees 含可选字段缺省;src/types.ts:296-306)")
|
||
func projectDetailDecodesFullSample() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
|
||
body: Data(Self.detailJSON.utf8)
|
||
)
|
||
|
||
// Act
|
||
let detail = try await fixture.client.projectDetail(path: "/Users/dev/notes")
|
||
|
||
// Assert
|
||
#expect(detail.name == "notes")
|
||
#expect(detail.isGit)
|
||
#expect(detail.branch == "main")
|
||
#expect(detail.dirty == false)
|
||
#expect(detail.hasClaudeMd)
|
||
#expect(detail.claudeMd == "# Notes")
|
||
#expect(detail.worktrees.count == 2)
|
||
let second = try #require(detail.worktrees.last)
|
||
#expect(second.branch == nil) // detached HEAD → branch 缺省
|
||
#expect(second.locked == true)
|
||
#expect(second.prunable == nil)
|
||
#expect(!second.isMain)
|
||
}
|
||
|
||
@Test("detail 容忍:hasClaudeMd 缺失 → false;畸形 worktree 条目丢弃;200 但 body 畸形 → invalidResponseBody")
|
||
func projectDetailToleratesDegradedShapes() async throws {
|
||
// Arrange — worktrees 里混入缺 path 的畸形条目
|
||
let fixture = try makeFixture()
|
||
let degraded = """
|
||
{"name":"a","path":"/a","isGit":false,\
|
||
"worktrees":[{"isMain":true},{"path":"/a","isMain":true,"isCurrent":true}],"sessions":[]}
|
||
"""
|
||
let url = try routeURL("/projects/detail?path=%2Fa")
|
||
await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
|
||
await fixture.http.queueSuccess(url: url, body: Data("[1,2]".utf8))
|
||
|
||
// Act
|
||
let detail = try await fixture.client.projectDetail(path: "/a")
|
||
|
||
// Assert
|
||
#expect(detail.hasClaudeMd == false)
|
||
#expect(detail.claudeMd == nil)
|
||
#expect(detail.worktrees.count == 1)
|
||
// Act + Assert — 200 但整体形状不对
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await fixture.client.projectDetail(path: "/a")
|
||
}
|
||
}
|
||
|
||
@Test("detail 400/404/500 {error} → projectPathInvalid/projectNotFound/projectDetailUnavailable,各有非空话术")
|
||
func projectDetailMapsErrorStatusesToTypedErrors() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let url = try routeURL("/projects/detail?path=%2Fgone")
|
||
await fixture.http.queueSuccess(url: url, status: 400, body: Data(#"{"error":"path query parameter is required"}"#.utf8))
|
||
await fixture.http.queueSuccess(url: url, status: 404, body: Data(#"{"error":"project not found"}"#.utf8))
|
||
await fixture.http.queueSuccess(url: url, status: 500, body: Data(#"{"error":"failed to read project detail"}"#.utf8))
|
||
|
||
// Act + Assert
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.projectDetail(path: "/gone")
|
||
}
|
||
await #expect(throws: APIClientError.projectNotFound) {
|
||
_ = try await fixture.client.projectDetail(path: "/gone")
|
||
}
|
||
await #expect(throws: APIClientError.projectDetailUnavailable) {
|
||
_ = try await fixture.client.projectDetail(path: "/gone")
|
||
}
|
||
for error in [APIClientError.projectPathInvalid, .projectNotFound, .projectDetailUnavailable] {
|
||
#expect(!error.message.isEmpty)
|
||
}
|
||
}
|
||
|
||
@Test("空 path → projectPathInvalid,联网前拒绝(镜像服务器 400 规则)")
|
||
func emptyDetailPathIsRejectedBeforeNetwork() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
|
||
// Act + Assert
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.projectDetail(path: "")
|
||
}
|
||
#expect(await fixture.http.recordedRequests.isEmpty)
|
||
}
|
||
}
|