mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
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.
64 lines
2.5 KiB
Swift
64 lines
2.5 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import Testing
|
|
|
|
// The Keychain-backed store cannot run on CI (no entitlements/keychain), so
|
|
// coverage targets the protocol semantics through the in-memory double the
|
|
// rest of the suite relies on.
|
|
@Suite("InMemoryCredentialStore")
|
|
struct CredentialStoreTests {
|
|
@Test func roundTripsPerHubRecords() throws {
|
|
let store = InMemoryCredentialStore()
|
|
let missing = try store.credentials(forHub: "https://one.test")
|
|
#expect(missing == nil)
|
|
|
|
let one = HubCredentials(
|
|
hubUrl: "https://one.test",
|
|
accessToken: "token-one",
|
|
jwt: "jwt-one",
|
|
jwtObtainedAt: 1_700_000_000_000
|
|
)
|
|
let two = HubCredentials(hubUrl: "https://two.test", accessToken: "token-two")
|
|
try store.store(one)
|
|
try store.store(two)
|
|
|
|
let storedOne = try store.credentials(forHub: "https://one.test")
|
|
let storedTwo = try store.credentials(forHub: "https://two.test")
|
|
#expect(storedOne == one)
|
|
#expect(storedTwo == two)
|
|
}
|
|
|
|
@Test func storeReplacesExistingRecord() throws {
|
|
let store = InMemoryCredentialStore()
|
|
try store.store(HubCredentials(hubUrl: "https://one.test", accessToken: "old"))
|
|
let updated = HubCredentials(hubUrl: "https://one.test", accessToken: "new", jwt: "j")
|
|
try store.store(updated)
|
|
let stored = try store.credentials(forHub: "https://one.test")
|
|
#expect(stored == updated)
|
|
}
|
|
|
|
@Test func deleteIsIdempotent() throws {
|
|
let store = InMemoryCredentialStore()
|
|
try store.store(HubCredentials(hubUrl: "https://one.test", accessToken: "t"))
|
|
try store.deleteCredentials(forHub: "https://one.test")
|
|
let afterDelete = try store.credentials(forHub: "https://one.test")
|
|
#expect(afterDelete == nil)
|
|
// Deleting again must not throw.
|
|
try store.deleteCredentials(forHub: "https://one.test")
|
|
}
|
|
|
|
@Test func hubCredentialsSurviveJSONRoundTrip() throws {
|
|
// The Keychain store persists the record as JSON; make sure the
|
|
// shape round-trips losslessly.
|
|
let record = HubCredentials(
|
|
hubUrl: "https://hub.test",
|
|
accessToken: "base:team",
|
|
jwt: makeJWT(),
|
|
jwtObtainedAt: 1_700_000_123_456
|
|
)
|
|
let data = try JSONEncoder().encode(record)
|
|
let decoded = try JSONDecoder().decode(HubCredentials.self, from: data)
|
|
#expect(decoded == record)
|
|
}
|
|
}
|