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:
@@ -3,23 +3,46 @@ import WireProtocol
|
||||
|
||||
/// Typed client for the server's HTTP surface (frozen contract, plan §3.4).
|
||||
///
|
||||
/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: only the two G (state-changing)
|
||||
/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do.
|
||||
/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is
|
||||
/// single-point derived by `HostEndpoint` (never hand-assembled).
|
||||
/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: `Origin: endpoint.originHeader` is
|
||||
/// stamped **iff** the route is G (state-changing); RO GETs never carry it.
|
||||
/// Stamping lives in ONE place — `APIRoute.urlRequest(for:accessToken:)` — and
|
||||
/// the value is single-point derived by `HostEndpoint` (never hand-assembled).
|
||||
/// The optional access-token `Cookie` is stamped at that same single point and is
|
||||
/// **orthogonal**: it never replaces Origin (ios-completion §1.1).
|
||||
///
|
||||
/// The server is an UNTRUSTED input source at this boundary (plan §4): bodies
|
||||
/// are decoded tolerantly (malformed entries dropped), statuses are mapped to
|
||||
/// explicit `APIClientError`s, and nothing here ever crashes on bad input.
|
||||
public struct APIClient: Sendable {
|
||||
public struct APIClient: Sendable, CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public let endpoint: HostEndpoint
|
||||
private let http: any HTTPTransport
|
||||
/// The host's optional shared access token (`WEBTERM_TOKEN`, ios-completion
|
||||
/// §1.1). SECRET: private, never printed (see `description`), never put in a
|
||||
/// URL, only ever leaving as a `Cookie` header stamped in `APIRoute`.
|
||||
/// nil = the host has no token configured (LAN zero-config).
|
||||
private let accessToken: String?
|
||||
|
||||
public init(endpoint: HostEndpoint, http: any HTTPTransport) {
|
||||
public init(endpoint: HostEndpoint, http: any HTTPTransport, accessToken: String? = nil) {
|
||||
self.endpoint = endpoint
|
||||
self.http = http
|
||||
self.accessToken = accessToken
|
||||
}
|
||||
|
||||
/// Whether this client carries an access token — PRESENCE only. There is
|
||||
/// deliberately no getter for the value: the token leaves this type solely
|
||||
/// as a `Cookie` header (plan §5: never log, never a URL, never a report).
|
||||
public var hasAccessToken: Bool { accessToken != nil }
|
||||
|
||||
/// Redacted on purpose: the default reflection-based description of a
|
||||
/// struct holding a secret would print it into any log line that
|
||||
/// interpolates the client.
|
||||
public var description: String {
|
||||
"APIClient(origin: \(endpoint.originHeader), accessToken: "
|
||||
+ (accessToken == nil ? "none)" : "<redacted>)")
|
||||
}
|
||||
|
||||
public var debugDescription: String { description }
|
||||
|
||||
// MARK: - RO (read-only — NO Origin header)
|
||||
|
||||
/// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every
|
||||
@@ -107,11 +130,35 @@ public struct APIClient: Sendable {
|
||||
|
||||
// MARK: - Internals (shared with the P1 feature files, T-iOS-38)
|
||||
|
||||
/// The ONE request choke point. Two structural guarantees live here:
|
||||
/// - a configured token is shape-validated before it can reach a header
|
||||
/// (`.malformedToken`, fail-fast — never silently send an unauthenticated
|
||||
/// request and let the user read the 401 as "server is down");
|
||||
/// - a 401 becomes the typed `.unauthorized` (ios-completion §1.1) for every
|
||||
/// route except the two families that define their own 401
|
||||
/// (`UnauthorizedPolicy.routeDefined`).
|
||||
func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let request = route.urlRequest(for: endpoint) else {
|
||||
let token = try validatedAccessToken()
|
||||
guard let request = route.urlRequest(for: endpoint, accessToken: token) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
return try await http.send(request)
|
||||
let (data, response) = try await http.send(request)
|
||||
if response.statusCode == HTTPStatus.unauthorized,
|
||||
route.unauthorizedPolicy == .accessTokenGate {
|
||||
throw APIClientError.unauthorized
|
||||
}
|
||||
return (data, response)
|
||||
}
|
||||
|
||||
/// nil when no token is configured; throws `.malformedToken` when one is
|
||||
/// configured but violates the frozen charset/length rule (which is also
|
||||
/// what makes CRLF header injection impossible).
|
||||
private func validatedAccessToken() throws -> String? {
|
||||
guard let accessToken else { return nil }
|
||||
guard AccessTokenRule.isWellFormed(accessToken) else {
|
||||
throw APIClientError.malformedToken
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
/// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`.
|
||||
@@ -125,6 +172,44 @@ public struct APIClient: Sendable {
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/projects/*` three-prong contract, shared by `detail`/`log`/`pr`
|
||||
/// (src/server.ts:1033-1042 and friends): `path` missing/empty → 400,
|
||||
/// non-git dir → 404, read failure → 500. `notFound` is a parameter because
|
||||
/// `/projects/worktree/state`'s 404 means "not a worktree", not "no project".
|
||||
static func requireGitReadOK(
|
||||
_ response: HTTPURLResponse, notFound: APIClientError = .projectNotFound
|
||||
) throws {
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
return
|
||||
case HTTPStatus.badRequest:
|
||||
throw APIClientError.projectPathInvalid
|
||||
case HTTPStatus.notFound:
|
||||
throw notFound
|
||||
case HTTPStatus.internalServerError:
|
||||
throw APIClientError.gitDataUnavailable
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of the server's own `path` guard, applied BEFORE any network I/O
|
||||
/// (validate at the boundary, plan §4) — every `/projects/*` route rejects
|
||||
/// an empty path with 400, so there is nothing to learn from the round trip.
|
||||
static func requireNonEmptyPath(_ path: String) throws {
|
||||
guard !path.isEmpty else {
|
||||
throw APIClientError.projectPathInvalid
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a single JSON object body, or `.invalidResponseBody`.
|
||||
static func decodeObject<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||
guard let value = try? JSONDecoder().decode(T.self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
|
||||
@@ -132,8 +217,12 @@ enum HTTPStatus {
|
||||
static let ok = 200
|
||||
static let noContent = 204
|
||||
static let badRequest = 400
|
||||
static let unauthorized = 401
|
||||
static let forbidden = 403
|
||||
static let notFound = 404
|
||||
static let conflict = 409
|
||||
static let payloadTooLarge = 413
|
||||
static let tooManyRequests = 429
|
||||
static let internalServerError = 500
|
||||
static let serviceUnavailable = 503
|
||||
}
|
||||
|
||||
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal file
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · optional shared access token (`WEBTERM_TOKEN`) — ios-completion §1.1
|
||||
// FROZEN contract, cross-checked against `src/http/auth.ts` + `src/server.ts`.
|
||||
//
|
||||
// Server facts:
|
||||
// | cookie name | `webterm_auth` | auth.ts:30 |
|
||||
// | login endpoint | `POST /auth` | server.ts:393 |
|
||||
// | request body | `{"token":"<t>"}` + JSON C-T | server.ts:396 |
|
||||
// | `Accept` | MUST NOT contain `text/html` | server.ts:366 |
|
||||
// | valid | 204 **with** `Set-Cookie` | server.ts:412 |
|
||||
// | wrong token | 401 `{"error":"invalid token"}` | server.ts:418 |
|
||||
// | rate limited | 429 (10/min/IP) | server.ts:399 |
|
||||
// | auth DISABLED | 204 **without** `Set-Cookie` | server.ts:404 |
|
||||
//
|
||||
// IMPLEMENTATION DECISION (frozen): a native client KNOWS its token, so it
|
||||
// hand-writes `Cookie: webterm_auth=<t>` and NEVER parses `Set-Cookie` nor
|
||||
// relies on a cookie jar (URLSession/OkHttp jars behave inconsistently on a WS
|
||||
// upgrade and are hard to test). Hand-written header == the same pattern as the
|
||||
// hand-written `Origin`, pinned by pure-function unit tests.
|
||||
//
|
||||
// HONEST BOUNDARY (src/http/auth.ts header): the token is a bar-raiser, NOT a
|
||||
// TLS substitute. On bare `ws://`/`http://` it travels in cleartext and is
|
||||
// replayable by a LAN sniffer; it only meaningfully hardens the TLS-terminated
|
||||
// relay/tunnel path.
|
||||
|
||||
/// The auth cookie, single-point (name + value assembly).
|
||||
enum AuthCookie {
|
||||
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30).
|
||||
static let name = "webterm_auth"
|
||||
/// Response header the probe reads. The client checks its PRESENCE only and
|
||||
/// never parses its value — the token it would echo is already known.
|
||||
static let setCookieHeader = "Set-Cookie"
|
||||
|
||||
/// `webterm_auth=<token>`. Callers MUST pass a token that already satisfies
|
||||
/// `AccessTokenRule.isWellFormed` — the charset check is what guarantees no
|
||||
/// CR/LF (header injection) and no `;` (cookie splitting) can appear here.
|
||||
static func headerValue(for token: String) -> String {
|
||||
"\(name)=\(token)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The frozen token shape: 16–512 characters from `[A-Za-z0-9._~+/=-]`
|
||||
/// (CLAUDE.md / `src/config.ts` — the server REFUSES TO START with anything
|
||||
/// else, so a shape-violating token can never be the right one).
|
||||
enum AccessTokenRule {
|
||||
static let minLength = 16
|
||||
static let maxLength = 512
|
||||
/// URL/cookie-safe set, byte-for-byte the server's `[A-Za-z0-9._~+/=-]`.
|
||||
private static let allowed = Set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-"
|
||||
)
|
||||
|
||||
static func isWellFormed(_ token: String) -> Bool {
|
||||
(minLength...maxLength).contains(token.count) && token.allSatisfy(allowed.contains)
|
||||
}
|
||||
}
|
||||
|
||||
/// The four outcomes of the `POST /auth` pairing-time probe (ios-completion
|
||||
/// §1.1). They are RESULTS, not errors: three of the four are perfectly normal
|
||||
/// states the pairing UI must tell apart.
|
||||
public enum AccessTokenProbeResult: Sendable, Equatable {
|
||||
/// 204 **with** `Set-Cookie` — the token is correct; persist it (Keychain).
|
||||
case valid
|
||||
/// 204 **without** `Set-Cookie` — this host has auth DISABLED. It is NOT
|
||||
/// "authenticated": nothing was verified and nothing should be persisted.
|
||||
case authDisabled
|
||||
/// 401 — wrong token.
|
||||
case invalidToken
|
||||
/// 429 — 10 attempts/min/IP exceeded; ask the user to wait.
|
||||
case rateLimited
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
static let authPath = "/auth"
|
||||
|
||||
/// `POST /auth` with body `{"token":…}`.
|
||||
///
|
||||
/// - `.guarded`: this is a state-changing POST (it mints a session cookie),
|
||||
/// so it stamps `Origin` like every other write. The server does not
|
||||
/// Origin-check `/auth` today — keeping the rule uniform costs one header
|
||||
/// and avoids a special case that would rot the moment it does.
|
||||
/// - `.routeDefined`: this route's own 401 means "wrong token", not "the
|
||||
/// gate rejected you" — the probe maps it to `.invalidToken`.
|
||||
static func auth(token: String) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: .post, path: authPath, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(AuthTokenBody(token: token)),
|
||||
unauthorizedPolicy: .routeDefined
|
||||
)
|
||||
}
|
||||
|
||||
private struct AuthTokenBody: Encodable {
|
||||
let token: String
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// One-shot pairing-time probe of a candidate access token
|
||||
/// (`POST /auth`, ios-completion §1.1).
|
||||
///
|
||||
/// The token travels ONLY in the JSON body — never in a URL query (the
|
||||
/// server's `?token=` bootstrap exists for browsers, which strip it from
|
||||
/// history afterwards; a native client has no reason to put a secret in a
|
||||
/// URL that lands in logs). A shape-violating candidate is rejected here,
|
||||
/// before any network I/O (`.malformedToken`).
|
||||
///
|
||||
/// Returns one of the four frozen outcomes; any other status throws
|
||||
/// `.unexpectedStatus` rather than guessing.
|
||||
public func probeAccessToken(_ token: String) async throws -> AccessTokenProbeResult {
|
||||
guard AccessTokenRule.isWellFormed(token) else {
|
||||
throw APIClientError.malformedToken
|
||||
}
|
||||
let (_, response) = try await perform(try Endpoints.auth(token: token))
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.noContent:
|
||||
// THE distinction the whole feature hinges on: a 204 without a
|
||||
// Set-Cookie means the host never enabled auth. Reporting that as
|
||||
// "authenticated" would persist a token that gates nothing and
|
||||
// teach the user the host is protected when it is not.
|
||||
return response.value(forHTTPHeaderField: AuthCookie.setCookieHeader) == nil
|
||||
? .authDisabled : .valid
|
||||
case HTTPStatus.unauthorized:
|
||||
return .invalidToken
|
||||
case HTTPStatus.tooManyRequests:
|
||||
return .rateLimited
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,28 @@ enum OriginPolicy: Sendable, Equatable {
|
||||
case guarded
|
||||
}
|
||||
|
||||
/// How a 401 on this route must be READ (ios-completion §1.1). The access-token
|
||||
/// gate answers 401 for any unauthed request (src/server.ts:459 `authGate` step
|
||||
/// 5), so on almost every route 401 means "token missing/wrong" → typed
|
||||
/// `.unauthorized`. Two route families define their OWN 401 and must not be
|
||||
/// swallowed by that rule — declared here, at the route, so the exception is
|
||||
/// visible instead of hidden in a call site.
|
||||
enum UnauthorizedPolicy: Sendable, Equatable {
|
||||
/// Default — a 401 can only be the access-token gate.
|
||||
case accessTokenGate
|
||||
/// The route owns its 401: `POST /auth`'s wrong-token answer
|
||||
/// (src/server.ts:418) and the git-write family, where the server
|
||||
/// CLASSIFIES a host-side git credential failure as 401
|
||||
/// (src/http/git-ops.ts:108 "Push authentication required on the host.").
|
||||
case routeDefined
|
||||
}
|
||||
|
||||
/// Header/content-type names used by the builder (no magic strings inline).
|
||||
enum HeaderName {
|
||||
static let origin = "Origin"
|
||||
static let contentType = "Content-Type"
|
||||
static let accept = "Accept"
|
||||
static let cookie = "Cookie"
|
||||
}
|
||||
|
||||
enum ContentTypeValue {
|
||||
@@ -41,28 +59,43 @@ struct APIRoute: Sendable, Equatable {
|
||||
/// nil for no query. Percent-encoding happens ONCE, in the route builder
|
||||
/// (T-iOS-38: `/projects/detail?path=`) — never at call sites.
|
||||
let percentEncodedQuery: String?
|
||||
/// See `UnauthorizedPolicy`. Defaults to the gate reading.
|
||||
let unauthorizedPolicy: UnauthorizedPolicy
|
||||
|
||||
init(
|
||||
method: HTTPMethod,
|
||||
path: String,
|
||||
originPolicy: OriginPolicy,
|
||||
body: Data?,
|
||||
percentEncodedQuery: String? = nil
|
||||
percentEncodedQuery: String? = nil,
|
||||
unauthorizedPolicy: UnauthorizedPolicy = .accessTokenGate
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.originPolicy = originPolicy
|
||||
self.body = body
|
||||
self.percentEncodedQuery = percentEncodedQuery
|
||||
self.unauthorizedPolicy = unauthorizedPolicy
|
||||
}
|
||||
|
||||
/// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
|
||||
/// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
|
||||
/// (dropped when nil), fragment/credentials are dropped — the same
|
||||
/// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
|
||||
/// happens HERE and only here (single point; hand-stamping elsewhere is a
|
||||
/// review CRITICAL, plan §5.1).
|
||||
func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
|
||||
/// derivation philosophy as `HostEndpoint.wsURL`.
|
||||
///
|
||||
/// **Every header this client sends is stamped HERE and only here** (single
|
||||
/// point; hand-stamping elsewhere is a review CRITICAL, plan §5.1):
|
||||
/// - `Origin` **iff** `.guarded` — the security split;
|
||||
/// - `Cookie: webterm_auth=<t>` iff a token is configured — ORTHOGONAL to
|
||||
/// the Origin rule (ios-completion §1.1: the token never REPLACES Origin,
|
||||
/// both travel together, on RO and G alike);
|
||||
/// - `Accept: application/json` always — an `Accept` containing `text/html`
|
||||
/// makes the server treat the request as a browser navigation and answer
|
||||
/// 302→/login instead of 401/204 (src/server.ts:366-369,459).
|
||||
///
|
||||
/// `accessToken` MUST already be shape-validated (`AccessTokenRule`); the
|
||||
/// charset check is what makes a header-injection value impossible here.
|
||||
func urlRequest(for endpoint: HostEndpoint, accessToken: String? = nil) -> URLRequest? {
|
||||
guard var components = URLComponents(
|
||||
url: endpoint.baseURL, resolvingAgainstBaseURL: true
|
||||
) else { return nil }
|
||||
@@ -78,9 +111,15 @@ struct APIRoute: Sendable, Equatable {
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.accept)
|
||||
if originPolicy == .guarded {
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin)
|
||||
}
|
||||
if let accessToken {
|
||||
request.setValue(
|
||||
AuthCookie.headerValue(for: accessToken), forHTTPHeaderField: HeaderName.cookie
|
||||
)
|
||||
}
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType)
|
||||
@@ -89,17 +128,24 @@ struct APIRoute: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route
|
||||
/// table (verified against src/server.ts):
|
||||
/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
|
||||
/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609)
|
||||
/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293)
|
||||
/// · `GET /prefs` (:273)
|
||||
/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
|
||||
/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20
|
||||
/// shape, mirrors `/push/subscribe` :461-498)
|
||||
/// P1 builders live beside their feature models: `ApnsToken.swift`,
|
||||
/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner).
|
||||
/// Builders for the frozen endpoints (plan §3.4 · T-iOS-38 P1 · B1 增量). Route
|
||||
/// table (verified against src/server.ts at the line numbers shown):
|
||||
/// - RO — `GET /live-sessions` (:485) · `GET /live-sessions/:id/preview` (:585)
|
||||
/// · `GET /live-sessions/:id/events` (:984) · `GET /config/ui` (:1320)
|
||||
/// · `GET /projects` (:507) · `GET /projects/detail?path=` (:564)
|
||||
/// · `GET /prefs` (:518) · `GET /projects/log?path=[&n=]` (:1030)
|
||||
/// · `GET /projects/pr?path=` (:1058)
|
||||
/// · `GET /projects/worktree/state?path=` (:542) · `GET /sessions` (:479)
|
||||
/// - G — `DELETE /live-sessions/:id` (:689) · `POST /hook/decision` (:959)
|
||||
/// · `PUT /prefs` (:523) · `POST|DELETE /push/apns-token` (:871/:892)
|
||||
/// · `POST /auth` (:393) · `POST /live-sessions/:id/queue` (:605)
|
||||
/// · `POST /projects/git/stage|commit|push|fetch` (:1184/:1219/:1256/:1290)
|
||||
/// · `POST /projects/worktree` (:1095) · `DELETE /projects/worktree` (:1124)
|
||||
/// · `POST /projects/worktree/prune` (:1154)
|
||||
/// Builders live beside their feature models: `ApnsToken.swift`,
|
||||
/// `Projects.swift`, `Prefs.swift`, `AccessToken.swift`, `GitLog.swift`,
|
||||
/// `PrStatus.swift`, `WorktreeState.swift`, `GitWrite.swift`, `History.swift`,
|
||||
/// `FollowupQueue.swift`.
|
||||
enum Endpoints {
|
||||
/// Strict RFC 3986 unreserved set — everything else gets percent-encoded.
|
||||
/// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is
|
||||
@@ -108,6 +154,21 @@ enum Endpoints {
|
||||
static let unreservedCharacters = CharacterSet(
|
||||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
|
||||
/// THE single percent-encoding choke point for every query value in this
|
||||
/// package (`?path=`, and anything added later). nil = not encodable →
|
||||
/// callers surface `.invalidRequest` instead of building a broken URL.
|
||||
static func percentEncode(_ value: String) -> String? {
|
||||
value.addingPercentEncoding(withAllowedCharacters: unreservedCharacters)
|
||||
}
|
||||
|
||||
/// `path=<strictly-encoded>` — the query shared by every `/projects/*` read
|
||||
/// route (`detail`, `log`, `pr`, `worktree/state`). One builder, so a new
|
||||
/// route cannot re-introduce the `+`-decodes-to-space class of bug.
|
||||
static func pathQuery(_ path: String) -> String? {
|
||||
percentEncode(path).map { "path=\($0)" }
|
||||
}
|
||||
|
||||
static func liveSessions() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
@@ -155,7 +216,7 @@ enum Endpoints {
|
||||
/// Server session ids are lowercase `crypto.randomUUID()` strings and
|
||||
/// `:id` route params are matched as EXACT strings — always serialize
|
||||
/// lowercase (same rule as `MessageCodec`'s attach encoding).
|
||||
private static func pathId(_ id: UUID) -> String {
|
||||
static func pathId(_ id: UUID) -> String {
|
||||
id.uuidString.lowercased()
|
||||
}
|
||||
|
||||
|
||||
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal file
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `POST /live-sessions/:id/queue` (src/server.ts:605-643) — **G**.
|
||||
// State-changing (it causes shell input on the next idle) → Origin guard + per-IP
|
||||
// rate limit. Body: `{text, appendEnter?}`.
|
||||
//
|
||||
// BYTE-SHUTTLE (the project's central invariant): the bytes are stored and later
|
||||
// injected VERBATIM. The server never parses them as a shell command, and
|
||||
// neither does this client — `text` is passed through untouched, exactly like a
|
||||
// keystroke. The FE owns the Enter decision (`appendEnter` → a trailing `\r`,
|
||||
// 0x0D — never `\n`), so the stored entry is byte-identical to what typing it
|
||||
// would have produced.
|
||||
|
||||
/// `POST /live-sessions/:id/queue` 200 body — the queue's new depth.
|
||||
struct QueueDepth: Decodable {
|
||||
let length: Int
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `POST /live-sessions/:id/queue` — G. `appendEnter` is always serialized
|
||||
/// (the server reads `=== true`, so an explicit `false` is honest and
|
||||
/// symmetric rather than relying on an absent-key default).
|
||||
static func enqueueFollowup(
|
||||
sessionId: UUID, text: String, appendEnter: Bool
|
||||
) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: .post, path: "/live-sessions/\(pathId(sessionId))/queue",
|
||||
originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(
|
||||
FollowupBody(text: text, appendEnter: appendEnter)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private struct FollowupBody: Encodable {
|
||||
let text: String
|
||||
let appendEnter: Bool
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// Enqueue a follow-up prompt, fired into the PTY on the session's next idle
|
||||
/// (w2). Returns the queue's NEW depth.
|
||||
///
|
||||
/// G → `Origin` byte-equal. Server-enforced limits (theirs, documented for
|
||||
/// callers): body ≤ 16 KB, `text` + optional `\r` ≤ `queueItemMaxBytes`
|
||||
/// (→ 413), depth ≤ `queueMaxItems` (→ 409, never a silent drop), per-IP
|
||||
/// rate limit (→ 429), `QUEUE_ENABLED=0` (→ 503).
|
||||
/// An empty `text` is rejected before any network I/O (mirrors the 400 rule).
|
||||
@discardableResult
|
||||
public func enqueueFollowup(
|
||||
sessionId: UUID, text: String, appendEnter: Bool
|
||||
) async throws -> Int {
|
||||
guard !text.isEmpty else {
|
||||
throw APIClientError.queueTextInvalid
|
||||
}
|
||||
let route = try Endpoints.enqueueFollowup(
|
||||
sessionId: sessionId, text: text, appendEnter: appendEnter
|
||||
)
|
||||
let (data, response) = try await perform(route)
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
return try Self.decodeObject(QueueDepth.self, from: data).length
|
||||
case HTTPStatus.badRequest:
|
||||
throw APIClientError.queueTextInvalid
|
||||
case HTTPStatus.forbidden:
|
||||
throw APIClientError.forbidden
|
||||
case HTTPStatus.notFound:
|
||||
throw APIClientError.sessionNotFound
|
||||
case HTTPStatus.conflict:
|
||||
throw APIClientError.queueFull
|
||||
case HTTPStatus.payloadTooLarge:
|
||||
throw APIClientError.queueTextTooLarge
|
||||
case HTTPStatus.tooManyRequests:
|
||||
throw APIClientError.rateLimited
|
||||
case HTTPStatus.serviceUnavailable:
|
||||
throw APIClientError.queueDisabled
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal file
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /projects/log?path=[&n=]` (src/server.ts:1030-1056) — RO, NO Origin.
|
||||
// Response = `src/types.ts:757-772` `GitLogResult`. Always 200 on a valid git
|
||||
// dir (a git failure degrades to an empty commit list server-side); `path`
|
||||
// missing → 400, non-git dir → 404, read failure → 500.
|
||||
|
||||
/// One commit from `git log` (src/types.ts:757-763). `hash` and `at` are
|
||||
/// REQUIRED — a commit without them cannot be rendered or opened, so the entry
|
||||
/// is dropped while its siblings survive. `at` = `%ct * 1000` (epoch ms, an
|
||||
/// integer — unlike the `stat()`-derived timestamps elsewhere).
|
||||
///
|
||||
/// Every field is INERT display text: render as plain text, never autolink,
|
||||
/// never pass to a shell.
|
||||
public struct CommitLogEntry: Sendable, Equatable {
|
||||
public let hash: String
|
||||
public let at: Int
|
||||
public let subject: String
|
||||
/// w6/G4: reachable from HEAD but not from `@{u}`. nil = the server did not
|
||||
/// say (no upstream to compare against) — which is NOT the same as `false`.
|
||||
public let unpushed: Bool?
|
||||
|
||||
public init(hash: String, at: Int, subject: String = "", unpushed: Bool? = nil) {
|
||||
self.hash = hash
|
||||
self.at = at
|
||||
self.subject = subject
|
||||
self.unpushed = unpushed
|
||||
}
|
||||
}
|
||||
|
||||
extension CommitLogEntry: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case hash, at, subject, unpushed
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
hash = try container.decode(String.self, forKey: .hash)
|
||||
at = try container.decode(Int.self, forKey: .at)
|
||||
subject = (try? container.decode(String.self, forKey: .subject)) ?? ""
|
||||
unpushed = try? container.decode(Bool.self, forKey: .unpushed)
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /projects/log` result (src/types.ts:765-772).
|
||||
public struct GitLogResult: Sendable, Equatable {
|
||||
public let commits: [CommitLogEntry]
|
||||
/// More commits exist beyond the server's cap.
|
||||
public let truncated: Bool
|
||||
/// w6/G4: upstream short name, used to label the pushed/unpushed boundary.
|
||||
/// nil ⇒ nothing to compare against, so NO boundary may be drawn.
|
||||
public let upstream: String?
|
||||
|
||||
public init(commits: [CommitLogEntry], truncated: Bool = false, upstream: String? = nil) {
|
||||
self.commits = commits
|
||||
self.truncated = truncated
|
||||
self.upstream = upstream
|
||||
}
|
||||
}
|
||||
|
||||
extension GitLogResult: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case commits, truncated, upstream
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
commits = LossyList.decode(CommitLogEntry.self, in: container, forKey: .commits)
|
||||
truncated = (try? container.decode(Bool.self, forKey: .truncated)) ?? false
|
||||
upstream = try? container.decode(String.self, forKey: .upstream)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// Mirror of `src/http/git-log.ts:32` `GIT_LOG_MAX` — the server's `?n=`
|
||||
/// clamp ceiling. Clamping client-side too keeps the URL honest about what
|
||||
/// will come back (the server re-clamps regardless).
|
||||
static let gitLogMaxCount = 50
|
||||
static let gitLogMinCount = 1
|
||||
|
||||
/// `GET /projects/log?path=[&n=]` — RO, no Origin. nil = `path` could not be
|
||||
/// percent-encoded. A nil `n` omits the parameter (server default applies).
|
||||
static func gitLog(path: String, n: Int?) -> APIRoute? {
|
||||
guard var query = pathQuery(path) else { return nil }
|
||||
if let n {
|
||||
query += "&n=\(min(max(n, gitLogMinCount), gitLogMaxCount))"
|
||||
}
|
||||
return APIRoute(
|
||||
method: .get, path: "/projects/log", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /projects/log?path=[&n=]` — the repo's recent commits. RO → no
|
||||
/// Origin. `n` is clamped to `1...50`; nil leaves it to the server.
|
||||
/// 400/404/500 → `.projectPathInvalid` / `.projectNotFound` /
|
||||
/// `.gitDataUnavailable`; an empty path is rejected before any network I/O.
|
||||
public func gitLog(path: String, n: Int? = nil) async throws -> GitLogResult {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
guard let route = Endpoints.gitLog(path: path, n: n) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await perform(route)
|
||||
try Self.requireGitReadOK(response)
|
||||
return try Self.decodeObject(GitLogResult.self, from: data)
|
||||
}
|
||||
}
|
||||
377
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
377
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
@@ -0,0 +1,377 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · the seven **G** (state-changing) git/worktree routes — the highest-risk
|
||||
// channel in the app. Every one of them: `Origin` (CSRF) → `gitOpsEnabled` /
|
||||
// `worktreeEnabled` kill-switch (403) → per-IP rate limit (429) →
|
||||
// `isValidGitDir` three-prong (404). Sources:
|
||||
// POST /projects/git/stage src/server.ts:1184-1218 {path,files,stage}
|
||||
// POST /projects/git/commit src/server.ts:1219-1255 {path,message}
|
||||
// POST /projects/git/push src/server.ts:1256-1289 {path}
|
||||
// POST /projects/git/fetch src/server.ts:1290-1319 {path}
|
||||
// POST /projects/worktree src/server.ts:1095-1123 {path,branch[,base]}
|
||||
// DELETE /projects/worktree src/server.ts:1124-1153 {path,worktreePath,force}
|
||||
// POST /projects/worktree/prune src/server.ts:1154-1183 {path}
|
||||
//
|
||||
// The remote/branch/refspec of push and fetch are ALWAYS derived server-side —
|
||||
// this client cannot point them at an arbitrary URL, and never force-pushes.
|
||||
// Failure bodies carry the server's already-classified, already-sanitized `error`
|
||||
// string only (never raw git stderr, SEC-M10): the client shows it verbatim.
|
||||
|
||||
/// Outcome of one guarded git write. Three shapes, because the server gives
|
||||
/// exactly three:
|
||||
/// - `.ok` — 200 with the route's payload;
|
||||
/// - `.rejected` — a 4xx/5xx carrying the server's SAFE `error` message, to be
|
||||
/// displayed INERTLY. **403 is overloaded** (the Origin guard AND the feature
|
||||
/// kill-switch both answer 403) and the client cannot tell them apart by
|
||||
/// status, so it surfaces the message instead of inventing a typed variant;
|
||||
/// - `.rateLimited` — 429. Do NOT auto-retry (that is what the limiter is for).
|
||||
public enum GitWriteOutcome<Payload: Sendable & Equatable>: Sendable, Equatable {
|
||||
case ok(Payload)
|
||||
case rejected(status: Int, message: String?)
|
||||
case rateLimited
|
||||
}
|
||||
|
||||
/// A guarded write's 200 payload. `degraded` is what a 200 with a missing or
|
||||
/// garbled body decodes to: the write ALREADY HAPPENED, so a bad body must not
|
||||
/// be reported as a failure — and the fallback must not be a crash path either.
|
||||
protocol GitWritePayload: Decodable, Sendable, Equatable {
|
||||
static var degraded: Self { get }
|
||||
}
|
||||
|
||||
// MARK: - Per-route 200 payloads
|
||||
|
||||
/// `POST /projects/git/stage` → `{ok,staged,count}`.
|
||||
public struct StageResult: Sendable, Equatable, Decodable {
|
||||
/// true = files were staged (`git add`); false = unstaged (`git restore --staged`).
|
||||
public let staged: Bool
|
||||
public let count: Int
|
||||
|
||||
public init(staged: Bool = false, count: Int = 0) {
|
||||
self.staged = staged
|
||||
self.count = count
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case staged, count }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
staged = (try? container.decode(Bool.self, forKey: .staged)) ?? false
|
||||
count = LossyNumber.int(in: container, forKey: .count) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/commit` → `{ok,commit}` (short sha; `""` is possible).
|
||||
public struct CommitResult: Sendable, Equatable, Decodable {
|
||||
public let commit: String
|
||||
|
||||
public init(commit: String = "") {
|
||||
self.commit = commit
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case commit }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
commit = (try? container.decode(String.self, forKey: .commit)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/push` → `{ok,branch,remote}` (both server-derived).
|
||||
public struct PushResult: Sendable, Equatable, Decodable {
|
||||
public let branch: String?
|
||||
public let remote: String?
|
||||
|
||||
public init(branch: String? = nil, remote: String? = nil) {
|
||||
self.branch = branch
|
||||
self.remote = remote
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case branch, remote }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
remote = try? container.decode(String.self, forKey: .remote)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/fetch` → `{ok,remote,lastFetchMs}`. `lastFetchMs` is the
|
||||
/// post-fetch `FETCH_HEAD` mtime — fractional (Double), same as `SyncState`.
|
||||
/// nil means the mtime could not be read: the UI must NOT then claim the
|
||||
/// `behind` count was freshly verified.
|
||||
public struct FetchResult: Sendable, Equatable, Decodable {
|
||||
public let remote: String?
|
||||
public let lastFetchMs: Double?
|
||||
|
||||
public init(remote: String? = nil, lastFetchMs: Double? = nil) {
|
||||
self.remote = remote
|
||||
self.lastFetchMs = lastFetchMs
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case remote, lastFetchMs }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
remote = try? container.decode(String.self, forKey: .remote)
|
||||
lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/worktree` → `{ok,path,branch}` (git's canonical values).
|
||||
public struct CreateWorktreeResult: Sendable, Equatable, Decodable {
|
||||
public let path: String?
|
||||
public let branch: String?
|
||||
|
||||
public init(path: String? = nil, branch: String? = nil) {
|
||||
self.path = path
|
||||
self.branch = branch
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case path, branch }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
path = try? container.decode(String.self, forKey: .path)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /projects/worktree` → `{ok,path}` (the canonical path removed).
|
||||
public struct RemoveWorktreeResult: Sendable, Equatable, Decodable {
|
||||
public let path: String?
|
||||
|
||||
public init(path: String? = nil) {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case path }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
path = try? container.decode(String.self, forKey: .path)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/worktree/prune` → `{ok,pruned}`. An empty list means
|
||||
/// "nothing to prune" — the route is idempotent.
|
||||
public struct PruneWorktreesResult: Sendable, Equatable, Decodable {
|
||||
public let pruned: [String]
|
||||
|
||||
public init(pruned: [String] = []) {
|
||||
self.pruned = pruned
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case pruned }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
pruned = (try? container.decode([String].self, forKey: .pruned)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// All-defaults fallbacks, declared next to nothing else so they stay one line
|
||||
// each and cannot drift from the memberwise defaults above.
|
||||
extension StageResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension CommitResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension PushResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension FetchResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension CreateWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension RemoveWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension PruneWorktreesResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
|
||||
/// Shape of a failure body: the worktree routes emit `{error}`, git-ops
|
||||
/// `{ok:false,error}`. Both carry `error` as a SAFE string.
|
||||
private struct GitErrorBody: Decodable {
|
||||
let error: String?
|
||||
}
|
||||
|
||||
// MARK: - Request bodies (frozen field-for-field against src/server.ts)
|
||||
|
||||
private struct StageBody: Encodable {
|
||||
let path: String
|
||||
let files: [String]
|
||||
let stage: Bool
|
||||
}
|
||||
|
||||
private struct CommitBody: Encodable {
|
||||
let path: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
/// `{path}` — push · fetch · worktree prune all take exactly this.
|
||||
private struct RepoPathBody: Encodable {
|
||||
let path: String
|
||||
}
|
||||
|
||||
private struct CreateWorktreeBody: Encodable {
|
||||
let path: String
|
||||
let branch: String
|
||||
/// Omitted from the JSON when nil — the server reads a missing `base` as
|
||||
/// "branch from HEAD", and an explicit `null`/`""` is NOT the same thing.
|
||||
let base: String?
|
||||
}
|
||||
|
||||
private struct RemoveWorktreeBody: Encodable {
|
||||
let path: String
|
||||
let worktreePath: String
|
||||
let force: Bool
|
||||
}
|
||||
|
||||
// MARK: - Routes
|
||||
|
||||
extension Endpoints {
|
||||
/// Every guarded git write shares this shape: JSON body + `Origin`, and
|
||||
/// `.routeDefined` 401 — `src/http/git-ops.ts:108` classifies a HOST-side
|
||||
/// git credential failure as 401 ("Push authentication required on the
|
||||
/// host."), which must not be mistaken for the access-token gate telling us
|
||||
/// to enter a token.
|
||||
private static func gitWriteRoute<Body: Encodable>(
|
||||
_ method: HTTPMethod, _ path: String, _ body: Body
|
||||
) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: method, path: path, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(body),
|
||||
unauthorizedPolicy: .routeDefined
|
||||
)
|
||||
}
|
||||
|
||||
static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage))
|
||||
}
|
||||
|
||||
static func gitCommit(path: String, message: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message))
|
||||
}
|
||||
|
||||
static func gitPush(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/push", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func gitFetch(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/fetch", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute {
|
||||
try gitWriteRoute(
|
||||
.post, "/projects/worktree",
|
||||
CreateWorktreeBody(path: path, branch: branch, base: base)
|
||||
)
|
||||
}
|
||||
|
||||
/// DELETE **with** a JSON body — the server reads `express.json` here
|
||||
/// (src/server.ts:1124), so this is the wire shape, unusual as it looks.
|
||||
static func removeWorktree(path: String, worktreePath: String, force: Bool) throws -> APIRoute {
|
||||
try gitWriteRoute(
|
||||
.delete, "/projects/worktree",
|
||||
RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force)
|
||||
)
|
||||
}
|
||||
|
||||
static func pruneWorktrees(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/worktree/prune", RepoPathBody(path: path))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client calls
|
||||
|
||||
extension APIClient {
|
||||
/// Stage (`stage: true` → `git add`) or unstage (`false` →
|
||||
/// `git restore --staged`) specific files. The file list is capped and
|
||||
/// realpath-contained server-side; body limit 64 KB.
|
||||
public func gitStage(
|
||||
path: String, files: [String], stage: Bool
|
||||
) async throws -> GitWriteOutcome<StageResult> {
|
||||
try await performGitWrite(path: path, StageResult.self) {
|
||||
try Endpoints.gitStage(path: path, files: files, stage: stage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit the STAGED changes. `message` is length-capped server-side and
|
||||
/// passed as a single `-m <msg>` argv — never a shell string, never a
|
||||
/// pathspec. An empty message comes back as `.rejected(400, …)`.
|
||||
public func gitCommit(
|
||||
path: String, message: String
|
||||
) async throws -> GitWriteOutcome<CommitResult> {
|
||||
try await performGitWrite(path: path, CommitResult.self) {
|
||||
try Endpoints.gitCommit(path: path, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current branch to its existing upstream, or `-u <sole-remote>
|
||||
/// <branch>` when it has none. Never a force-push; the remote is derived
|
||||
/// server-side. Tighter rate limit than stage/commit (network-bound).
|
||||
public func gitPush(path: String) async throws -> GitWriteOutcome<PushResult> {
|
||||
try await performGitWrite(path: path, PushResult.self) {
|
||||
try Endpoints.gitPush(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh remote-tracking refs (`refs/remotes` only — never a pull) so the
|
||||
/// panel's `behind` stops being a stale guess.
|
||||
public func gitFetch(path: String) async throws -> GitWriteOutcome<FetchResult> {
|
||||
try await performGitWrite(path: path, FetchResult.self) {
|
||||
try Endpoints.gitFetch(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a git worktree (`base` nil ⇒ branch from HEAD). The only
|
||||
/// write-to-disk feature; gated by `WORKTREE_ENABLED` (403 when off).
|
||||
public func createWorktree(
|
||||
path: String, branch: String, base: String?
|
||||
) async throws -> GitWriteOutcome<CreateWorktreeResult> {
|
||||
try await performGitWrite(path: path, CreateWorktreeResult.self) {
|
||||
try Endpoints.createWorktree(path: path, branch: branch, base: base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a worktree. Destructive: a dirty worktree needs `force: true`
|
||||
/// (otherwise the server answers 409 with a safe message), and the main
|
||||
/// worktree can never be removed (400).
|
||||
public func removeWorktree(
|
||||
path: String, worktreePath: String, force: Bool
|
||||
) async throws -> GitWriteOutcome<RemoveWorktreeResult> {
|
||||
try await performGitWrite(path: path, RemoveWorktreeResult.self) {
|
||||
try Endpoints.removeWorktree(path: path, worktreePath: worktreePath, force: force)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prune stale worktree registrations (idempotent — an empty `pruned` list
|
||||
/// means there was nothing to reclaim).
|
||||
public func pruneWorktrees(path: String) async throws -> GitWriteOutcome<PruneWorktreesResult> {
|
||||
try await performGitWrite(path: path, PruneWorktreesResult.self) {
|
||||
try Endpoints.pruneWorktrees(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The ONE place a guarded git write is executed and its status mapped, so
|
||||
/// all seven routes cannot drift apart: empty path rejected before any
|
||||
/// network I/O → 200 decoded (a garbled payload degrades to defaults rather
|
||||
/// than throwing — the write already happened) → 429 `.rateLimited` →
|
||||
/// everything else `.rejected` with the server's safe message.
|
||||
private func performGitWrite<Payload: GitWritePayload>(
|
||||
path: String,
|
||||
_ payload: Payload.Type,
|
||||
route build: () throws -> APIRoute
|
||||
) async throws -> GitWriteOutcome<Payload> {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
let (data, response) = try await perform(try build())
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
let decoded = try? JSONDecoder().decode(Payload.self, from: data)
|
||||
return .ok(decoded ?? Payload.degraded)
|
||||
case HTTPStatus.tooManyRequests:
|
||||
return .rateLimited
|
||||
default:
|
||||
return .rejected(
|
||||
status: response.statusCode, message: Self.decodeGitError(from: data)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's SAFE `error` string, or nil when the body is empty/
|
||||
/// unparseable — the client never invents a reason.
|
||||
private static func decodeGitError(from data: Data) -> String? {
|
||||
(try? JSONDecoder().decode(GitErrorBody.self, from: data))?.error
|
||||
}
|
||||
}
|
||||
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal file
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /sessions` (src/server.ts:479-481) — RO, NO Origin. Response =
|
||||
// `src/http/history.ts:13-19` `HistorySession[]`: the host's most recently
|
||||
// modified Claude Code session files, i.e. the `claude --resume` picker's data
|
||||
// (T-iOS-32).
|
||||
//
|
||||
// SECURITY (Sec H3, accepted upstream risk — documented, not introduced here):
|
||||
// this route is UNAUTHENTICATED on a token-less host and returns session cwds
|
||||
// plus the first ~120 chars of each first prompt. That matches the app's threat
|
||||
// model (the whole app hands a shell to anyone who can reach the port; deploy
|
||||
// behind Tailscale) — but it means the CLIENT must treat every field as INERT
|
||||
// display text: never autolink, never interpolate into a shell command.
|
||||
|
||||
/// One past Claude Code session (src/http/history.ts:13-19).
|
||||
///
|
||||
/// `id` stays a **String**, not a UUID: it is the `.jsonl` filename stem the
|
||||
/// host will pass to `claude --resume <id>` verbatim. Parsing it as a UUID would
|
||||
/// buy nothing and would DROP any session whose file was renamed — while the id
|
||||
/// still resumes fine. Required + non-empty, though: an entry without one cannot
|
||||
/// be resumed, so it is dropped rather than rendered as a dead row.
|
||||
public struct HistorySession: Sendable, Equatable {
|
||||
public let id: String
|
||||
/// The session's working directory (`""` when the jsonl had none).
|
||||
public let cwd: String
|
||||
/// Last cwd segment, for display (`"unknown"` server-side when cwd is empty).
|
||||
public let project: String
|
||||
/// The jsonl's mtime in ms. `fs.stat().mtimeMs` is FRACTIONAL on a real host
|
||||
/// (e.g. `1785390645813.5327`), so this is a Double — decoding it as Int
|
||||
/// would fail and silently drop every entry (src/http/history.ts:105).
|
||||
public let mtimeMs: Double
|
||||
/// First user prompt, whitespace-collapsed and truncated to 120 chars
|
||||
/// server-side. INERT text.
|
||||
public let preview: String
|
||||
|
||||
public init(id: String, cwd: String = "", project: String = "", mtimeMs: Double, preview: String = "") {
|
||||
self.id = id
|
||||
self.cwd = cwd
|
||||
self.project = project
|
||||
self.mtimeMs = mtimeMs
|
||||
self.preview = preview
|
||||
}
|
||||
}
|
||||
|
||||
extension HistorySession: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, cwd, project, mtimeMs, preview
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let rawId = try container.decode(String.self, forKey: .id)
|
||||
guard !rawId.isEmpty else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .id, in: container,
|
||||
debugDescription: "empty session id is not resumable"
|
||||
)
|
||||
}
|
||||
id = rawId
|
||||
mtimeMs = try container.decode(Double.self, forKey: .mtimeMs)
|
||||
cwd = (try? container.decode(String.self, forKey: .cwd)) ?? ""
|
||||
project = (try? container.decode(String.self, forKey: .project)) ?? ""
|
||||
preview = (try? container.decode(String.self, forKey: .preview)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `GET /sessions` — RO, no Origin (src/server.ts:479).
|
||||
static func claudeSessions() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/sessions", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /sessions` — the host's recent Claude Code sessions, newest first
|
||||
/// (the server sorts by mtime and caps at 50). RO → no Origin.
|
||||
///
|
||||
/// Malformed entries are dropped one by one; a non-array body throws
|
||||
/// `.invalidResponseBody`. The server answers `[]` (never an error) when
|
||||
/// `~/.claude/projects` is missing, so an empty list means "no history",
|
||||
/// not "failed".
|
||||
public func claudeSessions() async throws -> [HistorySession] {
|
||||
let (data, response) = try await perform(Endpoints.claudeSessions())
|
||||
guard response.statusCode == HTTPStatus.ok else {
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
return try LossyList.decodeBody(HistorySession.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,37 @@ public enum APIClientError: Error, Equatable, Sendable {
|
||||
/// 500 from `GET /projects/detail` — the server failed reading the repo
|
||||
/// (src/server.ts:306-309, body `{error}`).
|
||||
case projectDetailUnavailable
|
||||
/// **401 from the access-token gate** (src/server.ts:459) — the host has
|
||||
/// `WEBTERM_TOKEN` set and this request carried no (or a wrong) `webterm_auth`
|
||||
/// cookie. Distinct from a transport failure ON PURPOSE (ios-completion
|
||||
/// §1.1): the UI must offer "enter the access token", not "retry".
|
||||
case unauthorized
|
||||
/// A configured access token violates the frozen shape (16–512 chars of
|
||||
/// `[A-Za-z0-9._~+/=-]`). Raised BEFORE any network I/O — a shape-invalid
|
||||
/// token can never match a server token (the server refuses to start with
|
||||
/// one), and refusing it here is also what makes header injection
|
||||
/// impossible.
|
||||
case malformedToken
|
||||
/// 500 from a read-only git side-channel (`/projects/log`, `/projects/pr`,
|
||||
/// `/projects/worktree/state`) — the host failed to read git state.
|
||||
case gitDataUnavailable
|
||||
/// 404 from `GET /projects/worktree/state` (src/server.ts:549-552) — that
|
||||
/// path is not a worktree of the repo (removed / never existed).
|
||||
case worktreeNotFound
|
||||
/// 503 from `POST /live-sessions/:id/queue` — `QUEUE_ENABLED=0` on the host
|
||||
/// (src/server.ts:608-611). A configuration state, not a failure to retry.
|
||||
case queueDisabled
|
||||
/// 409 from `POST /live-sessions/:id/queue` — the queue is at
|
||||
/// `queueMaxItems` (src/server.ts:637-641). Never silently dropped.
|
||||
case queueFull
|
||||
/// 400 from `POST /live-sessions/:id/queue` — empty text or a malformed
|
||||
/// session id (src/server.ts:620-627). Also raised client-side for empty
|
||||
/// text before any network I/O.
|
||||
case queueTextInvalid
|
||||
/// 413 from `POST /live-sessions/:id/queue` — text (plus the optional
|
||||
/// trailing `\r`) exceeds the host's `queueItemMaxBytes`
|
||||
/// (src/server.ts:632-635).
|
||||
case queueTextTooLarge
|
||||
/// Any other non-success status code.
|
||||
case unexpectedStatus(Int)
|
||||
|
||||
@@ -213,6 +244,22 @@ public enum APIClientError: Error, Equatable, Sendable {
|
||||
"项目不存在(路径可能已移动或删除)。"
|
||||
case .projectDetailUnavailable:
|
||||
"读取项目详情失败,请稍后再试。"
|
||||
case .unauthorized:
|
||||
"该主机启用了访问令牌,请填写正确的令牌后重试。"
|
||||
case .malformedToken:
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A-Z a-z 0-9 . _ ~ + / = -。"
|
||||
case .gitDataUnavailable:
|
||||
"读取 git 状态失败,请稍后再试。"
|
||||
case .worktreeNotFound:
|
||||
"该 worktree 已不存在(可能已被删除或清理)。"
|
||||
case .queueDisabled:
|
||||
"该主机关闭了排队注入功能(QUEUE_ENABLED=0)。"
|
||||
case .queueFull:
|
||||
"排队已满,请先等前面的任务发出去。"
|
||||
case .queueTextInvalid:
|
||||
"要排队的内容为空或会话标识不合法。"
|
||||
case .queueTextTooLarge:
|
||||
"内容过长,超出了主机允许的单条上限。"
|
||||
case .unexpectedStatus(let status):
|
||||
"服务器返回了意外状态码 \(status)。"
|
||||
}
|
||||
|
||||
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal file
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /projects/pr?path=` (src/server.ts:1058-1076) — RO, NO Origin.
|
||||
// Response = `src/types.ts:617-648` `PrStatus`.
|
||||
//
|
||||
// Out-of-band side-channel: the host spawns its own `gh` CLI, which makes a
|
||||
// NETWORK call to GitHub with the host's credential. This route NEVER accepts or
|
||||
// forwards a token, and `GH_ENABLED=0` disables it entirely. A valid git dir
|
||||
// ALWAYS answers 200: every degrade (gh missing / unauthed / no PR / disabled)
|
||||
// lives in `availability`, not in the HTTP status — so the client renders one
|
||||
// chip instead of branching on errors.
|
||||
|
||||
/// Why a `PrStatus` has (or lacks) PR data (src/types.ts:619-625).
|
||||
/// An unknown/future wire value degrades to `.error`: a new server availability
|
||||
/// must never make the chip crash or hide the row.
|
||||
public enum PrAvailability: String, Sendable, Equatable, CaseIterable {
|
||||
/// A PR exists for the current branch; the sibling fields are populated.
|
||||
case ok
|
||||
/// gh works but the branch has no PR (or no remote / default repo).
|
||||
case noPr = "no-pr"
|
||||
/// The `gh` binary is not on the host's PATH (ENOENT).
|
||||
case notInstalled = "not-installed"
|
||||
/// gh is present but not logged in (needs `gh auth login`).
|
||||
case unauthenticated
|
||||
/// `GH_ENABLED=0` — the feature is off and gh is never spawned.
|
||||
case disabled
|
||||
/// gh spawned but failed for another reason (timeout, …). Also the
|
||||
/// unknown/missing fallback.
|
||||
case error
|
||||
}
|
||||
|
||||
/// Rolled-up CI check counts from gh's `statusCheckRollup`
|
||||
/// (src/types.ts:628-633).
|
||||
public struct PrCheckSummary: Sendable, Equatable {
|
||||
public let total: Int
|
||||
public let passing: Int
|
||||
public let failing: Int
|
||||
public let pending: Int
|
||||
|
||||
public init(total: Int = 0, passing: Int = 0, failing: Int = 0, pending: Int = 0) {
|
||||
self.total = total
|
||||
self.passing = passing
|
||||
self.failing = failing
|
||||
self.pending = pending
|
||||
}
|
||||
}
|
||||
|
||||
extension PrCheckSummary: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case total, passing, failing, pending
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
total = LossyNumber.int(in: container, forKey: .total) ?? 0
|
||||
passing = LossyNumber.int(in: container, forKey: .passing) ?? 0
|
||||
failing = LossyNumber.int(in: container, forKey: .failing) ?? 0
|
||||
pending = LossyNumber.int(in: container, forKey: .pending) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /projects/pr` result (src/types.ts:635-648). Everything except
|
||||
/// `availability` is present only when `availability == .ok`.
|
||||
///
|
||||
/// `state` / `mergeable` stay RAW inert strings (the server already lower-cases
|
||||
/// gh's `OPEN`/`MERGEABLE`): they are display text, and inventing an enum here
|
||||
/// would just add a second place for a future gh value to break.
|
||||
public struct PrStatus: Sendable, Equatable {
|
||||
public let availability: PrAvailability
|
||||
public let number: Int?
|
||||
public let title: String?
|
||||
public let url: String?
|
||||
public let state: String?
|
||||
public let isDraft: Bool?
|
||||
public let mergeable: String?
|
||||
public let headRefName: String?
|
||||
public let baseRefName: String?
|
||||
public let checks: PrCheckSummary?
|
||||
|
||||
public init(
|
||||
availability: PrAvailability = .error,
|
||||
number: Int? = nil,
|
||||
title: String? = nil,
|
||||
url: String? = nil,
|
||||
state: String? = nil,
|
||||
isDraft: Bool? = nil,
|
||||
mergeable: String? = nil,
|
||||
headRefName: String? = nil,
|
||||
baseRefName: String? = nil,
|
||||
checks: PrCheckSummary? = nil
|
||||
) {
|
||||
self.availability = availability
|
||||
self.number = number
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.state = state
|
||||
self.isDraft = isDraft
|
||||
self.mergeable = mergeable
|
||||
self.headRefName = headRefName
|
||||
self.baseRefName = baseRefName
|
||||
self.checks = checks
|
||||
}
|
||||
}
|
||||
|
||||
extension PrStatus: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case availability, number, title, url, state, isDraft, mergeable
|
||||
case headRefName, baseRefName, checks
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let rawAvailability = try? container.decode(String.self, forKey: .availability)
|
||||
availability = rawAvailability.flatMap(PrAvailability.init(rawValue:)) ?? .error
|
||||
number = LossyNumber.int(in: container, forKey: .number)
|
||||
title = try? container.decode(String.self, forKey: .title)
|
||||
url = try? container.decode(String.self, forKey: .url)
|
||||
state = try? container.decode(String.self, forKey: .state)
|
||||
isDraft = try? container.decode(Bool.self, forKey: .isDraft)
|
||||
mergeable = try? container.decode(String.self, forKey: .mergeable)
|
||||
headRefName = try? container.decode(String.self, forKey: .headRefName)
|
||||
baseRefName = try? container.decode(String.self, forKey: .baseRefName)
|
||||
checks = try? container.decode(PrCheckSummary.self, forKey: .checks)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `GET /projects/pr?path=` — RO, no Origin. nil = `path` not encodable.
|
||||
static func projectPr(path: String) -> APIRoute? {
|
||||
pathQuery(path).map { query in
|
||||
APIRoute(
|
||||
method: .get, path: "/projects/pr", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /projects/pr?path=` — the current branch's PR + CI rollup. RO → no
|
||||
/// Origin. 400/404/500 → `.projectPathInvalid` / `.projectNotFound` /
|
||||
/// `.gitDataUnavailable`; an empty path is rejected before any network I/O.
|
||||
/// Note that a REACHABLE-but-degraded gh is a 200 with a non-`ok`
|
||||
/// `availability`, NOT an error.
|
||||
public func prStatus(path: String) async throws -> PrStatus {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
guard let route = Endpoints.projectPr(path: path) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await perform(route)
|
||||
try Self.requireGitReadOK(response)
|
||||
return try Self.decodeObject(PrStatus.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ public struct ProjectSessionRef: Sendable, Equatable {
|
||||
public let clientCount: Int
|
||||
public let createdAt: Int
|
||||
public let exited: Bool
|
||||
/// w6/G7: where the session runs — lets the UI attribute it to a worktree
|
||||
/// (src/types.ts:353). Optional additive field; nil on pre-w6 servers.
|
||||
public let cwd: String?
|
||||
|
||||
public init(
|
||||
id: UUID, title: String?, status: ClaudeStatus,
|
||||
clientCount: Int, createdAt: Int, exited: Bool
|
||||
clientCount: Int, createdAt: Int, exited: Bool, cwd: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -34,12 +37,13 @@ public struct ProjectSessionRef: Sendable, Equatable {
|
||||
self.clientCount = clientCount
|
||||
self.createdAt = createdAt
|
||||
self.exited = exited
|
||||
self.cwd = cwd
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectSessionRef: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, status, clientCount, createdAt, exited
|
||||
case id, title, status, clientCount, createdAt, exited, cwd
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -53,6 +57,7 @@ extension ProjectSessionRef: Decodable {
|
||||
let rawStatus = try? container.decode(String.self, forKey: .status)
|
||||
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
|
||||
title = try? container.decode(String.self, forKey: .title)
|
||||
cwd = try? container.decode(String.self, forKey: .cwd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +74,22 @@ public struct ProjectInfo: Sendable, Equatable {
|
||||
/// Uncommitted changes; only present when the server runs the dirty check.
|
||||
public let dirty: Bool?
|
||||
/// Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key.
|
||||
/// Derived from `fs.stat().mtimeMs`, so it arrives FRACTIONAL on a real host
|
||||
/// and is decoded via `LossyNumber.int` (see there for why a plain
|
||||
/// `decode(Int.self)` silently nils this field).
|
||||
public let lastActiveMs: Int?
|
||||
/// W3 sync chip — commits on HEAD not on `@{u}` (src/types.ts:366).
|
||||
public let ahead: Int?
|
||||
/// W3 sync chip — commits on `@{u}` not on HEAD (src/types.ts:367).
|
||||
public let behind: Int?
|
||||
/// HEAD commit time in ms (`git log -1 --format=%ct * 1000`, an integer).
|
||||
public let lastCommitMs: Int?
|
||||
public let sessions: [ProjectSessionRef]
|
||||
|
||||
public init(
|
||||
name: String, path: String, isGit: Bool, branch: String?,
|
||||
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef]
|
||||
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef],
|
||||
ahead: Int? = nil, behind: Int? = nil, lastCommitMs: Int? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
@@ -82,6 +97,9 @@ public struct ProjectInfo: Sendable, Equatable {
|
||||
self.branch = branch
|
||||
self.dirty = dirty
|
||||
self.lastActiveMs = lastActiveMs
|
||||
self.ahead = ahead
|
||||
self.behind = behind
|
||||
self.lastCommitMs = lastCommitMs
|
||||
self.sessions = sessions
|
||||
}
|
||||
}
|
||||
@@ -89,6 +107,7 @@ public struct ProjectInfo: Sendable, Equatable {
|
||||
extension ProjectInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, isGit, branch, dirty, lastActiveMs, sessions
|
||||
case ahead, behind, lastCommitMs
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -98,7 +117,10 @@ extension ProjectInfo: Decodable {
|
||||
isGit = try container.decode(Bool.self, forKey: .isGit)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
dirty = try? container.decode(Bool.self, forKey: .dirty)
|
||||
lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs)
|
||||
lastActiveMs = LossyNumber.int(in: container, forKey: .lastActiveMs)
|
||||
ahead = LossyNumber.int(in: container, forKey: .ahead)
|
||||
behind = LossyNumber.int(in: container, forKey: .behind)
|
||||
lastCommitMs = LossyNumber.int(in: container, forKey: .lastCommitMs)
|
||||
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
|
||||
}
|
||||
|
||||
@@ -106,10 +128,7 @@ extension ProjectInfo: Decodable {
|
||||
/// `invalidResponseBody`; malformed elements dropped one by one (same
|
||||
/// pattern as `LiveSessionInfo.decodeList`).
|
||||
static func decodeList(from data: Data) throws -> [ProjectInfo] {
|
||||
guard let entries = try? JSONDecoder().decode([LossyBox<ProjectInfo>].self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return entries.compactMap(\.value)
|
||||
try LossyList.decodeBody(ProjectInfo.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +183,11 @@ public struct ProjectDetail: Sendable, Equatable {
|
||||
public let isGit: Bool
|
||||
public let branch: String?
|
||||
public let dirty: Bool?
|
||||
/// w6/G1: `git status --porcelain` line count, same gate as `dirty`
|
||||
/// (src/types.ts:388). nil ⇒ the host has the dirty check off, NOT "clean".
|
||||
public let dirtyCount: Int?
|
||||
/// w6/G1: upstream sync state; nil for a non-git dir (src/types.ts:389).
|
||||
public let sync: SyncState?
|
||||
public let worktrees: [WorktreeInfo]
|
||||
public let sessions: [ProjectSessionRef]
|
||||
public let hasClaudeMd: Bool
|
||||
@@ -173,13 +197,16 @@ public struct ProjectDetail: Sendable, Equatable {
|
||||
public init(
|
||||
name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?,
|
||||
worktrees: [WorktreeInfo], sessions: [ProjectSessionRef],
|
||||
hasClaudeMd: Bool, claudeMd: String?
|
||||
hasClaudeMd: Bool, claudeMd: String?,
|
||||
dirtyCount: Int? = nil, sync: SyncState? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.isGit = isGit
|
||||
self.branch = branch
|
||||
self.dirty = dirty
|
||||
self.dirtyCount = dirtyCount
|
||||
self.sync = sync
|
||||
self.worktrees = worktrees
|
||||
self.sessions = sessions
|
||||
self.hasClaudeMd = hasClaudeMd
|
||||
@@ -190,6 +217,7 @@ public struct ProjectDetail: Sendable, Equatable {
|
||||
extension ProjectDetail: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd
|
||||
case dirtyCount, sync
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -199,6 +227,8 @@ extension ProjectDetail: Decodable {
|
||||
isGit = try container.decode(Bool.self, forKey: .isGit)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
dirty = try? container.decode(Bool.self, forKey: .dirty)
|
||||
dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount)
|
||||
sync = try? container.decode(SyncState.self, forKey: .sync)
|
||||
hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false
|
||||
claudeMd = try? container.decode(String.self, forKey: .claudeMd)
|
||||
worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees)
|
||||
@@ -206,31 +236,6 @@ extension ProjectDetail: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lossy decoding helpers (shared per-element tolerance)
|
||||
|
||||
/// Per-element tolerance shim: a malformed element becomes nil instead of
|
||||
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
|
||||
struct LossyBox<Wrapped: Decodable>: Decodable {
|
||||
let value: Wrapped?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? Wrapped(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
enum LossyList {
|
||||
/// Decode `[Element]` at `key`, dropping malformed elements; a missing or
|
||||
/// wrong-typed array degrades to `[]`.
|
||||
static func decode<Element: Decodable, Key: CodingKey>(
|
||||
_ type: Element.Type,
|
||||
in container: KeyedDecodingContainer<Key>,
|
||||
forKey key: Key
|
||||
) -> [Element] {
|
||||
let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
|
||||
return boxes.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Routes + client calls
|
||||
|
||||
extension Endpoints {
|
||||
@@ -240,18 +245,18 @@ extension Endpoints {
|
||||
}
|
||||
|
||||
/// `GET /projects/detail?path=` — RO, no Origin (src/server.ts:293-310).
|
||||
/// The ONE place `path` gets percent-encoded (strict unreserved-only set —
|
||||
/// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough).
|
||||
/// nil = the path could not be encoded (surfaced as `.invalidRequest`).
|
||||
/// `path` is encoded by the shared `pathQuery` choke point (strict
|
||||
/// unreserved-only set — see `unreservedCharacters` for why
|
||||
/// `.urlQueryAllowed` is not enough). nil = the path could not be encoded
|
||||
/// (surfaced as `.invalidRequest`).
|
||||
static func projectDetail(path: String) -> APIRoute? {
|
||||
guard let encoded = path.addingPercentEncoding(
|
||||
withAllowedCharacters: unreservedCharacters
|
||||
) else { return nil }
|
||||
return APIRoute(
|
||||
pathQuery(path).map { query in
|
||||
APIRoute(
|
||||
method: .get, path: "/projects/detail", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: "path=\(encoded)"
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
|
||||
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal file
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
// Shared tolerant-decoding helpers for the UNTRUSTED server boundary (plan §4).
|
||||
// ONE home for all of them: unknown fields ignored, malformed elements dropped
|
||||
// one by one, wrong-typed optionals degraded to nil — never a crash, never a
|
||||
// whole-list failure because of a single bad entry.
|
||||
|
||||
/// Per-element tolerance shim: a malformed element becomes nil instead of
|
||||
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
|
||||
struct LossyBox<Wrapped: Decodable>: Decodable {
|
||||
let value: Wrapped?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? Wrapped(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
enum LossyList {
|
||||
/// Decode `[Element]` at `key`, dropping malformed elements; a missing or
|
||||
/// wrong-typed array degrades to `[]`.
|
||||
static func decode<Element: Decodable, Key: CodingKey>(
|
||||
_ type: Element.Type,
|
||||
in container: KeyedDecodingContainer<Key>,
|
||||
forKey key: Key
|
||||
) -> [Element] {
|
||||
let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
|
||||
return boxes.compactMap(\.value)
|
||||
}
|
||||
|
||||
/// Decode a whole top-level `[Element]` body, dropping malformed elements.
|
||||
/// A non-array top level throws `.invalidResponseBody` — that is the
|
||||
/// "this port speaks HTTP but is not web-terminal" signal, not a degrade.
|
||||
static func decodeBody<Element: Decodable>(
|
||||
_ type: Element.Type, from data: Data
|
||||
) throws -> [Element] {
|
||||
guard let boxes = try? JSONDecoder().decode([LossyBox<Element>].self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return boxes.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
enum LossyNumber {
|
||||
/// Decode an integer-valued field that the server may serialize as a
|
||||
/// FRACTIONAL JSON number.
|
||||
///
|
||||
/// This is not paranoia: every `*Ms` field derived from `fs.stat().mtimeMs`
|
||||
/// (`ProjectInfo.lastActiveMs` — src/http/projects.ts:171,
|
||||
/// `HistorySession.mtimeMs` — src/http/history.ts:105,
|
||||
/// `SyncState.lastFetchMs` — src/http/projects.ts:170) carries sub-millisecond
|
||||
/// precision on APFS: a real body reads `1785390645813.5327`. A plain
|
||||
/// `decode(Int.self)` FAILS on that, so a bare `try?` would silently null the
|
||||
/// field (or drop the whole entry) against every real server.
|
||||
static func int<Key: CodingKey>(
|
||||
in container: KeyedDecodingContainer<Key>, forKey key: Key
|
||||
) -> Int? {
|
||||
if let exact = try? container.decode(Int.self, forKey: key) { return exact }
|
||||
guard let fractional = try? container.decode(Double.self, forKey: key),
|
||||
fractional.isFinite,
|
||||
fractional >= Double(Int.min), fractional <= Double(Int.max)
|
||||
else { return nil }
|
||||
return Int(fractional)
|
||||
}
|
||||
}
|
||||
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal file
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · `GET /projects/worktree/state?path=` (src/server.ts:542-562) — RO, NO
|
||||
// Origin. Response = `src/types.ts:392-408` (`SyncState` / `WorktreeState`),
|
||||
// fetched lazily per worktree row (w6/G7) because listing N worktrees eagerly
|
||||
// would spend a git spawn per row on data nothing renders.
|
||||
|
||||
/// Upstream sync state for one repo or worktree (src/types.ts:392-398).
|
||||
///
|
||||
/// EVERY field degrades independently, and each absence is a NORMAL state: no
|
||||
/// upstream, detached HEAD, empty repo and never-fetched all leave their field
|
||||
/// nil. Two rules the UI must honour (they are the reason this type is all
|
||||
/// optionals instead of zeros):
|
||||
/// - `ahead` is always trustworthy (local refs only);
|
||||
/// - `behind` is only as fresh as `lastFetchMs`, because `@{u}` is a locally
|
||||
/// cached remote ref that only a fetch moves — a stale `behind: 0` must NEVER
|
||||
/// render as "in sync";
|
||||
/// - `upstream == nil` means "nothing to compare against", which is NOT
|
||||
/// "nothing to push".
|
||||
public struct SyncState: Sendable, Equatable {
|
||||
/// e.g. `origin/develop`; nil ⇒ the branch tracks nothing.
|
||||
public let upstream: String?
|
||||
/// Commits on HEAD not on `@{u}`.
|
||||
public let ahead: Int?
|
||||
/// Commits on `@{u}` not on HEAD — trust only with a fresh `lastFetchMs`.
|
||||
public let behind: Int?
|
||||
/// `FETCH_HEAD` mtime (ms); nil ⇒ never fetched. Comes from
|
||||
/// `fs.stat().mtimeMs`, so it is FRACTIONAL on a real host — hence Double,
|
||||
/// not Int (src/http/projects.ts readLastFetchMs).
|
||||
public let lastFetchMs: Double?
|
||||
/// HEAD is not on a branch ⇒ no branch, no ahead/behind.
|
||||
public let detached: Bool?
|
||||
|
||||
public init(
|
||||
upstream: String? = nil, ahead: Int? = nil, behind: Int? = nil,
|
||||
lastFetchMs: Double? = nil, detached: Bool? = nil
|
||||
) {
|
||||
self.upstream = upstream
|
||||
self.ahead = ahead
|
||||
self.behind = behind
|
||||
self.lastFetchMs = lastFetchMs
|
||||
self.detached = detached
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncState: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case upstream, ahead, behind, lastFetchMs, detached
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
upstream = try? container.decode(String.self, forKey: .upstream)
|
||||
ahead = LossyNumber.int(in: container, forKey: .ahead)
|
||||
behind = LossyNumber.int(in: container, forKey: .behind)
|
||||
lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs)
|
||||
detached = try? container.decode(Bool.self, forKey: .detached)
|
||||
}
|
||||
}
|
||||
|
||||
/// The git state of ONE worktree (src/types.ts:402-408).
|
||||
public struct WorktreeState: Sendable, Equatable {
|
||||
public let path: String
|
||||
public let branch: String?
|
||||
public let sync: SyncState?
|
||||
/// `git status --porcelain` line count; nil ⇒ the host has the dirty check
|
||||
/// disabled (`PROJECT_DIRTY_CHECK=0`), which is NOT "clean".
|
||||
public let dirtyCount: Int?
|
||||
|
||||
public init(path: String, branch: String? = nil, sync: SyncState? = nil, dirtyCount: Int? = nil) {
|
||||
self.path = path
|
||||
self.branch = branch
|
||||
self.sync = sync
|
||||
self.dirtyCount = dirtyCount
|
||||
}
|
||||
}
|
||||
|
||||
extension WorktreeState: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path, branch, sync, dirtyCount
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
path = try container.decode(String.self, forKey: .path)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
sync = try? container.decode(SyncState.self, forKey: .sync)
|
||||
dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
/// `GET /projects/worktree/state?path=` — RO, no Origin.
|
||||
static func worktreeState(path: String) -> APIRoute? {
|
||||
pathQuery(path).map { query in
|
||||
APIRoute(
|
||||
method: .get, path: "/projects/worktree/state", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: query
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /projects/worktree/state?path=` — branch + sync + dirty count for
|
||||
/// ONE worktree row. RO → no Origin. 400 → `.projectPathInvalid`,
|
||||
/// **404 → `.worktreeNotFound`** (this route's 404 means "not a worktree of
|
||||
/// this repo", not "no such project"), 500 → `.gitDataUnavailable`.
|
||||
/// An empty path is rejected before any network I/O.
|
||||
public func worktreeState(path: String) async throws -> WorktreeState {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
guard let route = Endpoints.worktreeState(path: path) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await perform(route)
|
||||
try Self.requireGitReadOK(response, notFound: .worktreeNotFound)
|
||||
return try Self.decodeObject(WorktreeState.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -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 — 非数组
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// Complete failure taxonomy for a per-host access token: the three validation
|
||||
/// verdicts plus the one store-level miss. One enum so the UI has a single
|
||||
/// thing to switch over when it saves a token (never a generic `Error`).
|
||||
public enum AccessTokenError: Error, Equatable {
|
||||
case tooShort(length: Int)
|
||||
case tooLong(length: Int)
|
||||
/// Deliberately payload-free. An error value gets logged, wrapped in an
|
||||
/// alert string, and attached to crash reports — carrying the rejected
|
||||
/// token here would turn every one of those into a leak (§5.3). The
|
||||
/// length cases carry only a length, which is not secret.
|
||||
case invalidCharacters
|
||||
/// `setAccessToken` for an id that is not in the store. Explicit error
|
||||
/// rather than a silent no-op: a UI that thinks it saved a token but
|
||||
/// didn't would send unauthenticated requests forever.
|
||||
case unknownHost(UUID)
|
||||
}
|
||||
|
||||
/// The host's shared access token (`WEBTERM_TOKEN`), validated at the boundary.
|
||||
///
|
||||
/// Charset and length are the FROZEN server contract (coordination doc §1.1),
|
||||
/// byte-for-byte the same rule the server enforces at config load —
|
||||
/// `/^[A-Za-z0-9._~+/=-]{16,512}$/` (src/config.ts:178). Validating here means
|
||||
/// a token that cannot possibly authenticate is rejected at the keyboard
|
||||
/// instead of turning into a mystery 401 later, and a value of this type is
|
||||
/// always safe to place verbatim in a `Cookie: webterm_auth=<t>` header (the
|
||||
/// charset is exactly the cookie-safe one — nothing to escape).
|
||||
///
|
||||
/// SECRET MATERIAL (§5.3). Three deliberate properties keep it out of logs:
|
||||
/// - `description`/`debugDescription` are redacted, so string interpolation
|
||||
/// (`"\(token)"`) — the way a token usually reaches a log — cannot leak it.
|
||||
/// - `customMirror` hides the storage, so `dump()` and reflection-based crash
|
||||
/// reporters see `<redacted>` too.
|
||||
/// - it is intentionally NOT `Codable`: nothing can serialize a token by
|
||||
/// accident. `Host` encodes it explicitly (one audited call site) into the
|
||||
/// Keychain item, which is the only place a token is ever persisted.
|
||||
public struct AccessToken: Sendable, Hashable, RawRepresentable,
|
||||
CustomStringConvertible, CustomDebugStringConvertible,
|
||||
CustomReflectable {
|
||||
/// §1.1 length window (server: `{16,512}`).
|
||||
public static let minLength = 16
|
||||
public static let maxLength = 512
|
||||
|
||||
/// §1.1 charset `[A-Za-z0-9._~+/=-]`. A `Set` so validation is O(n) in the
|
||||
/// token length with no regex engine in the hot path.
|
||||
private static let allowedCharacters: Set<Character> = Set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-"
|
||||
)
|
||||
|
||||
/// The token, verbatim as it must appear in the `Cookie` header.
|
||||
public let rawValue: String
|
||||
|
||||
/// Validating init — the ONLY way a token enters the app.
|
||||
///
|
||||
/// Leading/trailing whitespace is trimmed first: tokens arrive by paste,
|
||||
/// and whitespace is never a legal token character, so trimming can never
|
||||
/// change the meaning of a valid token. Interior whitespace is NOT
|
||||
/// tolerated (that would be silent normalization of a secret).
|
||||
public init(validating raw: String) throws {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let length = trimmed.count
|
||||
guard length >= Self.minLength else { throw AccessTokenError.tooShort(length: length) }
|
||||
guard length <= Self.maxLength else { throw AccessTokenError.tooLong(length: length) }
|
||||
guard trimmed.allSatisfy(Self.allowedCharacters.contains) else {
|
||||
throw AccessTokenError.invalidCharacters
|
||||
}
|
||||
self.rawValue = trimmed
|
||||
}
|
||||
|
||||
/// Lenient seam over the same rules (`RawRepresentable`): invalid → nil.
|
||||
/// Used where a rejected value must degrade instead of failing a whole
|
||||
/// operation — decoding a tampered Keychain record (see `Host`).
|
||||
///
|
||||
/// Note: whitespace-padded input normalizes, so `rawValue` round-trips
|
||||
/// only for already-trimmed strings.
|
||||
public init?(rawValue: String) {
|
||||
guard let token = try? AccessToken(validating: rawValue) else { return nil }
|
||||
self = token
|
||||
}
|
||||
|
||||
public var description: String { "AccessToken(redacted, \(rawValue.count) chars)" }
|
||||
|
||||
public var debugDescription: String { description }
|
||||
|
||||
public var customMirror: Mirror {
|
||||
Mirror(self, children: ["rawValue": "<redacted>"], displayStyle: .struct)
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,90 @@ import WireProtocol
|
||||
/// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module;
|
||||
/// downstream test/app targets that import Foundation should alias
|
||||
/// `typealias Host = HostRegistry.Host` or qualify.
|
||||
public struct Host: Sendable, Equatable, Codable, Identifiable {
|
||||
public struct Host: Sendable, Equatable, Codable, Identifiable,
|
||||
CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
/// Single point of Origin/wsURL derivation — from WireProtocol, never
|
||||
/// redeclared here (plan §6 rule 1).
|
||||
public let endpoint: HostEndpoint
|
||||
/// This host's access token (`WEBTERM_TOKEN`), or nil when the host has
|
||||
/// no auth configured — which is the zero-config LAN default, so nil is
|
||||
/// the normal state, not an error state.
|
||||
///
|
||||
/// Why it lives ON the host record instead of in its own Keychain item:
|
||||
/// plan §5.3 assigns the host list to the Keychain precisely as the place
|
||||
/// for "host 列表(含未来 authMaterial 占位)". Piggy-backing on that one
|
||||
/// item means the token inherits the audited protection policy
|
||||
/// (`AfterFirstUnlockThisDeviceOnly`, never synchronizable — see
|
||||
/// `KeychainItemSpec`) and, just as importantly, that removing a host
|
||||
/// removes its secret in the same write — a separate item could be
|
||||
/// orphaned in the Keychain forever.
|
||||
public let accessToken: AccessToken?
|
||||
|
||||
public init(id: UUID, name: String, endpoint: HostEndpoint) {
|
||||
/// `accessToken` defaults to nil so every existing three-argument call
|
||||
/// site (pairing, tests, fixtures) keeps compiling and keeps meaning
|
||||
/// "no token configured".
|
||||
public init(id: UUID, name: String, endpoint: HostEndpoint, accessToken: AccessToken? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.endpoint = endpoint
|
||||
self.accessToken = accessToken
|
||||
}
|
||||
|
||||
/// Immutable update: returns a NEW host with the token set (or cleared with
|
||||
/// nil). Never mutates the receiver — the store persists the returned value.
|
||||
public func withAccessToken(_ token: AccessToken?) -> Host {
|
||||
Host(id: id, name: name, endpoint: endpoint, accessToken: token)
|
||||
}
|
||||
|
||||
// MARK: - Codable (hand-written: migration + tamper behaviour is the point)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, endpoint, accessToken
|
||||
}
|
||||
|
||||
/// MIGRATION SAFETY: a host paired by an earlier build has no
|
||||
/// `accessToken` key at all. `decodeIfPresent` maps both "key absent" and
|
||||
/// "key is null" to nil, so those records keep loading unchanged.
|
||||
///
|
||||
/// TAMPER SAFETY: a stored value that no longer passes §1.1 validation
|
||||
/// degrades to nil (the user re-enters a token) instead of throwing —
|
||||
/// throwing here would surface as `KeychainHostStoreError.corruptedData`
|
||||
/// for the WHOLE list, i.e. every paired host lost over one bad field.
|
||||
/// The `endpoint` stays strictly validated (a host without a dialable URL
|
||||
/// is not a host), so this leniency is scoped to the one optional field.
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(UUID.self, forKey: .id)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
self.endpoint = try container.decode(HostEndpoint.self, forKey: .endpoint)
|
||||
let stored = try container.decodeIfPresent(String.self, forKey: .accessToken)
|
||||
self.accessToken = stored.flatMap(AccessToken.init(rawValue:))
|
||||
}
|
||||
|
||||
/// Encodes the token as a plain string under `accessToken`, and omits the
|
||||
/// key entirely when there is none (an absent key is exactly what the
|
||||
/// decoder above treats as "no token", so old and new shapes stay
|
||||
/// interchangeable in both directions).
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(endpoint, forKey: .endpoint)
|
||||
try container.encodeIfPresent(accessToken?.rawValue, forKey: .accessToken)
|
||||
}
|
||||
|
||||
// MARK: - Redacted description (§5.3)
|
||||
|
||||
/// Hand-written so the token can never ride along into a log line: the
|
||||
/// synthesized reflection dump would print the `accessToken` child, and a
|
||||
/// `Host` is the natural thing to interpolate when logging a connection.
|
||||
/// Only the token's PRESENCE is reported.
|
||||
public var description: String {
|
||||
"Host(id: \(id), name: \(name), origin: \(endpoint.originHeader), "
|
||||
+ "hasAccessToken: \(accessToken != nil))"
|
||||
}
|
||||
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
@@ -10,8 +10,44 @@ public protocol HostStore: Sendable {
|
||||
/// Returns the new collection.
|
||||
func upsert(_ host: Host) async throws -> [Host]
|
||||
/// Removing an unknown `id` is an explicit no-op: returns the unchanged
|
||||
/// collection, never throws for "not found".
|
||||
/// collection, never throws for "not found". Removing a host also removes
|
||||
/// its `accessToken` — the token is part of the record (see `Host`), so no
|
||||
/// orphaned secret can survive in storage.
|
||||
func remove(id: UUID) async throws -> [Host]
|
||||
|
||||
/// This host's access token, or nil when none is configured / the host is
|
||||
/// unknown. A read never throws for "not found" (mirrors `remove`).
|
||||
func accessToken(host: UUID) async throws -> AccessToken?
|
||||
/// Sets (or with nil, clears) the host's access token and returns the new
|
||||
/// collection. Throws `AccessTokenError.unknownHost` rather than silently
|
||||
/// dropping a token the UI believes it saved.
|
||||
func setAccessToken(_ token: AccessToken?, host: UUID) async throws -> [Host]
|
||||
}
|
||||
|
||||
/// Generic token read/write built from `loadAll`/`upsert`, so every conformer —
|
||||
/// including test doubles outside this package — gets the behaviour for free
|
||||
/// and cannot drift from it. `KeychainHostStore`/`InMemoryHostStore` override
|
||||
/// both to do the read-modify-write INSIDE their actor (atomic against a
|
||||
/// concurrent upsert/remove); this default's two suspension points cannot be.
|
||||
extension HostStore {
|
||||
public func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
try await loadAll().first { $0.id == id }?.accessToken
|
||||
}
|
||||
|
||||
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
|
||||
let current = try await loadAll()
|
||||
guard let target = current.first(where: { $0.id == id }) else {
|
||||
throw AccessTokenError.unknownHost(id)
|
||||
}
|
||||
guard target.accessToken != token else { return current } // no-op: unchanged
|
||||
return try await upsert(target.withAccessToken(token))
|
||||
}
|
||||
|
||||
/// Clearing is writing nil — named so the intent reads at the call site
|
||||
/// (the token-settings UI removes a token; it does not "set nil").
|
||||
public func clearAccessToken(host id: UUID) async throws -> [Host] {
|
||||
try await setAccessToken(nil, host: id)
|
||||
}
|
||||
}
|
||||
|
||||
// Pure collection transforms shared by all HostStore implementations (DRY);
|
||||
@@ -26,4 +62,12 @@ extension [Host] {
|
||||
func removing(id: UUID) -> [Host] {
|
||||
filter { $0.id != id }
|
||||
}
|
||||
|
||||
/// Sets/clears one host's token. Returns nil when `id` is not in the
|
||||
/// collection so callers can raise `AccessTokenError.unknownHost` — a
|
||||
/// sentinel-free way to distinguish "unchanged" from "unknown host".
|
||||
func settingAccessToken(_ token: AccessToken?, host id: UUID) -> [Host]? {
|
||||
guard contains(where: { $0.id == id }) else { return nil }
|
||||
return map { $0.id == id ? $0.withAccessToken(token) : $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,18 @@ public actor InMemoryHostStore: HostStore {
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
public func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
hosts.first { $0.id == id }?.accessToken
|
||||
}
|
||||
|
||||
/// Atomic override for the same reason as `KeychainHostStore`'s: the
|
||||
/// read-modify-write stays inside the actor.
|
||||
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
|
||||
guard let updated = hosts.settingAccessToken(token, host: id) else {
|
||||
throw AccessTokenError.unknownHost(id)
|
||||
}
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,27 @@ public actor KeychainHostStore: HostStore {
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Access token (stored inside the same host record — see Host)
|
||||
|
||||
public func accessToken(host id: UUID) async throws -> AccessToken? {
|
||||
try readHosts().first { $0.id == id }?.accessToken
|
||||
}
|
||||
|
||||
/// Atomic override of the `HostStore` default: the read-modify-write runs
|
||||
/// inside the actor, so a concurrent `upsert`/`remove` cannot interleave
|
||||
/// and lose the token (the default implementation awaits twice).
|
||||
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
|
||||
let current = try readHosts()
|
||||
guard let updated = current.settingAccessToken(token, host: id) else {
|
||||
throw AccessTokenError.unknownHost(id)
|
||||
}
|
||||
guard updated != current else {
|
||||
return current // explicit no-op: same token, nothing persisted
|
||||
}
|
||||
try persist(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec)
|
||||
|
||||
private func readHosts() throws -> [Host] {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import HostRegistry
|
||||
|
||||
// AccessToken:边界校验 + "零泄漏"表示。
|
||||
// 契约来源:协调 doc §1.1 —— 字符集 `[A-Za-z0-9._~+/=-]`、长度 16–512
|
||||
// (与服务端 src/config.ts:178 的 WEBTERM_TOKEN_RE 逐字一致);令牌是密级材料,
|
||||
// 绝不进日志 / URL / 崩溃报告(§5.3)。
|
||||
|
||||
// MARK: - 边界校验(字符集)
|
||||
|
||||
@Test("合法令牌:覆盖全字符集 → 构造成功且 rawValue 逐字保真")
|
||||
func validTokenPreservesRawValueVerbatim() throws {
|
||||
// Arrange
|
||||
let raw = Fixtures.validTokenString
|
||||
|
||||
// Act
|
||||
let token = try AccessToken(validating: raw)
|
||||
|
||||
// Assert
|
||||
#expect(token.rawValue == raw)
|
||||
}
|
||||
|
||||
@Test("非法字符 → .invalidCharacters", arguments: [
|
||||
"abcdefghijklmno p", // 空格
|
||||
"abcdefghijklmnop!", // 感叹号
|
||||
"abcdefghijklmnop%", // 百分号(需 URL 编码 → 不在 cookie-safe 集里)
|
||||
"abcdefghijklmnop,", // 逗号(cookie 分隔符)
|
||||
"abcdefghijklmnop;", // 分号(cookie 分隔符)
|
||||
"abcdefghijklmnop令", // 非 ASCII
|
||||
])
|
||||
func invalidCharactersAreRejectedWithTypedError(raw: String) {
|
||||
// Act / Assert
|
||||
#expect(throws: AccessTokenError.invalidCharacters) {
|
||||
_ = try AccessToken(validating: raw)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("非法字符错误不携带令牌内容(错误对象本身不得成为泄漏面)")
|
||||
func invalidCharactersErrorCarriesNoTokenPayload() {
|
||||
// Arrange
|
||||
let secretish = "supersecret-tail!!!!!!"
|
||||
|
||||
// Act
|
||||
var captured = ""
|
||||
do {
|
||||
_ = try AccessToken(validating: secretish)
|
||||
Issue.record("期望抛错,实际构造成功")
|
||||
} catch {
|
||||
captured = String(describing: error) + String(reflecting: error)
|
||||
}
|
||||
|
||||
// Assert
|
||||
#expect(captured.isEmpty == false)
|
||||
#expect(captured.contains("supersecret") == false)
|
||||
}
|
||||
|
||||
// MARK: - 边界校验(长度)
|
||||
|
||||
@Test("长度下限:15 → .tooShort(15)")
|
||||
func tokenBelowMinimumLengthIsRejected() {
|
||||
// Act / Assert
|
||||
#expect(throws: AccessTokenError.tooShort(length: 15)) {
|
||||
_ = try AccessToken(validating: Fixtures.tokenString(length: 15))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("长度下限:恰好 16 → 通过")
|
||||
func tokenAtMinimumLengthIsAccepted() throws {
|
||||
// Act
|
||||
let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.minLength))
|
||||
|
||||
// Assert
|
||||
#expect(token.rawValue.count == 16)
|
||||
}
|
||||
|
||||
@Test("长度上限:恰好 512 → 通过")
|
||||
func tokenAtMaximumLengthIsAccepted() throws {
|
||||
// Act
|
||||
let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.maxLength))
|
||||
|
||||
// Assert
|
||||
#expect(token.rawValue.count == 512)
|
||||
}
|
||||
|
||||
@Test("长度上限:513 → .tooLong(513)")
|
||||
func tokenAboveMaximumLengthIsRejected() {
|
||||
// Act / Assert
|
||||
#expect(throws: AccessTokenError.tooLong(length: 513)) {
|
||||
_ = try AccessToken(validating: Fixtures.tokenString(length: 513))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("空串 → .tooShort(0)(不是 invalidCharacters:先判长度更贴近用户话术)")
|
||||
func emptyTokenIsRejectedAsTooShort() {
|
||||
// Act / Assert
|
||||
#expect(throws: AccessTokenError.tooShort(length: 0)) {
|
||||
_ = try AccessToken(validating: "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 粘贴 UX:首尾空白裁剪
|
||||
|
||||
@Test("首尾空白/换行被裁剪后再校验:合法令牌不受影响(空白永非合法令牌字符)")
|
||||
func surroundingWhitespaceIsTrimmedBeforeValidation() throws {
|
||||
// Arrange
|
||||
let pasted = " \n\t" + Fixtures.validTokenString + " \n"
|
||||
|
||||
// Act
|
||||
let token = try AccessToken(validating: pasted)
|
||||
|
||||
// Assert
|
||||
#expect(token.rawValue == Fixtures.validTokenString)
|
||||
}
|
||||
|
||||
@Test("中间空白不被裁剪:仍是 .invalidCharacters(不做静默规范化)")
|
||||
func interiorWhitespaceIsStillInvalid() {
|
||||
// Act / Assert
|
||||
#expect(throws: AccessTokenError.invalidCharacters) {
|
||||
_ = try AccessToken(validating: "abcdefgh ijklmnop")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("init?(rawValue:) 宽松入口:非法 → nil,不抛")
|
||||
func lenientRawValueInitReturnsNilForInvalidToken() {
|
||||
// Act / Assert
|
||||
#expect(AccessToken(rawValue: "too-short") == nil)
|
||||
#expect(AccessToken(rawValue: Fixtures.validTokenString)?.rawValue == Fixtures.validTokenString)
|
||||
}
|
||||
|
||||
// MARK: - §5.3 零泄漏表示
|
||||
|
||||
@Test("§5.3 description/debugDescription 不含令牌,只给长度")
|
||||
func stringRepresentationsRedactTheToken() {
|
||||
// Arrange
|
||||
let token = Fixtures.makeToken()
|
||||
|
||||
// Act
|
||||
let description = token.description
|
||||
let debugDescription = token.debugDescription
|
||||
|
||||
// Assert
|
||||
#expect(description.contains(Fixtures.validTokenString) == false)
|
||||
#expect(debugDescription.contains(Fixtures.validTokenString) == false)
|
||||
#expect(description.contains("redacted"))
|
||||
#expect(description.contains("\(Fixtures.validTokenString.count)"))
|
||||
}
|
||||
|
||||
@Test("§5.3 String(describing:)/String(reflecting:) 也不泄漏(插值即日志的默认路径)")
|
||||
func interpolationPathsRedactTheToken() {
|
||||
// Arrange
|
||||
let token = Fixtures.makeToken()
|
||||
|
||||
// Act
|
||||
let interpolated = "token=\(token)"
|
||||
|
||||
// Assert
|
||||
#expect(interpolated.contains(Fixtures.validTokenString) == false)
|
||||
#expect(String(describing: token).contains(Fixtures.validTokenString) == false)
|
||||
#expect(String(reflecting: token).contains(Fixtures.validTokenString) == false)
|
||||
}
|
||||
|
||||
@Test("§5.3 Mirror/dump 反射不泄漏令牌(崩溃报告与 dump 是常见旁路)")
|
||||
func reflectionDoesNotExposeTheToken() {
|
||||
// Arrange
|
||||
let token = Fixtures.makeToken()
|
||||
|
||||
// Act
|
||||
let children = Mirror(reflecting: token).children.map { String(describing: $0.value) }
|
||||
var dumped = ""
|
||||
dump(token, to: &dumped)
|
||||
|
||||
// Assert
|
||||
#expect(children.contains(where: { $0.contains(Fixtures.validTokenString) }) == false)
|
||||
#expect(dumped.contains(Fixtures.validTokenString) == false)
|
||||
}
|
||||
|
||||
@Test("§5.3 Host 的字符串表示不含令牌,只标注是否已配置")
|
||||
func hostStringRepresentationRedactsTheToken() {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
|
||||
|
||||
// Act
|
||||
let described = String(describing: host)
|
||||
let reflected = String(reflecting: host)
|
||||
var dumped = ""
|
||||
dump(host, to: &dumped)
|
||||
|
||||
// Assert
|
||||
#expect(described.contains(Fixtures.validTokenString) == false)
|
||||
#expect(reflected.contains(Fixtures.validTokenString) == false)
|
||||
#expect(dumped.contains(Fixtures.validTokenString) == false)
|
||||
#expect(described.contains("hasAccessToken: true"))
|
||||
#expect(String(describing: Fixtures.makeHost()).contains("hasAccessToken: false"))
|
||||
}
|
||||
|
||||
// MARK: - 值语义
|
||||
|
||||
@Test("Equatable/Hashable:同值相等且同 hash,异值不等")
|
||||
func tokenValueSemanticsAreByRawValue() {
|
||||
// Arrange
|
||||
let a = Fixtures.makeToken()
|
||||
let b = Fixtures.makeToken()
|
||||
let other = Fixtures.makeToken(Fixtures.otherValidTokenString)
|
||||
|
||||
// Assert
|
||||
#expect(a == b)
|
||||
#expect(a.hashValue == b.hashValue)
|
||||
#expect(a != other)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
import HostRegistry
|
||||
|
||||
// 按主机存访问令牌的 store 语义(B2):read / write / clear,以及
|
||||
// "移除主机必须一起移除其令牌"(Keychain 里不留孤儿密文)。
|
||||
// 令牌就存在既有的 host 列表 Keychain item 里 —— 计划 §5.3 明写
|
||||
// "Keychain:host 列表(含未来 authMaterial 占位)",故 remove(id:) 天然连带清除。
|
||||
|
||||
/// 取出某次 keychain 写入的 JSON 载荷文本(用于"载荷里不得再出现该令牌"断言)。
|
||||
///
|
||||
/// 必须先把 `\/` 还原成 `/`:JSONEncoder 默认转义正斜杠,而令牌字符集里恰好含 `/` ——
|
||||
/// 不还原的话 `contains(token) == false` 这类"无残留密文"断言会因为转义而**假通过**。
|
||||
/// 令牌字符集 `[A-Za-z0-9._~+/=-]` 中只有 `/` 会被 JSON 转义,故这一步足够。
|
||||
private func payloadText(_ attributes: [String: any Sendable]?) -> String {
|
||||
guard let data = attributes?[kSecValueData as String] as? Data else { return "" }
|
||||
return String(decoding: data, as: UTF8.self).replacingOccurrences(of: "\\/", with: "/")
|
||||
}
|
||||
|
||||
/// 第三方 conformer:只实现 §3.3 原有三个方法,不 override 令牌方法 ——
|
||||
/// 钉死协议默认实现(App 层已有的自定义 HostStore 替身走的就是这条路)。
|
||||
private actor MinimalHostStore: HostStore {
|
||||
private var hosts: [Host]
|
||||
|
||||
init(hosts: [Host]) { self.hosts = hosts }
|
||||
|
||||
func loadAll() async throws -> [Host] { hosts }
|
||||
|
||||
func upsert(_ host: Host) async throws -> [Host] {
|
||||
hosts = hosts.contains(where: { $0.id == host.id })
|
||||
? hosts.map { $0.id == host.id ? host : $0 }
|
||||
: hosts + [host]
|
||||
return hosts
|
||||
}
|
||||
|
||||
func remove(id: UUID) async throws -> [Host] {
|
||||
hosts = hosts.filter { $0.id != id }
|
||||
return hosts
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 写入 / 读取 / 清除(Keychain 实现)
|
||||
|
||||
@Test("setAccessToken 后 accessToken(host:) 读回同值,且 loadAll 的 Host 带上令牌")
|
||||
func setAccessTokenPersistsAndReadsBack() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
let token = Fixtures.makeToken()
|
||||
|
||||
// Act
|
||||
let updated = try await store.setAccessToken(token, host: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(updated.first?.accessToken == token)
|
||||
#expect(try await store.accessToken(host: host.id) == token)
|
||||
#expect(try await store.loadAll().first?.accessToken == token)
|
||||
}
|
||||
|
||||
@Test("令牌跨 store 实例存活:同一 shim 上的新 store 读回令牌")
|
||||
func accessTokenSurvivesAcrossStoreInstances() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let host = Fixtures.makeHost()
|
||||
let writer = KeychainHostStore(shim: shim)
|
||||
_ = try await writer.upsert(host)
|
||||
_ = try await writer.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
|
||||
// Act
|
||||
let reader = KeychainHostStore(shim: shim)
|
||||
|
||||
// Assert
|
||||
#expect(try await reader.accessToken(host: host.id) == Fixtures.makeToken())
|
||||
}
|
||||
|
||||
@Test("未配置令牌的主机:accessToken(host:) 为 nil(不是错误)")
|
||||
func accessTokenIsNilWhenNeverSet() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
|
||||
// Act / Assert
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
}
|
||||
|
||||
@Test("未知 host 的 accessToken 查询:nil(不 throw —— 只读路径宽松)")
|
||||
func accessTokenForUnknownHostIsNil() async throws {
|
||||
// Arrange
|
||||
let store = KeychainHostStore(shim: FakeSecItemShim())
|
||||
|
||||
// Act / Assert
|
||||
#expect(try await store.accessToken(host: UUID()) == nil)
|
||||
}
|
||||
|
||||
@Test("setAccessToken(nil) 清除令牌,但主机本身保留")
|
||||
func setAccessTokenNilClearsTokenAndKeepsHost() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
|
||||
// Act
|
||||
let updated = try await store.setAccessToken(nil, host: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(updated.map(\.id) == [host.id])
|
||||
#expect(updated.first?.accessToken == nil)
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
#expect(payloadText(shim.recordedUpdates.last?.attributes).contains(Fixtures.validTokenString) == false)
|
||||
}
|
||||
|
||||
@Test("clearAccessToken(host:) 等价于 setAccessToken(nil, host:)")
|
||||
func clearAccessTokenRemovesTheToken() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
|
||||
// Act
|
||||
let updated = try await store.clearAccessToken(host: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(updated.first?.accessToken == nil)
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
}
|
||||
|
||||
@Test("替换令牌:旧值不再出现在写入载荷里")
|
||||
func replacingTokenLeavesNoTraceOfTheOldValue() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
|
||||
// Act
|
||||
let updated = try await store.setAccessToken(
|
||||
Fixtures.makeToken(Fixtures.otherValidTokenString), host: host.id
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(updated.first?.accessToken?.rawValue == Fixtures.otherValidTokenString)
|
||||
let payload = payloadText(shim.recordedUpdates.last?.attributes)
|
||||
#expect(payload.contains(Fixtures.otherValidTokenString))
|
||||
#expect(payload.contains(Fixtures.validTokenString) == false)
|
||||
}
|
||||
|
||||
@Test("重复写同一令牌:显式 no-op,零额外持久化调用")
|
||||
func settingTheSameTokenTwiceIsAnExplicitNoOp() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
let addsBefore = shim.recordedAdds.count
|
||||
let updatesBefore = shim.recordedUpdates.count
|
||||
|
||||
// Act
|
||||
let updated = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(updated.first?.accessToken == Fixtures.makeToken())
|
||||
#expect(shim.recordedAdds.count == addsBefore)
|
||||
#expect(shim.recordedUpdates.count == updatesBefore)
|
||||
}
|
||||
|
||||
@Test("给未知 host 写令牌:抛 .unknownHost,零持久化(绝不静默丢弃)")
|
||||
func setAccessTokenForUnknownHostThrowsAndPersistsNothing() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
_ = try await store.upsert(Fixtures.makeHost())
|
||||
let addsBefore = shim.recordedAdds.count
|
||||
let unknownId = UUID()
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: AccessTokenError.unknownHost(unknownId)) {
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId)
|
||||
}
|
||||
#expect(shim.recordedAdds.count == addsBefore)
|
||||
#expect(shim.recordedUpdates.isEmpty)
|
||||
#expect(shim.recordedDeletes.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 移除主机连带清除令牌(无孤儿密文)
|
||||
|
||||
@Test("remove 最后一个主机:整个 keychain item 被删,令牌随之消失")
|
||||
func removingLastHostDeletesTheItemAndItsToken() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
|
||||
// Act
|
||||
let updated = try await store.remove(id: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(updated.isEmpty)
|
||||
#expect(shim.recordedDeletes.count == 1)
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
#expect(try await KeychainHostStore(shim: shim).loadAll().isEmpty)
|
||||
}
|
||||
|
||||
@Test("remove 其中一个主机:新载荷不含其令牌,另一主机的令牌完好")
|
||||
func removingOneHostStripsOnlyItsTokenFromThePayload() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let doomed = Fixtures.makeHost(name: "doomed")
|
||||
let keeper = Fixtures.makeHost(name: "keeper", urlString: "https://mac.ts.net")
|
||||
_ = try await store.upsert(doomed)
|
||||
_ = try await store.upsert(keeper)
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: doomed.id)
|
||||
_ = try await store.setAccessToken(
|
||||
Fixtures.makeToken(Fixtures.otherValidTokenString), host: keeper.id
|
||||
)
|
||||
|
||||
// Act
|
||||
let updated = try await store.remove(id: doomed.id)
|
||||
|
||||
// Assert
|
||||
#expect(updated.map(\.id) == [keeper.id])
|
||||
let payload = payloadText(shim.recordedUpdates.last?.attributes)
|
||||
#expect(payload.contains(Fixtures.validTokenString) == false)
|
||||
#expect(payload.contains(Fixtures.otherValidTokenString))
|
||||
#expect(try await store.accessToken(host: keeper.id)?.rawValue == Fixtures.otherValidTokenString)
|
||||
}
|
||||
|
||||
// MARK: - §5.3 属性字典不因令牌而改变
|
||||
|
||||
@Test("§5.3 带令牌写入时 add 属性字典仍是策略集:6 键、无 synchronizable、accessible 重申")
|
||||
func storingATokenDoesNotWeakenTheKeychainPolicy() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test")
|
||||
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
|
||||
|
||||
// Act
|
||||
_ = try await store.upsert(host)
|
||||
|
||||
// Assert
|
||||
let attrs = try #require(shim.recordedAdds.first)
|
||||
#expect(attrs[kSecUseDataProtectionKeychain as String] as? Bool == true)
|
||||
#expect(attrs[kSecAttrAccessible as String] as? String
|
||||
== kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
|
||||
#expect(attrs[kSecAttrSynchronizable as String] == nil)
|
||||
#expect(attrs.count == 6)
|
||||
}
|
||||
|
||||
// MARK: - 实现间契约对等
|
||||
|
||||
@Test("InMemoryHostStore:read/write/clear 与 Keychain 实现语义一致")
|
||||
func inMemoryStoreHasTheSameAccessTokenSemantics() async throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost()
|
||||
let store = InMemoryHostStore(hosts: [host])
|
||||
let token = Fixtures.makeToken()
|
||||
|
||||
// Act
|
||||
let afterSet = try await store.setAccessToken(token, host: host.id)
|
||||
let read = try await store.accessToken(host: host.id)
|
||||
let afterClear = try await store.clearAccessToken(host: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(afterSet.first?.accessToken == token)
|
||||
#expect(read == token)
|
||||
#expect(afterClear.first?.accessToken == nil)
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
}
|
||||
|
||||
@Test("InMemoryHostStore:未知 host 写令牌同样抛 .unknownHost")
|
||||
func inMemoryStoreRejectsUnknownHostToo() async {
|
||||
// Arrange
|
||||
let store = InMemoryHostStore()
|
||||
let unknownId = UUID()
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: AccessTokenError.unknownHost(unknownId)) {
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("InMemoryHostStore:remove 主机 → 令牌一并消失")
|
||||
func inMemoryStoreRemovingHostDropsItsToken() async throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost()
|
||||
let store = InMemoryHostStore(hosts: [host])
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
|
||||
|
||||
// Act
|
||||
_ = try await store.remove(id: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(try await store.accessToken(host: host.id) == nil)
|
||||
}
|
||||
|
||||
@Test("协议默认实现:只实现原三方法的 conformer 也能 read/write/clear")
|
||||
func protocolDefaultImplementationsWorkForMinimalConformers() async throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost()
|
||||
let store = MinimalHostStore(hosts: [host])
|
||||
let token = Fixtures.makeToken()
|
||||
|
||||
// Act
|
||||
let afterSet = try await store.setAccessToken(token, host: host.id)
|
||||
let read = try await store.accessToken(host: host.id)
|
||||
let afterClear = try await store.clearAccessToken(host: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(afterSet.first?.accessToken == token)
|
||||
#expect(read == token)
|
||||
#expect(afterClear.first?.accessToken == nil)
|
||||
}
|
||||
|
||||
@Test("协议默认实现:未知 host 写令牌抛 .unknownHost")
|
||||
func protocolDefaultImplementationRejectsUnknownHost() async {
|
||||
// Arrange
|
||||
let store = MinimalHostStore(hosts: [])
|
||||
let unknownId = UUID()
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: AccessTokenError.unknownHost(unknownId)) {
|
||||
_ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
import HostRegistry
|
||||
|
||||
// 迁移安全 + 持久化形状(B2)。
|
||||
// 已配对的主机是**当前线上构建**写进 Keychain 的旧形状记录(没有 accessToken 字段);
|
||||
// 新构建必须照常读出它们 —— 缺字段 = nil,绝不是解码失败(否则用户的主机列表整体变
|
||||
// .corruptedData,等于要求重新配对所有主机)。
|
||||
|
||||
/// 上一版 `Host` 的编码形状:只有 id/name/endpoint,合成 Codable。
|
||||
/// 用它来生成"旧记录",比手写 JSON 更能保真上一版编码器的输出。
|
||||
private struct LegacyHostRecord: Encodable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let endpoint: HostEndpoint
|
||||
}
|
||||
|
||||
private func encodedLegacyPayload(_ host: Host) throws -> Data {
|
||||
try JSONEncoder().encode([
|
||||
LegacyHostRecord(id: host.id, name: host.name, endpoint: host.endpoint),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - 旧形状读取(迁移回归)
|
||||
|
||||
@Test("迁移:旧形状记录(无 accessToken 字段)照常读出,令牌为 nil")
|
||||
func legacyRecordWithoutTokenFieldStillLoads() async throws {
|
||||
// Arrange
|
||||
let legacy = Fixtures.makeHost(name: "paired-before-tokens")
|
||||
let shim = FakeSecItemShim()
|
||||
shim.forceCopyData(try encodedLegacyPayload(legacy))
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
|
||||
// Act
|
||||
let loaded = try await store.loadAll()
|
||||
|
||||
// Assert
|
||||
#expect(loaded.count == 1)
|
||||
#expect(loaded.first?.id == legacy.id)
|
||||
#expect(loaded.first?.name == "paired-before-tokens")
|
||||
#expect(loaded.first?.endpoint == legacy.endpoint)
|
||||
#expect(loaded.first?.accessToken == nil)
|
||||
#expect(loaded == [legacy]) // 旧记录 == 令牌为 nil 的新值
|
||||
}
|
||||
|
||||
@Test("迁移:逐字旧 JSON(键名钉死 id/name/endpoint.baseURL)解码成功且令牌为 nil")
|
||||
func literalLegacyJSONDecodesWithNilToken() throws {
|
||||
// Arrange —— 逐字旧形状,钉死上一版的键名(键名一改就红)
|
||||
let json = """
|
||||
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F",\
|
||||
"name":"mac-studio","endpoint":{"baseURL":"http://192.168.1.5:3000"}}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
|
||||
|
||||
// Assert
|
||||
#expect(hosts.count == 1)
|
||||
#expect(hosts.first?.name == "mac-studio")
|
||||
#expect(hosts.first?.endpoint.originHeader == "http://192.168.1.5:3000")
|
||||
#expect(hosts.first?.accessToken == nil)
|
||||
}
|
||||
|
||||
@Test("被篡改的非法令牌值:降级为 nil,不把整张主机表打成 .corruptedData")
|
||||
func tamperedTokenValueDegradesToNilInsteadOfCorruptingTheList() async throws {
|
||||
// Arrange —— 只有外部篡改才可能出现非法值(写路径已在边界校验)
|
||||
let json = """
|
||||
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\
|
||||
"endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":"short!"}]
|
||||
"""
|
||||
let shim = FakeSecItemShim()
|
||||
shim.forceCopyData(Data(json.utf8))
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
|
||||
// Act
|
||||
let loaded = try await store.loadAll()
|
||||
|
||||
// Assert
|
||||
#expect(loaded.count == 1)
|
||||
#expect(loaded.first?.name == "mac-studio")
|
||||
#expect(loaded.first?.accessToken == nil)
|
||||
}
|
||||
|
||||
@Test("accessToken 为 JSON null:等同缺字段,令牌为 nil")
|
||||
func explicitNullTokenDecodesAsNil() throws {
|
||||
// Arrange
|
||||
let json = """
|
||||
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\
|
||||
"endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":null}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
|
||||
|
||||
// Assert
|
||||
#expect(hosts.first?.accessToken == nil)
|
||||
}
|
||||
|
||||
@Test("endpoint 仍是硬校验:非 http(s) 的 baseURL → 解码失败(令牌宽松不放宽 endpoint)")
|
||||
func endpointRemainsStrictlyValidatedOnDecode() {
|
||||
// Arrange
|
||||
let json = """
|
||||
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"bad",\
|
||||
"endpoint":{"baseURL":"ftp://192.168.1.5"}}]
|
||||
"""
|
||||
|
||||
// Act / Assert
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 新形状
|
||||
|
||||
@Test("新形状:令牌以纯字符串存在 accessToken 键下(键名/形状钉死)")
|
||||
func tokenIsPersistedAsAPlainStringUnderAccessTokenKey() throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
|
||||
|
||||
// Act
|
||||
let data = try JSONEncoder().encode(host)
|
||||
let object = try #require(
|
||||
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(object["accessToken"] as? String == Fixtures.validTokenString)
|
||||
}
|
||||
|
||||
@Test("新形状:令牌为 nil 时不写 accessToken 键(不留空字段)")
|
||||
func nilTokenIsOmittedFromTheEncodedForm() throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost()
|
||||
|
||||
// Act
|
||||
let data = try JSONEncoder().encode(host)
|
||||
let object = try #require(
|
||||
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(object.keys.contains("accessToken") == false)
|
||||
#expect(object.keys.sorted() == ["endpoint", "id", "name"])
|
||||
}
|
||||
|
||||
@Test("Codable 往返:带令牌的 Host 全字段保真")
|
||||
func codableRoundTripPreservesTheToken() throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443")
|
||||
.withAccessToken(Fixtures.makeToken())
|
||||
|
||||
// Act
|
||||
let decoded = try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host))
|
||||
|
||||
// Assert
|
||||
#expect(decoded == host)
|
||||
#expect(decoded.accessToken == Fixtures.makeToken())
|
||||
#expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443")
|
||||
}
|
||||
|
||||
// MARK: - 不可变性
|
||||
|
||||
@Test("withAccessToken 不可变:返回新值,原 Host 不变")
|
||||
func withAccessTokenReturnsANewValueWithoutMutatingTheOriginal() {
|
||||
// Arrange
|
||||
let original = Fixtures.makeHost()
|
||||
|
||||
// Act
|
||||
let withToken = original.withAccessToken(Fixtures.makeToken())
|
||||
let cleared = withToken.withAccessToken(nil)
|
||||
|
||||
// Assert
|
||||
#expect(original.accessToken == nil)
|
||||
#expect(withToken.accessToken == Fixtures.makeToken())
|
||||
#expect(cleared.accessToken == nil)
|
||||
#expect(withToken.id == original.id)
|
||||
#expect(withToken.name == original.name)
|
||||
#expect(withToken.endpoint == original.endpoint)
|
||||
#expect(cleared == original)
|
||||
}
|
||||
|
||||
@Test("Equatable 含令牌:令牌不同的同 id 主机不相等(UI 才能感知令牌变化)")
|
||||
func hostsDifferingOnlyByTokenAreNotEqual() {
|
||||
// Arrange
|
||||
let base = Fixtures.makeHost()
|
||||
|
||||
// Act / Assert
|
||||
#expect(base.withAccessToken(Fixtures.makeToken()) != base)
|
||||
#expect(base.withAccessToken(Fixtures.makeToken())
|
||||
!= base.withAccessToken(Fixtures.makeToken(Fixtures.otherValidTokenString)))
|
||||
}
|
||||
@@ -25,4 +25,28 @@ enum Fixtures {
|
||||
static func makeSuiteName() -> String {
|
||||
"HostRegistryTests." + UUID().uuidString
|
||||
}
|
||||
|
||||
// MARK: - Access-token fixtures (coordination doc §1.1)
|
||||
|
||||
/// 22 chars exercising EVERY class of the frozen charset
|
||||
/// `[A-Za-z0-9._~+/=-]` (upper/lower/digit/`.`/`_`/`~`/`+`/`/`/`=`/`-`),
|
||||
/// comfortably above the 16-char minimum. Test-only value — not a secret.
|
||||
static let validTokenString = "A9z._~+/=-Abcdef012345"
|
||||
|
||||
/// A second, distinct valid token (for "replaced/other host" assertions).
|
||||
static let otherValidTokenString = "Zy8-=/+~_.9876543210fedcba"
|
||||
|
||||
/// Builds a valid token; traps loudly if a fixture string is malformed
|
||||
/// (a broken fixture must fail the suite, not silently skip assertions).
|
||||
static func makeToken(_ raw: String = Fixtures.validTokenString) -> AccessToken {
|
||||
guard let token = AccessToken(rawValue: raw) else {
|
||||
fatalError("test fixture token invalid: length \(raw.count)")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/// A repeated-character token of an exact length (length-boundary tests).
|
||||
static func tokenString(length: Int) -> String {
|
||||
String(repeating: "a", count: length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/// The ONE place SessionCore turns a configured access token (`WEBTERM_TOKEN`)
|
||||
/// into an HTTP request header (B3 · ios-completion §1.1, FROZEN).
|
||||
///
|
||||
/// Why hand-written and not `HTTPCookieStorage`: the native client already KNOWS
|
||||
/// the token, and `URLSession`'s cookie jar behaves inconsistently on a
|
||||
/// WebSocket upgrade (and is barely testable there). A hand-written header is
|
||||
/// the same pattern as the hand-written `Origin` (plan §5.1) and can be pinned
|
||||
/// by pure unit tests. `Set-Cookie` is never parsed.
|
||||
///
|
||||
/// SECURITY: the token is secret material. It exists here only long enough to be
|
||||
/// handed to `URLRequest.setValue`; it is never logged, never interpolated into a
|
||||
/// URL/query, and never carried by any error this module throws.
|
||||
enum AuthCookie {
|
||||
/// The server's `AUTH_COOKIE_NAME` (src/http/auth.ts:30).
|
||||
static let name = "webterm_auth"
|
||||
|
||||
/// `webterm_auth=<token>` for a well-formed token; `nil` when no token is
|
||||
/// configured OR the configured value is off-policy.
|
||||
///
|
||||
/// Off-policy ⇒ OMIT rather than throw: a host with auth DISABLED ignores
|
||||
/// the cookie entirely, so a junk stored token must not break a working
|
||||
/// connection. When the host DOES enforce auth, the omitted cookie yields
|
||||
/// the server's 401 → `TermTransportError.unauthorized` → the UI asks for a
|
||||
/// correct token. Omitting a secret is never a leak.
|
||||
static func headerValue(token: String?) -> String? {
|
||||
guard let token, isValidToken(token) else { return nil }
|
||||
return "\(name)=\(token)"
|
||||
}
|
||||
|
||||
/// True iff `candidate` satisfies the server's token policy: a byte-identical
|
||||
/// port of `WEBTERM_TOKEN_RE` (src/config.ts:178) — URL/cookie-safe charset,
|
||||
/// 16–512 chars. A token the server would reject at startup can never be a
|
||||
/// valid credential, and enforcing the charset HERE is also what keeps a
|
||||
/// hostile stored value from injecting CRLF into the request headers.
|
||||
///
|
||||
/// The literal is built per call (as in `WireProtocol.Validation`): `Regex`
|
||||
/// is not `Sendable`, so it cannot be hoisted into a `static let`.
|
||||
static func isValidToken(_ candidate: String) -> Bool {
|
||||
let tokenPolicy = /^[A-Za-z0-9._~+\/=-]{16,512}$/
|
||||
return candidate.wholeMatch(of: tokenPolicy) != nil
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import WireProtocol
|
||||
/// frame goes through `MessageCodec.decodeServer`; nil → dropped + counted,
|
||||
/// never a crash.
|
||||
/// - Terminal states never re-enter backoff: `exit` (session over, including
|
||||
/// spawn failure -1, M4) and `.replayTooLarge` (deterministic failure).
|
||||
/// spawn failure -1, M4), `.replayTooLarge` (deterministic failure), and
|
||||
/// `.unauthorized` (401 on the upgrade — B3 · ios-completion §1.1).
|
||||
///
|
||||
/// Sizing (documented decision, plan §7 T-iOS-10): the server spawns every PTY
|
||||
/// at 80×24 (src/server.ts:714-717) and the frozen `open` signature carries no
|
||||
@@ -324,9 +325,9 @@ public actor SessionEngine {
|
||||
eventsContinuation.finish()
|
||||
return false
|
||||
}
|
||||
if let error, isReplayTooLarge(error) {
|
||||
if let error, let reason = Self.terminalFailure(for: error) {
|
||||
hasFailedTerminally = true
|
||||
emit(.connection(.failed(.replayTooLarge))) // NEVER backoff (plan §1)
|
||||
emit(.connection(.failed(reason))) // NEVER backoff (plan §1 / §1.1)
|
||||
eventsContinuation.finish()
|
||||
return false
|
||||
}
|
||||
@@ -470,8 +471,17 @@ public actor SessionEngine {
|
||||
generation == gen
|
||||
}
|
||||
|
||||
private func isReplayTooLarge(_ error: any Error) -> Bool {
|
||||
(error as? TermTransportError) == .replayTooLarge
|
||||
/// The NON-retryable transport classifications, in ONE place. Retrying
|
||||
/// either is pointless: `replayTooLarge` fails deterministically forever
|
||||
/// (plan §1) and `unauthorized` (401 on the upgrade — Origin or access
|
||||
/// token, ios-completion §1.1) does not become correct by waiting, while a
|
||||
/// retry storm on 401 reads as a brute-force attempt. `nil` = retryable.
|
||||
private static func terminalFailure(for error: any Error) -> FailureReason? {
|
||||
switch error as? TermTransportError {
|
||||
case .replayTooLarge: return .replayTooLarge
|
||||
case .unauthorized: return .unauthorized
|
||||
case .sendAfterClose, .none: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func emit(_ event: SessionEvent) {
|
||||
|
||||
@@ -57,4 +57,12 @@ public enum FailureReason: Sendable, Equatable {
|
||||
/// enters backoff. UI copy: lower the server's SCROLLBACK_BYTES or raise
|
||||
/// the client cap (plan §3.2.1 coupling warning).
|
||||
case replayTooLarge
|
||||
/// The WS upgrade was rejected with **401** (B3 · ios-completion §1.1):
|
||||
/// either the host's Origin whitelist does not contain our dialed origin or
|
||||
/// the access token is missing/wrong (src/server.ts:1367-1379 checks Origin
|
||||
/// first, then the `webterm_auth` cookie — the status is the same, so the UI
|
||||
/// must offer BOTH remedies). Never retried: the credential does not become
|
||||
/// correct by waiting, and a retry storm on 401 is a brute-force signal.
|
||||
/// UI copy: re-enter the access token, or fix `ALLOWED_ORIGINS` on the host.
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ public enum TermTransportError: Error, Equatable, Sendable {
|
||||
/// backoff loop (plan §1 / §3.2.1 coupling warning), otherwise the retry
|
||||
/// is deterministic and infinite.
|
||||
case replayTooLarge
|
||||
/// The WS upgrade was answered **401** (B3 · ios-completion §1.1). Both
|
||||
/// server-side upgrade gates write that status — the Origin/CSWSH check
|
||||
/// first, then the access-token cookie (src/server.ts:1367-1379) — and
|
||||
/// neither becomes true by waiting. NON-RETRYABLE, exactly like
|
||||
/// `replayTooLarge`: the engine surfaces `connection(.failed(.unauthorized))`
|
||||
/// and must never feed it into the backoff loop, because retrying a wrong
|
||||
/// token forever is useless AND indistinguishable from a brute-force probe.
|
||||
/// Carries NO payload — the token must never ride on an error.
|
||||
case unauthorized
|
||||
/// `send` on a connection that already terminated (client `close()`,
|
||||
/// server close frame, or transport error). Mirrors
|
||||
/// `FakeTransportError.sendAfterClose` (TestSupport).
|
||||
@@ -74,6 +83,8 @@ protocol ConnectionPinger: Sendable {
|
||||
/// snapshot frame → every task gets `Tunables.maxWSMessageBytes` (16 MiB).
|
||||
/// - `receive()` delivers ONE message per call → the loop re-arms forever.
|
||||
/// - No automatic ping → `ConnectionPinger` (driven by `PingScheduler`).
|
||||
/// - Access token: a hand-written `Cookie: webterm_auth=<t>` next to (never
|
||||
/// instead of) `Origin` — B3 · ios-completion §1.1, see `AuthCookie`.
|
||||
public struct URLSessionTermTransport: TermTransport {
|
||||
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
||||
private let maxMessageBytes: Int
|
||||
@@ -86,19 +97,32 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
|
||||
/// result is inert there.
|
||||
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||
/// B3 · access token, resolved LAZILY once per `connect` for the same
|
||||
/// no-relaunch reason as `identityProvider`: a token entered (or rotated)
|
||||
/// mid-run takes effect on the NEXT connection. `nil` ⇒ no `Cookie` header
|
||||
/// at all, so LAN zero-config hosts are byte-identical to before.
|
||||
/// The value is passed straight to `AuthCookie` — never logged, never stored.
|
||||
private let tokenProvider: @Sendable () -> String?
|
||||
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
public init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
public init(
|
||||
identity: ClientIdentity? = nil,
|
||||
tokenProvider: @escaping @Sendable () -> String? = { nil }
|
||||
) {
|
||||
self.init(identityProvider: { identity }, tokenProvider: tokenProvider)
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
|
||||
/// provider is re-consulted on every `connect`, so a nil→installed
|
||||
/// transition is picked up without relaunch.
|
||||
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
public init(
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?,
|
||||
tokenProvider: @escaping @Sendable () -> String? = { nil }
|
||||
) {
|
||||
self.init(
|
||||
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
|
||||
maxMessageBytes: Tunables.maxWSMessageBytes,
|
||||
identityProvider: identityProvider, tokenProvider: tokenProvider
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,15 +131,20 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
/// through `init()` / `init(identityProvider:)` and the frozen
|
||||
/// `Tunables.maxWSMessageBytes`.
|
||||
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
|
||||
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
|
||||
self.init(
|
||||
maxMessageBytes: maxMessageBytes, identityProvider: { identity },
|
||||
tokenProvider: { nil }
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
maxMessageBytes: Int,
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?,
|
||||
tokenProvider: @escaping @Sendable () -> String?
|
||||
) {
|
||||
self.maxMessageBytes = maxMessageBytes
|
||||
self.identityProvider = identityProvider
|
||||
self.tokenProvider = tokenProvider
|
||||
}
|
||||
|
||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||
@@ -123,12 +152,12 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
}
|
||||
|
||||
/// Internal: concrete-typed connect (tests assert task configuration;
|
||||
/// `connectPingable` builds on it). The identity is resolved HERE, per
|
||||
/// connect, from `identityProvider` (no-relaunch pickup).
|
||||
/// `connectPingable` builds on it). Identity AND token are resolved HERE,
|
||||
/// per connect, from their providers (no-relaunch pickup).
|
||||
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
||||
try await WSConnection.open(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
|
||||
identity: identityProvider()
|
||||
identity: identityProvider(), token: tokenProvider()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -185,11 +214,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
||||
/// then start the re-arming receive loop.
|
||||
static func open(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil,
|
||||
token: String? = nil
|
||||
) async throws -> WSConnection {
|
||||
let connection = WSConnection()
|
||||
connection.configure(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity,
|
||||
token: token
|
||||
)
|
||||
try await connection.performHandshake()
|
||||
connection.startReceiveLoop()
|
||||
@@ -209,21 +240,52 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
// MARK: Setup
|
||||
|
||||
private func configure(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?, token: String?
|
||||
) {
|
||||
// Set BEFORE building the task/resume: the mTLS challenge can only fire
|
||||
// after `task.resume()`, so this write happens-before any read of it.
|
||||
self.identity = identity
|
||||
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
||||
var request = URLRequest(url: endpoint.wsURL)
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||
let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
|
||||
let task = session.webSocketTask(with: request)
|
||||
let session = URLSession(
|
||||
configuration: Self.sessionConfiguration(), delegate: self, delegateQueue: nil
|
||||
)
|
||||
let task = session.webSocketTask(
|
||||
with: Self.upgradeRequest(endpoint: endpoint, token: token)
|
||||
)
|
||||
task.maximumMessageSize = maxMessageBytes
|
||||
self.session = session
|
||||
self.task = task
|
||||
}
|
||||
|
||||
/// THE single choke point for upgrade request headers.
|
||||
///
|
||||
/// - `Origin`: SINGLE source of truth = `endpoint.originHeader` (plan §5.1)
|
||||
/// — always sent.
|
||||
/// - `Cookie`: `webterm_auth=<token>` when a token is configured
|
||||
/// (B3 · ios-completion §1.1). ADDITIVE and ORTHOGONAL: the server checks
|
||||
/// Origin first and the cookie second (src/server.ts:1367-1379), so the
|
||||
/// cookie never replaces Origin. Absent/off-policy token ⇒ header omitted
|
||||
/// (see `AuthCookie.headerValue`).
|
||||
private static func upgradeRequest(endpoint: HostEndpoint, token: String?) -> URLRequest {
|
||||
var request = URLRequest(url: endpoint.wsURL)
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||
if let cookie = AuthCookie.headerValue(token: token) {
|
||||
request.setValue(cookie, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/// Ephemeral, with the cookie jar switched OFF: §1.1 freezes the
|
||||
/// hand-written `Cookie` header as the ONLY authority, so no `Set-Cookie`
|
||||
/// from any host can override it, outlive the connection, or park the secret
|
||||
/// in shared storage.
|
||||
private static func sessionConfiguration() -> URLSessionConfiguration {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieAcceptPolicy = .never
|
||||
configuration.httpCookieStorage = nil
|
||||
return configuration
|
||||
}
|
||||
|
||||
private func performHandshake() async throws {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, any Error>) in
|
||||
@@ -331,6 +393,34 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status both server-side upgrade gates answer with: the Origin/CSWSH
|
||||
/// check, then the access-token cookie (src/server.ts:1367-1379).
|
||||
private static let unauthorizedStatusCode = 401
|
||||
|
||||
/// The upgrade never opened — decide what `connect` throws.
|
||||
///
|
||||
/// A **401** is non-retryable by construction (see
|
||||
/// `TermTransportError.unauthorized`), so it becomes the typed error the
|
||||
/// engine treats as terminal. EVERYTHING else is rethrown VERBATIM, because
|
||||
/// `PairingError.classify` (T-iOS-8) keys off the underlying POSIX/NSURLError
|
||||
/// identity to tell localNetworkDenied / atsBlocked / tlsFailure apart —
|
||||
/// wrapping those would break that taxonomy (see `TermTransportError` doc).
|
||||
private static func handshakeFailure(task: URLSessionTask, error: (any Error)?) -> any Error {
|
||||
if httpStatusCode(of: task) == unauthorizedStatusCode {
|
||||
return TermTransportError.unauthorized
|
||||
}
|
||||
return error ?? URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
/// The HTTP status of a rejected upgrade: `URLSessionWebSocketTask` fails the
|
||||
/// task on a non-101 answer and leaves the `HTTPURLResponse` on `task`
|
||||
/// (mutation-verified against a scripted 401 vs 500 in
|
||||
/// `URLSessionTermTransportTests`; without it the 401 arrives as the opaque
|
||||
/// NSURLErrorDomain -1011 that WOULD enter the backoff loop).
|
||||
private static func httpStatusCode(of task: URLSessionTask) -> Int? {
|
||||
(task.response as? HTTPURLResponse)?.statusCode
|
||||
}
|
||||
|
||||
/// T-iOS-2 spike (measured on a real server): exceeding
|
||||
/// `maximumMessageSize` fails `receive()` with NSPOSIXErrorDomain
|
||||
/// EMSGSIZE(40) "Message too long" — not a 1009 close. ENOBUFS(55) is the
|
||||
@@ -399,10 +489,7 @@ extension WSConnection: URLSessionWebSocketDelegate {
|
||||
didCompleteWithError error: (any Error)?
|
||||
) {
|
||||
if let handshake = takeHandshakeContinuation() {
|
||||
// Handshake never opened. Rethrow the UNDERLYING error verbatim —
|
||||
// PairingError.classify (T-iOS-8) depends on its POSIX/NSURLError
|
||||
// identity (see TermTransportError doc).
|
||||
handshake.resume(throwing: error ?? URLError(.cannotConnectToHost))
|
||||
handshake.resume(throwing: Self.handshakeFailure(task: task, error: error))
|
||||
session.finishTasksAndInvalidate()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Testing
|
||||
@testable import SessionCore
|
||||
|
||||
/// B3 · `AuthCookie` — the ONE place SessionCore turns a configured access
|
||||
/// token into an upgrade request header (`ios-completion` §1.1, FROZEN:
|
||||
/// hand-written `Cookie`, never `Set-Cookie`/`HTTPCookieStorage`).
|
||||
///
|
||||
/// The policy mirrored here is the server's, verbatim:
|
||||
/// - cookie name `webterm_auth` (src/http/auth.ts:30 `AUTH_COOKIE_NAME`)
|
||||
/// - token charset/length `^[A-Za-z0-9._~+/=-]{16,512}$` (src/config.ts:178)
|
||||
@Suite("AuthCookie")
|
||||
struct AuthCookieTests {
|
||||
/// A well-formed token exercising every allowed character class.
|
||||
private static let validToken = "Ab9._~+/=-Ab9._~+/=-"
|
||||
private static let minLengthToken = String(repeating: "a", count: 16)
|
||||
private static let maxLengthToken = String(repeating: "a", count: 512)
|
||||
|
||||
@Test("cookie name matches the server's AUTH_COOKIE_NAME")
|
||||
func cookieNameMatchesServer() {
|
||||
#expect(AuthCookie.name == "webterm_auth")
|
||||
}
|
||||
|
||||
@Test("a valid token becomes exactly `webterm_auth=<token>`")
|
||||
func validTokenBecomesHeaderValue() {
|
||||
// Arrange / Act
|
||||
let header = AuthCookie.headerValue(token: Self.validToken)
|
||||
|
||||
// Assert
|
||||
#expect(header == "webterm_auth=\(Self.validToken)")
|
||||
}
|
||||
|
||||
@Test("no token configured (nil / empty) ⇒ no Cookie header at all")
|
||||
func absentTokenYieldsNoHeader() {
|
||||
#expect(AuthCookie.headerValue(token: nil) == nil)
|
||||
#expect(AuthCookie.headerValue(token: "") == nil)
|
||||
}
|
||||
|
||||
@Test("length policy: 16 and 512 accepted, 15 and 513 rejected (server charset rule)")
|
||||
func lengthPolicyMatchesServer() {
|
||||
#expect(AuthCookie.headerValue(token: Self.minLengthToken) != nil)
|
||||
#expect(AuthCookie.headerValue(token: Self.maxLengthToken) != nil)
|
||||
#expect(AuthCookie.headerValue(token: String(repeating: "a", count: 15)) == nil)
|
||||
#expect(AuthCookie.headerValue(token: String(repeating: "a", count: 513)) == nil)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"off-charset tokens are refused, never smuggled into the header (CRLF injection included)",
|
||||
arguments: [
|
||||
"aaaaaaaaaaaaaaaa\r\nX-Evil: 1", // header injection attempt
|
||||
"aaaaaaaaaaaaaaaa;path=/", // cookie-attribute injection attempt
|
||||
"aaaaaaaaaaaaaaaa aaaa", // space
|
||||
"aaaaaaaaaaaaaaaa\"quoted\"",
|
||||
"aaaaaaaaaaaaaaaa\u{00E9}", // non-ASCII
|
||||
"aaaaaaaaaaaaaaaa\u{0000}",
|
||||
]
|
||||
)
|
||||
func offCharsetTokensRefused(token: String) {
|
||||
#expect(AuthCookie.headerValue(token: token) == nil)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,22 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
let headers: [String: String]
|
||||
}
|
||||
|
||||
/// A scripted NON-101 answer to the upgrade, byte-shaped like the real
|
||||
/// server's reject path: it writes a bare status line and destroys the
|
||||
/// socket — no headers, no body (src/server.ts:1367-1379, both the Origin
|
||||
/// gate and the access-token cookie gate).
|
||||
struct UpgradeRejection: Sendable {
|
||||
let statusCode: Int
|
||||
let reasonPhrase: String
|
||||
|
||||
/// What BOTH server-side upgrade gates write (Origin, then cookie).
|
||||
static let unauthorized = UpgradeRejection(statusCode: 401, reasonPhrase: "Unauthorized")
|
||||
/// A non-401 rejection: must stay in the RETRYABLE class.
|
||||
static let serverError = UpgradeRejection(
|
||||
statusCode: 500, reasonPhrase: "Internal Server Error"
|
||||
)
|
||||
}
|
||||
|
||||
/// RFC 6455 wire constants (named — no magic numbers).
|
||||
private enum Wire {
|
||||
static let finBit: UInt8 = 0x80
|
||||
@@ -63,6 +79,7 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
private var connection: NWConnection?
|
||||
private var rxBuffer = [UInt8]()
|
||||
private var isHandshakeDone = false
|
||||
private var upgradeRejection: UpgradeRejection?
|
||||
private var startContinuation: CheckedContinuation<UInt16, any Error>?
|
||||
private var stopContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
@@ -131,6 +148,13 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
lock.withLock { connection }?.forceCancel()
|
||||
}
|
||||
|
||||
/// Answer every subsequent upgrade with `rejection` instead of the 101.
|
||||
/// Call before `connect` (the upgrade request is still recorded, so tests
|
||||
/// can assert the headers a REJECTED handshake carried).
|
||||
func rejectUpgrades(with rejection: UpgradeRejection) {
|
||||
lock.withLock { upgradeRejection = rejection }
|
||||
}
|
||||
|
||||
// MARK: - Connection handling
|
||||
|
||||
private func adopt(connection newConnection: NWConnection) {
|
||||
@@ -184,22 +208,31 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
// MARK: - Handshake
|
||||
|
||||
private func tryCompleteHandshake(on connection: NWConnection) {
|
||||
let requestBytes: [UInt8]? = lock.withLock {
|
||||
let pending: (requestBytes: [UInt8], rejection: UpgradeRejection?)? = lock.withLock {
|
||||
guard !isHandshakeDone, let end = Self.headerEndIndex(in: rxBuffer) else {
|
||||
return nil
|
||||
}
|
||||
let request = Array(rxBuffer[..<end])
|
||||
rxBuffer.removeFirst(end + Wire.headerTerminator.count)
|
||||
isHandshakeDone = true
|
||||
return request
|
||||
return (request, upgradeRejection)
|
||||
}
|
||||
guard let requestBytes else { return }
|
||||
let upgrade = Self.parseUpgrade(String(decoding: requestBytes, as: UTF8.self))
|
||||
guard let pending else { return }
|
||||
let upgrade = Self.parseUpgrade(String(decoding: pending.requestBytes, as: UTF8.self))
|
||||
// Record BEFORE answering, so a REJECTED upgrade is still observable.
|
||||
upgradesContinuation.yield(upgrade)
|
||||
guard let rejection = pending.rejection else {
|
||||
connection.send(
|
||||
content: Self.handshakeResponse(for: upgrade),
|
||||
completion: .contentProcessed { _ in }
|
||||
)
|
||||
upgradesContinuation.yield(upgrade)
|
||||
return
|
||||
}
|
||||
// Reject exactly like the server: status line, then destroy the socket.
|
||||
connection.send(
|
||||
content: Self.rejectionResponse(rejection),
|
||||
completion: .contentProcessed { _ in connection.cancel() }
|
||||
)
|
||||
}
|
||||
|
||||
private static func headerEndIndex(in bytes: [UInt8]) -> Int? {
|
||||
@@ -235,6 +268,10 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
return Data(response.utf8)
|
||||
}
|
||||
|
||||
private static func rejectionResponse(_ rejection: UpgradeRejection) -> Data {
|
||||
Data("HTTP/1.1 \(rejection.statusCode) \(rejection.reasonPhrase)\r\n\r\n".utf8)
|
||||
}
|
||||
|
||||
// MARK: - Client frame parsing
|
||||
|
||||
private func drainClientFrames(on connection: NWConnection) {
|
||||
|
||||
@@ -476,6 +476,70 @@ struct SessionEngineTests {
|
||||
#expect(await harness.transport.connectAttempts.count == 1)
|
||||
}
|
||||
|
||||
@Test("401 on the upgrade → connection(.failed(.unauthorized)) and NOT ONE reconnect attempt")
|
||||
func unauthorizedIsTerminalAndNeverBacksOff() async throws {
|
||||
// Arrange: the transport rejects the handshake with the typed 401 error
|
||||
// (B3 · ios-completion §1.1 "WS upgrade 收 401 ⇒ 终态,不进退避环").
|
||||
let harness = try Harness()
|
||||
await harness.transport.scriptConnectFailure(TermTransportError.unauthorized)
|
||||
|
||||
// Act
|
||||
await harness.engine.open(sessionId: nil, cwd: nil)
|
||||
|
||||
// Assert: terminal failure instead of a backoff rung — retrying a wrong
|
||||
// token forever is useless AND a brute-force signal.
|
||||
#expect(await harness.next() == .connection(.connecting))
|
||||
#expect(await harness.next() == .connection(.failed(.unauthorized)))
|
||||
#expect(await harness.next() == nil)
|
||||
#expect(harness.clock.pendingSleeperCount == 0)
|
||||
harness.clock.advance(by: .seconds(60))
|
||||
#expect(await harness.transport.connectAttempts.count == 1)
|
||||
|
||||
// Assert: the engine is inert afterwards (same shape as .replayTooLarge).
|
||||
await harness.engine.send(.input(data: "too late"))
|
||||
#expect(await harness.engine.droppedOutboundFrameCount == 1)
|
||||
}
|
||||
|
||||
@Test("ordinary failure still backs off; a 401 on the retry stops the ladder for good")
|
||||
func ordinaryFailureBacksOffThen401EndsIt() async throws {
|
||||
// Arrange: attempt #1 fails the ordinary (retryable) way, attempt #2 401s.
|
||||
let harness = try Harness()
|
||||
await harness.transport.scriptConnectFailure()
|
||||
await harness.transport.scriptConnectFailure(TermTransportError.unauthorized)
|
||||
|
||||
// Act / Assert: rung 1 is unchanged by the new classification.
|
||||
await harness.engine.open(sessionId: nil, cwd: nil)
|
||||
#expect(await harness.next() == .connection(.connecting))
|
||||
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
harness.clock.advance(by: .seconds(1))
|
||||
|
||||
// Assert: the 401 is terminal — no rung 2, no third attempt, ever.
|
||||
#expect(await harness.next() == .connection(.failed(.unauthorized)))
|
||||
#expect(await harness.next() == nil)
|
||||
#expect(harness.clock.pendingSleeperCount == 0)
|
||||
harness.clock.advance(by: .seconds(60))
|
||||
#expect(await harness.transport.connectAttempts.count == 2)
|
||||
}
|
||||
|
||||
@Test("unauthorized surfacing mid-stream is classified terminally too (one classifier)")
|
||||
func unauthorizedOnStreamIsAlsoTerminal() async throws {
|
||||
// Arrange
|
||||
let sessionId = UUID()
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt(adopting: sessionId)
|
||||
|
||||
// Act
|
||||
await harness.transport.emitError(TermTransportError.unauthorized)
|
||||
|
||||
// Assert
|
||||
#expect(await harness.next() == .connection(.failed(.unauthorized)))
|
||||
#expect(await harness.next() == nil)
|
||||
#expect(harness.clock.pendingSleeperCount == 0)
|
||||
harness.clock.advance(by: .seconds(60))
|
||||
#expect(await harness.transport.connectAttempts.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Close
|
||||
|
||||
@Test("close() closes the transport, finishes the events stream, and leaks no tasks")
|
||||
|
||||
@@ -30,6 +30,12 @@ struct URLSessionTermTransportTests {
|
||||
static let normalCloseCode: UInt16 = 1000
|
||||
/// Arbitrary non-text payload for the binary-frame drop test.
|
||||
static let binaryPayload: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF]
|
||||
/// A well-formed access token (server charset, src/config.ts:178).
|
||||
static let accessToken = "Ab9._~+/=-Ab9._~+/=-"
|
||||
/// A second well-formed token — proves per-connect token resolution.
|
||||
static let rotatedAccessToken = "Zz0-=/+~._Zz0-=/+~._"
|
||||
/// Off-charset token: must NEVER reach the wire (header injection).
|
||||
static let malformedAccessToken = "aaaaaaaaaaaaaaaa\r\nX-Evil: 1"
|
||||
}
|
||||
|
||||
private struct TimedOut: Error {}
|
||||
@@ -74,6 +80,22 @@ struct URLSessionTermTransportTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `count` upgrade requests, drained through ONE iterator
|
||||
/// (`AsyncStream` is single-consumer — buffered yields are never lost).
|
||||
private static func firstUpgrades(
|
||||
count: Int, from server: ScriptedWSServer
|
||||
) async throws -> [ScriptedWSServer.Upgrade] {
|
||||
try await withTimeout {
|
||||
var collected: [ScriptedWSServer.Upgrade] = []
|
||||
var iterator = server.upgrades.makeAsyncIterator()
|
||||
while collected.count < count {
|
||||
guard let upgrade = await iterator.next() else { throw TimedOut() }
|
||||
collected.append(upgrade)
|
||||
}
|
||||
return collected
|
||||
}
|
||||
}
|
||||
|
||||
private static func collect(
|
||||
_ count: Int, from frames: AsyncThrowingStream<String, any Error>
|
||||
) async throws -> [String] {
|
||||
@@ -278,6 +300,135 @@ struct URLSessionTermTransportTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Access token (B3 · ios-completion §1.1)
|
||||
|
||||
@Test("configured token ⇒ upgrade carries Cookie: webterm_auth=<t> AND Origin (additive, §1.1)")
|
||||
func upgradeCarriesAuthCookieAlongsideOrigin() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
|
||||
|
||||
let connection = try await transport.connect(to: endpoint)
|
||||
let upgrade = try await Self.firstUpgrade(server)
|
||||
|
||||
// The cookie NEVER replaces Origin — the server checks Origin first,
|
||||
// then the cookie (src/server.ts:1367-1379).
|
||||
#expect(upgrade.headers["origin"] == endpoint.originHeader)
|
||||
#expect(upgrade.headers["cookie"] == "\(AuthCookie.name)=\(TestTunables.accessToken)")
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("no token configured ⇒ NO Cookie header at all (LAN zero-config unchanged)")
|
||||
func upgradeOmitsCookieWhenNoTokenConfigured() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
|
||||
let connection = try await URLSessionTermTransport().connect(to: endpoint)
|
||||
let upgrade = try await Self.firstUpgrade(server)
|
||||
|
||||
#expect(upgrade.headers["cookie"] == nil)
|
||||
#expect(upgrade.headers["origin"] == endpoint.originHeader)
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("off-charset token is dropped, never smuggled onto the wire; Origin still sent")
|
||||
func malformedTokenIsNeverSentButOriginIs() async throws {
|
||||
// A malformed stored token must not break a host that has auth DISABLED
|
||||
// (it ignores the cookie), and must not become a header-injection vector.
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let transport = URLSessionTermTransport(
|
||||
tokenProvider: { TestTunables.malformedAccessToken }
|
||||
)
|
||||
|
||||
let connection = try await transport.connect(to: endpoint)
|
||||
let upgrade = try await Self.firstUpgrade(server)
|
||||
|
||||
#expect(upgrade.headers["cookie"] == nil)
|
||||
#expect(upgrade.headers["x-evil"] == nil)
|
||||
#expect(upgrade.headers["origin"] == endpoint.originHeader)
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("token provider is resolved PER CONNECT — a token added/rotated mid-run needs no relaunch")
|
||||
func tokenProviderResolvedPerConnect() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let tokens = TokenSequence([TestTunables.accessToken, TestTunables.rotatedAccessToken])
|
||||
let transport = URLSessionTermTransport(tokenProvider: { tokens.next() })
|
||||
|
||||
let first = try await transport.connect(to: endpoint)
|
||||
await first.close()
|
||||
let second = try await transport.connect(to: endpoint)
|
||||
await second.close()
|
||||
let upgrades = try await Self.firstUpgrades(count: 2, from: server)
|
||||
|
||||
#expect(upgrades.map { $0.headers["cookie"] } == [
|
||||
"\(AuthCookie.name)=\(TestTunables.accessToken)",
|
||||
"\(AuthCookie.name)=\(TestTunables.rotatedAccessToken)",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("401 on the WS upgrade ⇒ typed TermTransportError.unauthorized (terminal, never retried)")
|
||||
func unauthorizedUpgradeThrowsTypedUnauthorized() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
server.rejectUpgrades(with: .unauthorized)
|
||||
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
|
||||
|
||||
var thrown: (any Error)?
|
||||
do {
|
||||
_ = try await Self.withTimeout { try await transport.connect(to: endpoint) }
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
#expect(
|
||||
(thrown as? TermTransportError) == .unauthorized,
|
||||
"expected .unauthorized, got \(String(describing: thrown))"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("a NON-401 upgrade rejection stays in the retryable class (not .unauthorized)")
|
||||
func nonUnauthorizedUpgradeRejectionStaysRetryable() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
server.rejectUpgrades(with: .serverError)
|
||||
|
||||
var thrown: (any Error)?
|
||||
do {
|
||||
_ = try await Self.withTimeout {
|
||||
try await URLSessionTermTransport().connect(to: endpoint)
|
||||
}
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
let failure = try #require(thrown, "a 500 upgrade must still fail the connect")
|
||||
#expect(!(failure is TimedOut), "connect neither opened nor failed in time")
|
||||
#expect((failure as? TermTransportError) != .unauthorized)
|
||||
}
|
||||
|
||||
@Test("the token never appears in a thrown error (nothing loggable carries the secret)")
|
||||
func thrownErrorNeverCarriesTheToken() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
server.rejectUpgrades(with: .unauthorized)
|
||||
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
|
||||
|
||||
var thrown: (any Error)?
|
||||
do {
|
||||
_ = try await Self.withTimeout { try await transport.connect(to: endpoint) }
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
let failure = try #require(thrown)
|
||||
#expect(!String(describing: failure).contains(TestTunables.accessToken))
|
||||
#expect(!String(reflecting: failure).contains(TestTunables.accessToken))
|
||||
#expect(!failure.localizedDescription.contains(TestTunables.accessToken))
|
||||
}
|
||||
|
||||
/// Thread-safe invocation counter for the `@Sendable` identity provider
|
||||
/// (URLSession may consult it off the test's isolation domain).
|
||||
private final class CallCounter: @unchecked Sendable {
|
||||
@@ -286,5 +437,24 @@ struct URLSessionTermTransportTests {
|
||||
func increment() { lock.withLock { count += 1 } }
|
||||
var value: Int { lock.withLock { count } }
|
||||
}
|
||||
|
||||
/// Thread-safe token script: hands out one token per `connect` (the last
|
||||
/// value repeats), proving the provider is re-consulted every time.
|
||||
private final class TokenSequence: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var remaining: [String]
|
||||
|
||||
init(_ tokens: [String]) {
|
||||
remaining = tokens
|
||||
}
|
||||
|
||||
func next() -> String? {
|
||||
lock.withLock {
|
||||
guard let first = remaining.first else { return nil }
|
||||
if remaining.count > 1 { remaining.removeFirst() }
|
||||
return first
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user