mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-21 21:29:47 +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.
63 lines
2.3 KiB
Swift
63 lines
2.3 KiB
Swift
import Foundation
|
|
import HapiClient
|
|
import Testing
|
|
|
|
@Suite("JWT payload decoding")
|
|
struct JWTTests {
|
|
@Test func decodesHubShapedPayload() {
|
|
let token = makeJWT(uid: 7, ns: "team", exp: 1_800_000_000)
|
|
let claims = JWT.claims(from: token)
|
|
#expect(claims == JWTClaims(uid: 7, ns: "team", exp: 1_800_000_000))
|
|
#expect(claims?.expiresAt == Date(timeIntervalSince1970: 1_800_000_000))
|
|
}
|
|
|
|
@Test func missingExpIsAllowed() {
|
|
let claims = JWT.claims(from: makeJWT(uid: 1, ns: "default"))
|
|
#expect(claims == JWTClaims(uid: 1, ns: "default", exp: nil))
|
|
#expect(claims?.expiresAt == nil)
|
|
}
|
|
|
|
/// Payload lengths spanning every base64 padding remainder must all
|
|
/// decode (the middle segment arrives with its `=` padding stripped).
|
|
@Test(arguments: ["a", "ab", "abc", "abcd", "abcde"])
|
|
func decodesAcrossPaddingLengths(ns: String) {
|
|
#expect(JWT.claims(from: makeJWT(ns: ns))?.ns == ns)
|
|
}
|
|
|
|
/// `???` encodes to a base64 block containing `/`, `>>>` to one
|
|
/// containing `+` — both must round-trip through the url-safe alphabet.
|
|
@Test(arguments: ["???", ">>>"])
|
|
func decodesURLSafeAlphabet(ns: String) {
|
|
#expect(JWT.claims(from: makeJWT(ns: ns))?.ns == ns)
|
|
}
|
|
|
|
@Test(arguments: [
|
|
"",
|
|
"garbage",
|
|
"only.one-real-segment",
|
|
"a.!!!.c", // invalid base64 characters
|
|
"a.b.c", // payload length 1 (mod 4) is not valid base64
|
|
"a..c", // empty payload
|
|
])
|
|
func garbageYieldsNil(token: String) {
|
|
#expect(JWT.claims(from: token) == nil)
|
|
}
|
|
|
|
@Test func nonJSONPayloadYieldsNil() {
|
|
let token = "h.\(base64URLEncode(Data("hello".utf8))).s"
|
|
#expect(JWT.claims(from: token) == nil)
|
|
}
|
|
|
|
@Test func payloadWithoutRequiredClaimsYieldsNil() {
|
|
let token = "h.\(base64URLEncode(Data("{\"exp\":123}".utf8))).s"
|
|
#expect(JWT.claims(from: token) == nil)
|
|
}
|
|
|
|
/// A hostile out-of-range `exp` must not trap `Int(_:)`.
|
|
@Test func absurdExpIsDropped() {
|
|
let payload = "{\"uid\":1,\"ns\":\"default\",\"exp\":1e300}"
|
|
let token = "h.\(base64URLEncode(Data(payload.utf8))).s"
|
|
#expect(JWT.claims(from: token) == JWTClaims(uid: 1, ns: "default", exp: nil))
|
|
}
|
|
}
|