Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/CredentialStoreTests.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

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)
}
}