Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/APIClientTests.swift
T
weishu 1f0ec6184d feat(ios): HapiClient API transport + auth (A-M1b)
APIClient (final class, Sendable) with typed endpoints for the M2/M3 REST
surface behind an HTTPPerforming seam; actor AuthManager with single-flight
JWT refresh (POST /api/auth), proactive refresh 10 min before exp, terminal
authFailed state, and 401 -> refresh -> retry-once wiring per
docs/api/client-contract/auth.md; Keychain credential store (per-hub records
under run.hapi.companion) with CredentialStoring seam + in-memory double;
HubRegistry (ordered hubs + active hub in injectable UserDefaults, origin
normalization); payload-only JWT decoding; APIError {status, code, body}
parsing per errors.md; minimal multipart builder for M4c dictation; 256 MB
URLCache session for generated images.

Request/response wire models (SendMessageRequest, PermissionApproveRequest,
SpawnRequest/SpawnResponse, MessagesQuery, envelopes, RPC-wrapped shapes)
join HapiProtocol/Models mirroring shared/src/apiTypes.ts.

swift-testing coverage: JWT decode (padding/garbage/hostile exp), auth
single-flight (8 concurrent callers -> one exchange), 401 retry-once and
terminal paths, APIError body parsing, endpoint request construction
byte-checks (cursor queries, deliveryMode body, answers formats, explicit
null model reset) via a recording performer, hub URL normalization,
multipart bytes.
2026-08-17 15:20:03 +08:00

137 lines
6.5 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Testing
@Suite("APIClient auth & errors")
struct APIClientTests {
@Test func retriesOnceAfter401WithRefreshedToken() async throws {
let initial = freshJWT()
let harness = try makeHarness(jwt: initial)
let rotated = makeJWT(exp: testEpochSeconds + 5 * 3600)
await harness.performer.enqueue(status: 401, json: "{\"error\":\"Invalid token\"}")
await harness.performer.enqueue(json: authResponseJSON(token: rotated))
await harness.performer.enqueue(json: "{\"sessions\":[]}")
let sessions = try await harness.client.listSessions()
#expect(sessions.isEmpty)
let requests = await harness.performer.requests
try #require(requests.count == 3)
#expect(requests[0].url?.absoluteString == "\(testHubURLString)/api/sessions")
#expect(requests[0].value(forHTTPHeaderField: "Authorization") == "Bearer \(initial)")
#expect(requests[1].url?.absoluteString == "\(testHubURLString)/api/auth")
#expect(requests[1].value(forHTTPHeaderField: "Authorization") == nil)
#expect(requests[2].url?.absoluteString == "\(testHubURLString)/api/sessions")
#expect(requests[2].value(forHTTPHeaderField: "Authorization") == "Bearer \(rotated)")
}
@Test func secondConsecutive401IsTerminal() async throws {
let harness = try makeHarness(jwt: freshJWT())
let rotated = makeJWT(exp: testEpochSeconds + 5 * 3600)
await harness.performer.enqueue(status: 401, json: "{\"error\":\"Invalid token\"}")
await harness.performer.enqueue(json: authResponseJSON(token: rotated))
await harness.performer.enqueue(status: 401, json: "{\"error\":\"Invalid token\"}")
let error = await capturedError { try await harness.client.listSessions() }
let apiError = error as? APIError
#expect(apiError?.status == 401)
let failed = await harness.auth.isAuthenticationFailed
#expect(failed)
let countAfterFailure = await harness.performer.requests.count
#expect(countAfterFailure == 3)
// Follow-up calls surface "re-pair needed" without touching the hub.
let followUp = await capturedError { try await harness.client.listSessions() }
#expect(followUp as? AuthError == .reauthenticationRequired)
let countAfterFollowUp = await harness.performer.requests.count
#expect(countAfterFollowUp == 3)
}
@Test func rejectedAccessTokenSurfacesReauthenticationRequired() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(status: 401, json: "{\"error\":\"Invalid token\"}")
await harness.performer.enqueue(status: 401, json: "{\"error\":\"Invalid access token\"}")
let error = await capturedError { try await harness.client.listSessions() }
#expect(error as? AuthError == .reauthenticationRequired)
let requestCount = await harness.performer.requests.count
#expect(requestCount == 2)
}
@Test func parsesErrorBodyWithCode() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(
status: 409,
json: "{\"error\":\"Session is inactive\",\"code\":\"session_inactive\"}"
)
let error = await capturedError { try await harness.client.abortSession(id: "s1") }
let apiError = error as? APIError
#expect(apiError?.status == 409)
#expect(apiError?.code == "session_inactive")
#expect(apiError?.body == "{\"error\":\"Session is inactive\",\"code\":\"session_inactive\"}")
}
@Test func fallsBackToErrorStringAsPseudoCode() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(status: 503, json: "{\"error\":\"Not connected\"}")
let error = await capturedError { try await harness.client.listSessions() }
#expect((error as? APIError)?.code == "Not connected")
}
@Test func nonJSONBodyYieldsNilCode() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(status: 500, json: "boom")
let error = await capturedError { try await harness.client.listSessions() }
let apiError = error as? APIError
#expect(apiError?.status == 500)
#expect(apiError?.code == nil)
#expect(apiError?.body == "boom")
}
@Test func healthIsUnauthenticated() async throws {
// Deliberately unpaired: /health must not consult AuthManager.
let harness = try makeHarness(paired: false)
await harness.performer.enqueue(json: "{\"status\":\"ok\",\"protocolVersion\":1}")
let health = try await harness.client.health()
#expect(health.status == "ok")
#expect(health.protocolVersion == 1)
let requests = await harness.performer.requests
try #require(requests.count == 1)
#expect(requests[0].url?.absoluteString == "\(testHubURLString)/health")
#expect(requests[0].value(forHTTPHeaderField: "Authorization") == nil)
}
@Test func authenticateExchangesAccessToken() async throws {
let harness = try makeHarness(paired: false)
let issued = freshJWT()
await harness.performer.enqueue(json: authResponseJSON(token: issued))
let response = try await harness.client.authenticate(accessToken: "base:team")
#expect(response.token == issued)
#expect(response.user.id == 1)
let requests = await harness.performer.requests
try #require(requests.count == 1)
#expect(requests[0].url?.absoluteString == "\(testHubURLString)/api/auth")
let body = requests[0].httpBody.flatMap { String(data: $0, encoding: .utf8) }
#expect(body == "{\"accessToken\":\"base:team\"}")
#expect(requests[0].value(forHTTPHeaderField: "Authorization") == nil)
}
@Test func requestBytesReturnsRawPayload() async throws {
let harness = try makeHarness(jwt: freshJWT())
await harness.performer.enqueue(
json: "not-json-bytes",
headers: ["Content-Type": "image/png"]
)
let image = try await harness.client.generatedImage(sessionId: "s1", imageId: "img1")
#expect(image.data == Data("not-json-bytes".utf8))
#expect(image.mimeType == "image/png")
let requests = await harness.performer.requests
try #require(requests.count == 1)
#expect(
requests[0].url?.absoluteString
== "\(testHubURLString)/api/sessions/s1/generated-images/img1"
)
}
}