From a7104655f5a80ab48ba3bbbf7bee8b5e69793798 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 8 May 2026 14:13:09 -0400 Subject: [PATCH 01/11] #312 library: add public+web-auth user-identity endpoints and users/caller migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the library side of #312 — adding/renaming user-identity endpoints that require public-database routing with web-auth (user-context) credentials, and unblocking the convenience initializers from their hardcoded database/ environment defaults. #310: `CloudKitService` convenience initializers now accept `database:` and `environment:` parameters with defaults that preserve current behavior. #311: `users/current` → `users/caller`. Renamed in openapi.yaml and the generated client; added a hand-written `fetchCaller()` plus an `@available(*, deprecated, renamed: "fetchCaller")` `fetchCurrentUser()` shim that forwards to the new method. #28: GET `/users/discover` (`discoverAllUserIdentities`). #34: POST `/users/lookup/email` (`lookupUsersByEmail`). #35: POST `/users/lookup/id` (`lookupUsersByRecordName`). The three new endpoints reuse `DiscoverResponse` for parsing — Apple returns `{ users: [UserIdentity] }` for all of them. Each ships with a 5-file test suite mirroring the existing `DiscoverUserIdentities` pattern. #33 (`users/lookup/contacts`) intentionally not implemented: Apple has marked the endpoint deprecated. To be closed as not-planned with a pointer to #34/#35. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 14 +- ....swift => AuthenticationCredentials.swift} | 0 ...istDemoConfig+DatabaseConfiguration.swift} | 0 ...UserPhase.swift => FetchCallerPhase.swift} | 0 ...ionCredentialsTests+ToConfiguration.swift} | 0 ...t => AuthenticationCredentialsTests.swift} | 0 Sources/MistKit/Generated/Client.swift | 380 +++++++- Sources/MistKit/Generated/Types.swift | 915 +++++++++++++++++- .../CloudKitResponseProcessor+Changes.swift | 58 ++ .../Service/CloudKitResponseProcessor.swift | 6 +- .../CloudKitService+ErrorHandling.swift | 2 +- .../CloudKitService+Initialization.swift | 32 +- .../CloudKitService+UserOperations.swift | 103 +- Sources/MistKit/Service/CloudKitService.swift | 48 +- ...ons.discoverAllUserIdentities.Output.swift | 62 ++ ...wift => Operations.getCaller.Output.swift} | 4 +- ...Operations.lookupUsersByEmail.Output.swift | 62 ++ ...tions.lookupUsersByRecordName.Output.swift | 62 ++ Sources/MistKit/Service/UserInfo.swift | 2 +- ...ts.DiscoverAllUserIdentities+Helpers.swift | 65 ++ ...scoverAllUserIdentities+SuccessCases.swift | 84 ++ ...DiscoverAllUserIdentities+Validation.swift | 52 + ...rviceTests.DiscoverAllUserIdentities.swift | 41 + ...viceTests.LookupUsersByEmail+Helpers.swift | 65 ++ ...ests.LookupUsersByEmail+SuccessCases.swift | 85 ++ ...eTests.LookupUsersByEmail+Validation.swift | 52 + ...udKitServiceTests.LookupUsersByEmail.swift | 41 + ...ests.LookupUsersByRecordName+Helpers.swift | 63 ++ ...LookupUsersByRecordName+SuccessCases.swift | 83 ++ ...s.LookupUsersByRecordName+Validation.swift | 52 + ...ServiceTests.LookupUsersByRecordName.swift | 41 + openapi.yaml | 119 ++- 32 files changed, 2502 insertions(+), 91 deletions(-) rename Examples/MistDemo/Sources/MistDemoKit/Configuration/{DatabaseCredentials.swift => AuthenticationCredentials.swift} (100%) rename Examples/MistDemo/Sources/MistDemoKit/Configuration/{MistDemoConfig+DatabaseCredentials.swift => MistDemoConfig+DatabaseConfiguration.swift} (100%) rename Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/{FetchCurrentUserPhase.swift => FetchCallerPhase.swift} (100%) rename Examples/MistDemo/Tests/MistDemoTests/Configuration/{DatabaseCredentialsTests+ToDatabaseCredentials.swift => AuthenticationCredentialsTests+ToConfiguration.swift} (100%) rename Examples/MistDemo/Tests/MistDemoTests/Configuration/{DatabaseCredentialsTests.swift => AuthenticationCredentialsTests.swift} (100%) create mode 100644 Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift rename Sources/MistKit/Service/{Operations.getCurrentUser.Output.swift => Operations.getCaller.Output.swift} (97%) create mode 100644 Sources/MistKit/Service/Operations.lookupUsersByEmail.Output.swift create mode 100644 Sources/MistKit/Service/Operations.lookupUsersByRecordName.Output.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Validation.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Validation.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3afcc1a5..01b0c6d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,7 @@ MistKit/ | `CloudKitService+WriteOperations.swift` | `modifyRecords`, `createRecord`, `updateRecord`, `deleteRecord` | | `CloudKitService+ZoneOperations.swift` | `listZones`, `lookupZones(zoneIDs:)`, `fetchZoneChanges(syncToken:)` | | `CloudKitService+SyncOperations.swift` | `fetchRecordChanges(recordType:syncToken:)`, `fetchAllRecordChanges(recordType:syncToken:)` | -| `CloudKitService+UserOperations.swift` | `fetchCurrentUser()`, `discoverUserIdentities(lookupInfos:)` | +| `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()`, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)`, `fetchCurrentUser()` (deprecated, forwards to `fetchCaller`) | | `CloudKitService+AssetOperations.swift` | `uploadAssets`, `requestAssetUploadURL` | | `CloudKitService+AssetUpload.swift` | `uploadAssetData` | | `CloudKitService+RecordManaging.swift` | record-managing convenience surface | @@ -179,7 +179,15 @@ MistKit/ - `fetchAllRecordChanges(recordType:syncToken:)` — convenience wrapper that auto-paginates using `moreComing` - `fetchZoneChanges(syncToken:)` → `/zones/changes` — returns `ZoneChangesResult` - `lookupZones(zoneIDs:)` → `/zones/lookup` — returns `[ZoneInfo]` -- `discoverUserIdentities(lookupInfos:)` → `/users/discover` — takes `[UserIdentityLookupInfo]`, returns `[UserIdentity]` +- `discoverUserIdentities(lookupInfos:)` → POST `/users/discover` — takes `[UserIdentityLookupInfo]`, returns `[UserIdentity]` + +**User-Identity Operations (public DB + web-auth required):** +- `fetchCaller()` → `/users/caller` — returns `UserInfo`. Replaces deprecated `fetchCurrentUser()` / `users/current`. Only valid against the public database with web-auth credentials. +- `discoverAllUserIdentities()` → GET `/users/discover` — returns `[UserIdentity]` for every discoverable user in the caller's address book. +- `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`. +- `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`. + +In MistDemo, integration runs targeting these endpoints use `PhaseContext.userContextService` (a public+web-auth `CloudKitService`) which is built from `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` regardless of the primary `--database` selection. The `DatabaseConfiguration` / `AuthenticationCredentials` types in `Examples/MistDemo/Sources/MistDemoKit/Configuration/` enforce valid database+auth combinations at construction time. **Result Types (Sources/MistKit/Service/):** - `QueryResult` — `records: [RecordInfo]`, `continuationMarker: String?` @@ -344,7 +352,7 @@ Key endpoints documented in the OpenAPI spec: - Records: `/records/query`, `/records/modify`, `/records/lookup`, `/records/changes` - Zones: `/zones/list`, `/zones/lookup`, `/zones/modify`, `/zones/changes` - Subscriptions: `/subscriptions/list`, `/subscriptions/lookup`, `/subscriptions/modify` -- Users: `/users/current`, `/users/discover`, `/users/lookup/contacts` +- Users: `/users/caller`, `/users/discover` (POST + GET), `/users/lookup/email`, `/users/lookup/id` - Assets: `/assets/upload` - Tokens: `/tokens/create`, `/tokens/register` diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseCredentials.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift similarity index 100% rename from Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseCredentials.swift rename to Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseCredentials.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift similarity index 100% rename from Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseCredentials.swift rename to Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCurrentUserPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift similarity index 100% rename from Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCurrentUserPhase.swift rename to Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests+ToDatabaseCredentials.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift similarity index 100% rename from Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests+ToDatabaseCredentials.swift rename to Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift similarity index 100% rename from Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests.swift rename to Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift diff --git a/Sources/MistKit/Generated/Client.swift b/Sources/MistKit/Generated/Client.swift index d441a573..99738c25 100644 --- a/Sources/MistKit/Generated/Client.swift +++ b/Sources/MistKit/Generated/Client.swift @@ -2348,19 +2348,23 @@ internal struct Client: APIProtocol { } ) } - /// Get Current User + /// Get the Caller (Current User) /// - /// Fetch the current authenticated user's information + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - internal func getCurrentUser(_ input: Operations.getCurrentUser.Input) async throws -> Operations.getCurrentUser.Output { + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + internal func getCaller(_ input: Operations.getCaller.Input) async throws -> Operations.getCaller.Output { try await client.send( input: input, - forOperation: Operations.getCurrentUser.id, + forOperation: Operations.getCaller.id, serializer: { input in let path = try converter.renderedPath( - template: "/database/{}/{}/{}/{}/users/current", + template: "/database/{}/{}/{}/{}/users/caller", parameters: [ input.path.version, input.path.container, @@ -2383,7 +2387,7 @@ internal struct Client: APIProtocol { switch response.status.code { case 200: let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) - let body: Operations.getCurrentUser.Output.Ok.Body + let body: Operations.getCaller.Output.Ok.Body let chosenContentType = try converter.bestContentType( received: contentType, options: [ @@ -2657,6 +2661,121 @@ internal struct Client: APIProtocol { } ) } + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + internal func discoverAllUserIdentities(_ input: Operations.discoverAllUserIdentities.Input) async throws -> Operations.discoverAllUserIdentities.Output { + try await client.send( + input: input, + forOperation: Operations.discoverAllUserIdentities.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/users/discover", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.discoverAllUserIdentities.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.DiscoverResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.BadRequest.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Unauthorized.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } /// Discover User Identities /// /// Discover all user identities based on email addresses or user record names @@ -2777,6 +2896,251 @@ internal struct Client: APIProtocol { } ) } + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + internal func lookupUsersByEmail(_ input: Operations.lookupUsersByEmail.Input) async throws -> Operations.lookupUsersByEmail.Output { + try await client.send( + input: input, + forOperation: Operations.lookupUsersByEmail.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/users/lookup/email", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.lookupUsersByEmail.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.DiscoverResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.BadRequest.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Unauthorized.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + internal func lookupUsersByRecordName(_ input: Operations.lookupUsersByRecordName.Input) async throws -> Operations.lookupUsersByRecordName.Output { + try await client.send( + input: input, + forOperation: Operations.lookupUsersByRecordName.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/users/lookup/id", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.lookupUsersByRecordName.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.DiscoverResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.BadRequest.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Unauthorized.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } /// Lookup Contacts (Deprecated) /// /// Fetch contacts (This endpoint is deprecated) diff --git a/Sources/MistKit/Generated/Types.swift b/Sources/MistKit/Generated/Types.swift index 7546e936..d82b5a28 100644 --- a/Sources/MistKit/Generated/Types.swift +++ b/Sources/MistKit/Generated/Types.swift @@ -90,13 +90,28 @@ internal protocol APIProtocol: Sendable { /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/subscriptions/modify`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/subscriptions/modify/post(modifySubscriptions)`. func modifySubscriptions(_ input: Operations.modifySubscriptions.Input) async throws -> Operations.modifySubscriptions.Output - /// Get Current User + /// Get the Caller (Current User) /// - /// Fetch the current authenticated user's information + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - func getCurrentUser(_ input: Operations.getCurrentUser.Input) async throws -> Operations.getCurrentUser.Output + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + func getCaller(_ input: Operations.getCaller.Input) async throws -> Operations.getCaller.Output + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + func discoverAllUserIdentities(_ input: Operations.discoverAllUserIdentities.Input) async throws -> Operations.discoverAllUserIdentities.Output /// Discover User Identities /// /// Discover all user identities based on email addresses or user record names @@ -104,6 +119,25 @@ internal protocol APIProtocol: Sendable { /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/discover`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/post(discoverUserIdentities)`. func discoverUserIdentities(_ input: Operations.discoverUserIdentities.Input) async throws -> Operations.discoverUserIdentities.Output + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + func lookupUsersByEmail(_ input: Operations.lookupUsersByEmail.Input) async throws -> Operations.lookupUsersByEmail.Output + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + func lookupUsersByRecordName(_ input: Operations.lookupUsersByRecordName.Input) async throws -> Operations.lookupUsersByRecordName.Output /// Lookup Contacts (Deprecated) /// /// Fetch contacts (This endpoint is deprecated) @@ -325,17 +359,40 @@ extension APIProtocol { body: body )) } - /// Get Current User + /// Get the Caller (Current User) + /// + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + internal func getCaller( + path: Operations.getCaller.Input.Path, + headers: Operations.getCaller.Input.Headers = .init() + ) async throws -> Operations.getCaller.Output { + try await getCaller(Operations.getCaller.Input( + path: path, + headers: headers + )) + } + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. /// - /// Fetch the current authenticated user's information /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - internal func getCurrentUser( - path: Operations.getCurrentUser.Input.Path, - headers: Operations.getCurrentUser.Input.Headers = .init() - ) async throws -> Operations.getCurrentUser.Output { - try await getCurrentUser(Operations.getCurrentUser.Input( + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + internal func discoverAllUserIdentities( + path: Operations.discoverAllUserIdentities.Input.Path, + headers: Operations.discoverAllUserIdentities.Input.Headers = .init() + ) async throws -> Operations.discoverAllUserIdentities.Output { + try await discoverAllUserIdentities(Operations.discoverAllUserIdentities.Input( path: path, headers: headers )) @@ -357,6 +414,45 @@ extension APIProtocol { body: body )) } + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + internal func lookupUsersByEmail( + path: Operations.lookupUsersByEmail.Input.Path, + headers: Operations.lookupUsersByEmail.Input.Headers = .init(), + body: Operations.lookupUsersByEmail.Input.Body + ) async throws -> Operations.lookupUsersByEmail.Output { + try await lookupUsersByEmail(Operations.lookupUsersByEmail.Input( + path: path, + headers: headers, + body: body + )) + } + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + internal func lookupUsersByRecordName( + path: Operations.lookupUsersByRecordName.Input.Path, + headers: Operations.lookupUsersByRecordName.Input.Headers = .init(), + body: Operations.lookupUsersByRecordName.Input.Body + ) async throws -> Operations.lookupUsersByRecordName.Output { + try await lookupUsersByRecordName(Operations.lookupUsersByRecordName.Input( + path: path, + headers: headers, + body: body + )) + } /// Lookup Contacts (Deprecated) /// /// Fetch contacts (This endpoint is deprecated) @@ -6129,20 +6225,24 @@ internal enum Operations { } } } - /// Get Current User + /// Get the Caller (Current User) /// - /// Fetch the current authenticated user's information + /// Fetch the authenticated caller's user information. This replaces the deprecated + /// `users/current` endpoint. Requires public database with a web-auth token + /// (user-context auth); server-to-server credentials and the private database + /// will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. /// - /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/current`. - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)`. - internal enum getCurrentUser { - internal static let id: Swift.String = "getCurrentUser" + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/caller`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)`. + internal enum getCaller { + internal static let id: Swift.String = "getCaller" internal struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path`. internal struct Path: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/version`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/version`. internal var version: Components.Parameters.version - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/container`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/container`. internal var container: Components.Parameters.container /// Container environment /// @@ -6151,7 +6251,7 @@ internal enum Operations { case development = "development" case production = "production" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/environment`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/environment`. internal var environment: Components.Parameters.environment /// Database scope /// @@ -6161,7 +6261,7 @@ internal enum Operations { case _private = "private" case shared = "shared" } - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/path/database`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/path/database`. internal var database: Components.Parameters.database /// Creates a new `Path`. /// @@ -6182,27 +6282,27 @@ internal enum Operations { self.database = database } } - internal var path: Operations.getCurrentUser.Input.Path - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/header`. + internal var path: Operations.getCaller.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/header`. internal struct Headers: Sendable, Hashable { - internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] + internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - internal var headers: Operations.getCurrentUser.Input.Headers + internal var headers: Operations.getCaller.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: internal init( - path: Operations.getCurrentUser.Input.Path, - headers: Operations.getCurrentUser.Input.Headers = .init() + path: Operations.getCaller.Input.Path, + headers: Operations.getCaller.Input.Headers = .init() ) { self.path = path self.headers = headers @@ -6210,9 +6310,9 @@ internal enum Operations { } internal enum Output: Sendable, Hashable { internal struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/responses/200/content`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/responses/200/content`. internal enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/current/GET/responses/200/content/application\/json`. + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/caller/GET/responses/200/content/application\/json`. case json(Components.Schemas.UserResponse) /// The associated value of the enum case if `self` is `.json`. /// @@ -6228,26 +6328,26 @@ internal enum Operations { } } /// Received HTTP response body - internal var body: Operations.getCurrentUser.Output.Ok.Body + internal var body: Operations.getCaller.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - internal init(body: Operations.getCurrentUser.Output.Ok.Body) { + internal init(body: Operations.getCaller.Output.Ok.Body) { self.body = body } } /// User information retrieved successfully /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/200`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/200`. /// /// HTTP response code: `200 ok`. - case ok(Operations.getCurrentUser.Output.Ok) + case ok(Operations.getCaller.Output.Ok) /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - internal var ok: Operations.getCurrentUser.Output.Ok { + internal var ok: Operations.getCaller.Output.Ok { get throws { switch self { case let .ok(response): @@ -6262,7 +6362,7 @@ internal enum Operations { } /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/400`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Components.Responses.BadRequest) @@ -6285,7 +6385,7 @@ internal enum Operations { } /// Unauthorized (401) - AUTHENTICATION_FAILED /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/401`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/401`. /// /// HTTP response code: `401 unauthorized`. case unauthorized(Components.Responses.Unauthorized) @@ -6308,7 +6408,7 @@ internal enum Operations { } /// Forbidden (403) - ACCESS_DENIED /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/403`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/403`. /// /// HTTP response code: `403 forbidden`. case forbidden(Components.Responses.Forbidden) @@ -6331,7 +6431,7 @@ internal enum Operations { } /// Not found (404) - NOT_FOUND, ZONE_NOT_FOUND /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/404`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Components.Responses.NotFound) @@ -6354,7 +6454,7 @@ internal enum Operations { } /// Conflict (409) - CONFLICT, EXISTS /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/409`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/409`. /// /// HTTP response code: `409 conflict`. case conflict(Components.Responses.Conflict) @@ -6377,7 +6477,7 @@ internal enum Operations { } /// Precondition failed (412) - VALIDATING_REFERENCE_ERROR /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/412`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/412`. /// /// HTTP response code: `412 preconditionFailed`. case preconditionFailed(Components.Responses.PreconditionFailed) @@ -6400,7 +6500,7 @@ internal enum Operations { } /// Request entity too large (413) - QUOTA_EXCEEDED /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/413`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/413`. /// /// HTTP response code: `413 contentTooLarge`. case contentTooLarge(Components.Responses.RequestEntityTooLarge) @@ -6423,7 +6523,7 @@ internal enum Operations { } /// Too many requests (429) - THROTTLED /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/429`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/429`. /// /// HTTP response code: `429 tooManyRequests`. case tooManyRequests(Components.Responses.TooManyRequests) @@ -6446,7 +6546,7 @@ internal enum Operations { } /// Unprocessable entity (421) - AUTHENTICATION_REQUIRED /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/421`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/421`. /// /// HTTP response code: `421 misdirectedRequest`. case misdirectedRequest(Components.Responses.UnprocessableEntity) @@ -6469,7 +6569,7 @@ internal enum Operations { } /// Internal server error (500) - INTERNAL_ERROR /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/500`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/500`. /// /// HTTP response code: `500 internalServerError`. case internalServerError(Components.Responses.InternalServerError) @@ -6492,7 +6592,7 @@ internal enum Operations { } /// Service unavailable (503) - TRY_AGAIN_LATER /// - /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/current/get(getCurrentUser)/responses/503`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/caller/get(getCaller)/responses/503`. /// /// HTTP response code: `503 serviceUnavailable`. case serviceUnavailable(Components.Responses.ServiceUnavailable) @@ -6544,6 +6644,218 @@ internal enum Operations { } } } + /// Discover All User Identities + /// + /// Fetch every user identity in the caller's CloudKit address book. + /// Requires public-database routing with web-auth credentials (user-context + /// auth); only users who have run the app and granted discoverability are + /// returned. + /// + /// + /// - Remark: HTTP `GET /database/{version}/{container}/{environment}/{database}/users/discover`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)`. + internal enum discoverAllUserIdentities { + internal static let id: Swift.String = "discoverAllUserIdentities" + internal struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path`. + internal struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/version`. + internal var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/container`. + internal var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/environment`. + internal var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + internal enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/path/database`. + internal var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + internal init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + internal var path: Operations.discoverAllUserIdentities.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/header`. + internal struct Headers: Sendable, Hashable { + internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + internal var headers: Operations.discoverAllUserIdentities.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + internal init( + path: Operations.discoverAllUserIdentities.Input.Path, + headers: Operations.discoverAllUserIdentities.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + internal enum Output: Sendable, Hashable { + internal struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/responses/200/content`. + internal enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/discover/GET/responses/200/content/application\/json`. + case json(Components.Schemas.DiscoverResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + internal var json: Components.Schemas.DiscoverResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + internal var body: Operations.discoverAllUserIdentities.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + internal init(body: Operations.discoverAllUserIdentities.Output.Ok.Body) { + self.body = body + } + } + /// All discoverable user identities returned successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.discoverAllUserIdentities.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + internal var ok: Operations.discoverAllUserIdentities.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + internal var badRequest: Components.Responses.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Unauthorized (401) - AUTHENTICATION_FAILED + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/discover/get(discoverAllUserIdentities)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Unauthorized) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + internal var unauthorized: Components.Responses.Unauthorized { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + internal enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + internal init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + internal var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + internal static var allCases: [Self] { + [ + .json + ] + } + } + } /// Discover User Identities /// /// Discover all user identities based on email addresses or user record names @@ -6807,6 +7119,509 @@ internal enum Operations { } } } + /// Lookup Users by Email + /// + /// Look up user identities by email address. Requires public-database + /// routing with web-auth credentials (user-context auth). Each requested + /// email returns at most one identity in the `users` array. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/email`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)`. + internal enum lookupUsersByEmail { + internal static let id: Swift.String = "lookupUsersByEmail" + internal struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path`. + internal struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/version`. + internal var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/container`. + internal var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/environment`. + internal var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + internal enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/path/database`. + internal var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + internal init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + internal var path: Operations.lookupUsersByEmail.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/header`. + internal struct Headers: Sendable, Hashable { + internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + internal var headers: Operations.lookupUsersByEmail.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody`. + internal enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json`. + internal struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/usersPayload`. + internal struct usersPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/usersPayload/emailAddress`. + internal var emailAddress: Swift.String? + /// Creates a new `usersPayloadPayload`. + /// + /// - Parameters: + /// - emailAddress: + internal init(emailAddress: Swift.String? = nil) { + self.emailAddress = emailAddress + } + internal enum CodingKeys: String, CodingKey { + case emailAddress + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/users`. + internal typealias usersPayload = [Operations.lookupUsersByEmail.Input.Body.jsonPayload.usersPayloadPayload] + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/json/users`. + internal var users: Operations.lookupUsersByEmail.Input.Body.jsonPayload.usersPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - users: + internal init(users: Operations.lookupUsersByEmail.Input.Body.jsonPayload.usersPayload? = nil) { + self.users = users + } + internal enum CodingKeys: String, CodingKey { + case users + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/requestBody/content/application\/json`. + case json(Operations.lookupUsersByEmail.Input.Body.jsonPayload) + } + internal var body: Operations.lookupUsersByEmail.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + internal init( + path: Operations.lookupUsersByEmail.Input.Path, + headers: Operations.lookupUsersByEmail.Input.Headers = .init(), + body: Operations.lookupUsersByEmail.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + internal enum Output: Sendable, Hashable { + internal struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/responses/200/content`. + internal enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/email/POST/responses/200/content/application\/json`. + case json(Components.Schemas.DiscoverResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + internal var json: Components.Schemas.DiscoverResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + internal var body: Operations.lookupUsersByEmail.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + internal init(body: Operations.lookupUsersByEmail.Output.Ok.Body) { + self.body = body + } + } + /// User identities returned successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupUsersByEmail.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + internal var ok: Operations.lookupUsersByEmail.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + internal var badRequest: Components.Responses.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Unauthorized (401) - AUTHENTICATION_FAILED + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/email/post(lookupUsersByEmail)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Unauthorized) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + internal var unauthorized: Components.Responses.Unauthorized { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + internal enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + internal init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + internal var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + internal static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Lookup Users by Record Name + /// + /// Look up user identities by record name (CloudKit user record ID). + /// Requires public-database routing with web-auth credentials. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/users/lookup/id`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)`. + internal enum lookupUsersByRecordName { + internal static let id: Swift.String = "lookupUsersByRecordName" + internal struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path`. + internal struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/version`. + internal var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/container`. + internal var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + internal enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/environment`. + internal var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + internal enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/path/database`. + internal var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + internal init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + internal var path: Operations.lookupUsersByRecordName.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/header`. + internal struct Headers: Sendable, Hashable { + internal var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + internal init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + internal var headers: Operations.lookupUsersByRecordName.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody`. + internal enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json`. + internal struct jsonPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/usersPayload`. + internal struct usersPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/usersPayload/userRecordName`. + internal var userRecordName: Swift.String? + /// Creates a new `usersPayloadPayload`. + /// + /// - Parameters: + /// - userRecordName: + internal init(userRecordName: Swift.String? = nil) { + self.userRecordName = userRecordName + } + internal enum CodingKeys: String, CodingKey { + case userRecordName + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/users`. + internal typealias usersPayload = [Operations.lookupUsersByRecordName.Input.Body.jsonPayload.usersPayloadPayload] + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/json/users`. + internal var users: Operations.lookupUsersByRecordName.Input.Body.jsonPayload.usersPayload? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - users: + internal init(users: Operations.lookupUsersByRecordName.Input.Body.jsonPayload.usersPayload? = nil) { + self.users = users + } + internal enum CodingKeys: String, CodingKey { + case users + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/requestBody/content/application\/json`. + case json(Operations.lookupUsersByRecordName.Input.Body.jsonPayload) + } + internal var body: Operations.lookupUsersByRecordName.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + internal init( + path: Operations.lookupUsersByRecordName.Input.Path, + headers: Operations.lookupUsersByRecordName.Input.Headers = .init(), + body: Operations.lookupUsersByRecordName.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + internal enum Output: Sendable, Hashable { + internal struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/responses/200/content`. + internal enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/users/lookup/id/POST/responses/200/content/application\/json`. + case json(Components.Schemas.DiscoverResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + internal var json: Components.Schemas.DiscoverResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + internal var body: Operations.lookupUsersByRecordName.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + internal init(body: Operations.lookupUsersByRecordName.Output.Ok.Body) { + self.body = body + } + } + /// User identities returned successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.lookupUsersByRecordName.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + internal var ok: Operations.lookupUsersByRecordName.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Bad request (400) - BAD_REQUEST, ATOMIC_ERROR + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + internal var badRequest: Components.Responses.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Unauthorized (401) - AUTHENTICATION_FAILED + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/users/lookup/id/post(lookupUsersByRecordName)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Unauthorized) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + internal var unauthorized: Components.Responses.Unauthorized { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + internal enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + internal init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + internal var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + internal static var allCases: [Self] { + [ + .json + ] + } + } + } /// Lookup Contacts (Deprecated) /// /// Fetch contacts (This endpoint is deprecated) diff --git a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift index a992db88..4a75493b 100644 --- a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift +++ b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift @@ -74,6 +74,64 @@ extension CloudKitResponseProcessor { } } + /// Process discoverAllUserIdentities response + internal func processDiscoverAllUserIdentitiesResponse( + _ response: Operations.discoverAllUserIdentities.Output + ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let discoverData): + return discoverData + } + default: + // Should never reach here since all errors are handled above + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + + /// Process lookupUsersByEmail response + internal func processLookupUsersByEmailResponse( + _ response: Operations.lookupUsersByEmail.Output + ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let discoverData): + return discoverData + } + default: + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + + /// Process lookupUsersByRecordName response + internal func processLookupUsersByRecordNameResponse( + _ response: Operations.lookupUsersByRecordName.Output + ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { + if let error = CloudKitError(response) { + throw error + } + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let discoverData): + return discoverData + } + default: + assertionFailure("Unexpected response case after error handling") + throw CloudKitError.invalidResponse + } + } + /// Process uploadAssets response /// - Parameter response: The response to process /// - Returns: The extracted asset upload response data diff --git a/Sources/MistKit/Service/CloudKitResponseProcessor.swift b/Sources/MistKit/Service/CloudKitResponseProcessor.swift index 43b2c7b8..1f8d5c37 100644 --- a/Sources/MistKit/Service/CloudKitResponseProcessor.swift +++ b/Sources/MistKit/Service/CloudKitResponseProcessor.swift @@ -32,11 +32,11 @@ import OpenAPIRuntime /// Processes CloudKit API responses and handles errors internal struct CloudKitResponseProcessor { - /// Process getCurrentUser response + /// Process getCaller response /// - Parameter response: The response to process /// - Returns: The extracted user data /// - Throws: CloudKitError for various error conditions - internal func processGetCurrentUserResponse(_ response: Operations.getCurrentUser.Output) + internal func processGetCallerResponse(_ response: Operations.getCaller.Output) async throws(CloudKitError) -> Components.Schemas.UserResponse { // Check for errors first @@ -57,7 +57,7 @@ internal struct CloudKitResponseProcessor { /// Extract user data from OK response private func extractUserData( - from response: Operations.getCurrentUser.Output.Ok + from response: Operations.getCaller.Output.Ok ) throws(CloudKitError) -> Components.Schemas.UserResponse { switch response.body { case .json(let userData): diff --git a/Sources/MistKit/Service/CloudKitService+ErrorHandling.swift b/Sources/MistKit/Service/CloudKitService+ErrorHandling.swift index 01b93c9e..fbb759ba 100644 --- a/Sources/MistKit/Service/CloudKitService+ErrorHandling.swift +++ b/Sources/MistKit/Service/CloudKitService+ErrorHandling.swift @@ -41,7 +41,7 @@ extension CloudKitService { /// /// - Parameters: /// - error: The error to map - /// - context: A description of the operation (e.g., "fetchCurrentUser") + /// - context: A description of the operation (e.g., "fetchCaller") /// - Returns: A CloudKitError representing the original error internal func mapToCloudKitError( _ error: any Error, diff --git a/Sources/MistKit/Service/CloudKitService+Initialization.swift b/Sources/MistKit/Service/CloudKitService+Initialization.swift index ea5a5f77..199ae870 100644 --- a/Sources/MistKit/Service/CloudKitService+Initialization.swift +++ b/Sources/MistKit/Service/CloudKitService+Initialization.swift @@ -44,17 +44,19 @@ extension CloudKitService { containerIdentifier: String, apiToken: String, webAuthToken: String, + environment: Environment = .development, + database: Database = .private, transport: any ClientTransport ) throws { self.containerIdentifier = containerIdentifier self.apiToken = apiToken - self.environment = .development - self.database = .private + self.environment = environment + self.database = database let config = MistKitConfiguration( container: containerIdentifier, - environment: .development, - database: .private, + environment: environment, + database: database, apiToken: apiToken, webAuthToken: webAuthToken ) @@ -69,17 +71,19 @@ extension CloudKitService { public init( containerIdentifier: String, apiToken: String, + environment: Environment = .development, + database: Database = .public, transport: any ClientTransport ) throws { self.containerIdentifier = containerIdentifier self.apiToken = apiToken - self.environment = .development - self.database = .public // API-only supports public database + self.environment = environment + self.database = database let config = MistKitConfiguration( container: containerIdentifier, - environment: .development, - database: .public, // API-only supports public database + environment: environment, + database: database, apiToken: apiToken, webAuthToken: nil, keyID: nil, @@ -129,12 +133,16 @@ extension CloudKitService { public init( containerIdentifier: String, apiToken: String, - webAuthToken: String + webAuthToken: String, + environment: Environment = .development, + database: Database = .private ) throws { try self.init( containerIdentifier: containerIdentifier, apiToken: apiToken, webAuthToken: webAuthToken, + environment: environment, + database: database, transport: URLSessionTransport() ) } @@ -145,11 +153,15 @@ extension CloudKitService { /// For WASI builds, use the generic initializer that accepts a transport parameter. public init( containerIdentifier: String, - apiToken: String + apiToken: String, + environment: Environment = .development, + database: Database = .public ) throws { try self.init( containerIdentifier: containerIdentifier, apiToken: apiToken, + environment: environment, + database: database, transport: URLSessionTransport() ) } diff --git a/Sources/MistKit/Service/CloudKitService+UserOperations.swift b/Sources/MistKit/Service/CloudKitService+UserOperations.swift index f580bef2..54da9256 100644 --- a/Sources/MistKit/Service/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+UserOperations.swift @@ -40,20 +40,111 @@ import OpenAPIRuntime @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { - /// Fetch current user information - public func fetchCurrentUser() async throws(CloudKitError) -> UserInfo { + /// Fetch the caller's (current authenticated user's) information. + /// + /// Hits CloudKit's `users/caller` endpoint, which replaces the deprecated + /// `users/current`. Requires public-database routing with web-auth credentials + /// (user-context auth); calling against the private database returns + /// `BAD_REQUEST: endpoint not applicable in the database type`. + public func fetchCaller() async throws(CloudKitError) -> UserInfo { do { - let response = try await client.getCurrentUser( + let response = try await client.getCaller( .init( - path: createGetCurrentUserPath(containerIdentifier: containerIdentifier) + path: createGetCallerPath(containerIdentifier: containerIdentifier) ) ) let userData: Components.Schemas.UserResponse = - try await responseProcessor.processGetCurrentUserResponse(response) + try await responseProcessor.processGetCallerResponse(response) return UserInfo(from: userData) } catch { - throw mapToCloudKitError(error, context: "fetchCurrentUser") + throw mapToCloudKitError(error, context: "fetchCaller") + } + } + + /// Fetch the current authenticated user's information. + @available(*, deprecated, renamed: "fetchCaller", message: "users/current is deprecated by Apple. Use fetchCaller() instead.") + public func fetchCurrentUser() async throws(CloudKitError) -> UserInfo { + try await fetchCaller() + } + + /// Discover all user identities in the caller's CloudKit address book. + /// + /// Hits CloudKit's GET `users/discover` endpoint. Requires public-database + /// routing with web-auth credentials (user-context auth); only users who have + /// run the app and granted discoverability are returned. + public func discoverAllUserIdentities() async throws(CloudKitError) -> [UserIdentity] { + do { + let response = try await client.discoverAllUserIdentities( + .init( + path: createDiscoverAllUserIdentitiesPath( + containerIdentifier: containerIdentifier + ) + ) + ) + + let discoverData: Components.Schemas.DiscoverResponse = + try await responseProcessor.processDiscoverAllUserIdentitiesResponse( + response + ) + return discoverData.users?.map(UserIdentity.init(from:)) ?? [] + } catch { + throw mapToCloudKitError(error, context: "discoverAllUserIdentities") + } + } + + /// Look up user identities by email address. + /// + /// Hits CloudKit's POST `users/lookup/email` endpoint. Each requested email + /// returns at most one identity in the result array. Requires public-database + /// routing with web-auth credentials (user-context auth). + public func lookupUsersByEmail( + _ emails: [String] + ) async throws(CloudKitError) -> [UserIdentity] { + do { + let response = try await client.lookupUsersByEmail( + .init( + path: createLookupUsersByEmailPath( + containerIdentifier: containerIdentifier + ), + body: .json( + .init(users: emails.map { .init(emailAddress: $0) }) + ) + ) + ) + + let discoverData: Components.Schemas.DiscoverResponse = + try await responseProcessor.processLookupUsersByEmailResponse(response) + return discoverData.users?.map(UserIdentity.init(from:)) ?? [] + } catch { + throw mapToCloudKitError(error, context: "lookupUsersByEmail") + } + } + + /// Look up user identities by record name (CloudKit user record ID). + /// + /// Hits CloudKit's POST `users/lookup/id` endpoint. Requires public-database + /// routing with web-auth credentials (user-context auth). + public func lookupUsersByRecordName( + _ recordNames: [String] + ) async throws(CloudKitError) -> [UserIdentity] { + do { + let response = try await client.lookupUsersByRecordName( + .init( + path: createLookupUsersByRecordNamePath( + containerIdentifier: containerIdentifier + ), + body: .json( + .init(users: recordNames.map { .init(userRecordName: $0) }) + ) + ) + ) + + let discoverData: Components.Schemas.DiscoverResponse = + try await responseProcessor.processLookupUsersByRecordNameResponse(response) + return discoverData.users?.map(UserIdentity.init(from:)) ?? [] + } catch { + throw mapToCloudKitError(error, context: "lookupUsersByRecordName") } } diff --git a/Sources/MistKit/Service/CloudKitService.swift b/Sources/MistKit/Service/CloudKitService.swift index af992603..9a1b95fc 100644 --- a/Sources/MistKit/Service/CloudKitService.swift +++ b/Sources/MistKit/Service/CloudKitService.swift @@ -67,11 +67,11 @@ public struct CloudKitService: Sendable { @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { - /// Create a standard path for getCurrentUser requests + /// Create a standard path for getCaller requests /// - Parameter containerIdentifier: The container identifier /// - Returns: A configured path for the request - internal func createGetCurrentUserPath(containerIdentifier: String) - -> Operations.getCurrentUser.Input.Path + internal func createGetCallerPath(containerIdentifier: String) + -> Operations.getCaller.Input.Path { .init( version: "1", @@ -193,6 +193,48 @@ extension CloudKitService { ) } + /// Create a standard path for discoverAllUserIdentities requests + /// - Parameter containerIdentifier: The container identifier + /// - Returns: A configured path for the request + internal func createDiscoverAllUserIdentitiesPath( + containerIdentifier: String + ) -> Operations.discoverAllUserIdentities.Input.Path { + .init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ) + } + + /// Create a standard path for lookupUsersByEmail requests + /// - Parameter containerIdentifier: The container identifier + /// - Returns: A configured path for the request + internal func createLookupUsersByEmailPath( + containerIdentifier: String + ) -> Operations.lookupUsersByEmail.Input.Path { + .init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ) + } + + /// Create a standard path for lookupUsersByRecordName requests + /// - Parameter containerIdentifier: The container identifier + /// - Returns: A configured path for the request + internal func createLookupUsersByRecordNamePath( + containerIdentifier: String + ) -> Operations.lookupUsersByRecordName.Input.Path { + .init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ) + } + /// Create a standard path for fetchZoneChanges requests /// - Parameter containerIdentifier: The container identifier /// - Returns: A configured path for the request diff --git a/Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift b/Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift new file mode 100644 index 00000000..25c6a5b2 --- /dev/null +++ b/Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift @@ -0,0 +1,62 @@ +// +// Operations.discoverAllUserIdentities.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Operations.discoverAllUserIdentities.Output: CloudKitResponseType { + internal var badRequestResponse: Components.Responses.BadRequest? { + if case .badRequest(let response) = self { + return response + } else { + return nil + } + } + + internal var unauthorizedResponse: Components.Responses.Unauthorized? { + if case .unauthorized(let response) = self { + return response + } else { + return nil + } + } + + internal var isOk: Bool { + if case .ok = self { + return true + } else { + return false + } + } + + internal var undocumentedStatusCode: Int? { + if case .undocumented(let statusCode, _) = self { + return statusCode + } else { + return nil + } + } +} diff --git a/Sources/MistKit/Service/Operations.getCurrentUser.Output.swift b/Sources/MistKit/Service/Operations.getCaller.Output.swift similarity index 97% rename from Sources/MistKit/Service/Operations.getCurrentUser.Output.swift rename to Sources/MistKit/Service/Operations.getCaller.Output.swift index 8f3011ba..3ecc960b 100644 --- a/Sources/MistKit/Service/Operations.getCurrentUser.Output.swift +++ b/Sources/MistKit/Service/Operations.getCaller.Output.swift @@ -1,5 +1,5 @@ // -// Operations.getCurrentUser.Output.swift +// Operations.getCaller.Output.swift // MistKit // // Created by Leo Dion. @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -extension Operations.getCurrentUser.Output: CloudKitResponseType { +extension Operations.getCaller.Output: CloudKitResponseType { internal var badRequestResponse: Components.Responses.BadRequest? { if case .badRequest(let response) = self { return response diff --git a/Sources/MistKit/Service/Operations.lookupUsersByEmail.Output.swift b/Sources/MistKit/Service/Operations.lookupUsersByEmail.Output.swift new file mode 100644 index 00000000..bc9f99bb --- /dev/null +++ b/Sources/MistKit/Service/Operations.lookupUsersByEmail.Output.swift @@ -0,0 +1,62 @@ +// +// Operations.lookupUsersByEmail.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Operations.lookupUsersByEmail.Output: CloudKitResponseType { + internal var badRequestResponse: Components.Responses.BadRequest? { + if case .badRequest(let response) = self { + return response + } else { + return nil + } + } + + internal var unauthorizedResponse: Components.Responses.Unauthorized? { + if case .unauthorized(let response) = self { + return response + } else { + return nil + } + } + + internal var isOk: Bool { + if case .ok = self { + return true + } else { + return false + } + } + + internal var undocumentedStatusCode: Int? { + if case .undocumented(let statusCode, _) = self { + return statusCode + } else { + return nil + } + } +} diff --git a/Sources/MistKit/Service/Operations.lookupUsersByRecordName.Output.swift b/Sources/MistKit/Service/Operations.lookupUsersByRecordName.Output.swift new file mode 100644 index 00000000..c6332358 --- /dev/null +++ b/Sources/MistKit/Service/Operations.lookupUsersByRecordName.Output.swift @@ -0,0 +1,62 @@ +// +// Operations.lookupUsersByRecordName.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Operations.lookupUsersByRecordName.Output: CloudKitResponseType { + internal var badRequestResponse: Components.Responses.BadRequest? { + if case .badRequest(let response) = self { + return response + } else { + return nil + } + } + + internal var unauthorizedResponse: Components.Responses.Unauthorized? { + if case .unauthorized(let response) = self { + return response + } else { + return nil + } + } + + internal var isOk: Bool { + if case .ok = self { + return true + } else { + return false + } + } + + internal var undocumentedStatusCode: Int? { + if case .undocumented(let statusCode, _) = self { + return statusCode + } else { + return nil + } + } +} diff --git a/Sources/MistKit/Service/UserInfo.swift b/Sources/MistKit/Service/UserInfo.swift index 3a6f3cf8..1dd92c24 100644 --- a/Sources/MistKit/Service/UserInfo.swift +++ b/Sources/MistKit/Service/UserInfo.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// User information from CloudKit (User Dictionary — returned by users/current and users/lookup/*) +/// User information from CloudKit (User Dictionary — returned by users/caller and users/lookup/*) public struct UserInfo: Encodable, Sendable { /// The user's record name public let userRecordName: String diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift new file mode 100644 index 00000000..e4f0975f --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift @@ -0,0 +1,65 @@ +// +// CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.DiscoverAllUserIdentities { + private static let testAPIToken = TestConstants.apiToken + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeSuccessfulService( + identityCount: Int = 1 + ) async throws -> CloudKitService { + // Reuses the DiscoverResponse fixture builder from DiscoverUserIdentities + // — the response shape is identical for both endpoints. + let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( + identityCount: identityCount + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + apiToken: testAPIToken, + transport: transport + ) + } + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + apiToken: testAPIToken, + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift new file mode 100644 index 00000000..290c2b15 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift @@ -0,0 +1,84 @@ +// +// CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.DiscoverAllUserIdentities { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("discoverAllUserIdentities() returns a single identity") + internal func returnsSingleIdentity() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.DiscoverAllUserIdentities + .makeSuccessfulService(identityCount: 1) + + let identities = try await service.discoverAllUserIdentities() + + #expect(identities.count == 1) + #expect(identities.first?.userRecordName == "_user-0") + } + + @Test("discoverAllUserIdentities() returns multiple identities") + internal func returnsMultipleIdentities() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.DiscoverAllUserIdentities + .makeSuccessfulService(identityCount: 3) + + let identities = try await service.discoverAllUserIdentities() + + #expect(identities.count == 3) + #expect(identities[0].userRecordName == "_user-0") + #expect(identities[1].userRecordName == "_user-1") + #expect(identities[2].userRecordName == "_user-2") + } + + @Test("discoverAllUserIdentities() returns empty array when address book is empty") + internal func returnsEmptyArray() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.DiscoverAllUserIdentities + .makeSuccessfulService(identityCount: 0) + + let identities = try await service.discoverAllUserIdentities() + + #expect(identities.isEmpty) + } + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift new file mode 100644 index 00000000..63e1462d --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift @@ -0,0 +1,52 @@ +// +// CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.DiscoverAllUserIdentities { + @Suite("Validation") + internal struct Validation { + @Test("discoverAllUserIdentities() throws on authentication error") + internal func throwsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.DiscoverAllUserIdentities + .makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.discoverAllUserIdentities() + } + } + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift new file mode 100644 index 00000000..5d0b2719 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift @@ -0,0 +1,41 @@ +// +// CloudKitServiceTests.DiscoverAllUserIdentities.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite( + "CloudKitService DiscoverAllUserIdentities Operations", + .enabled(if: Platform.isCryptoAvailable) + ) + internal enum DiscoverAllUserIdentities {} +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift new file mode 100644 index 00000000..aa342681 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift @@ -0,0 +1,65 @@ +// +// CloudKitServiceTests.LookupUsersByEmail+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByEmail { + private static let testAPIToken = TestConstants.apiToken + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeSuccessfulService( + identityCount: Int = 1 + ) async throws -> CloudKitService { + // The endpoint returns the same `DiscoverResponse` shape — reuse the + // fixture builder from the DiscoverUserIdentities test helpers. + let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( + identityCount: identityCount + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + apiToken: testAPIToken, + transport: transport + ) + } + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + apiToken: testAPIToken, + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift new file mode 100644 index 00000000..4d03e826 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift @@ -0,0 +1,85 @@ +// +// CloudKitServiceTests.LookupUsersByEmail+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByEmail { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("lookupUsersByEmail() returns a single identity") + internal func returnsSingleIdentity() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeSuccessfulService(identityCount: 1) + + let identities = try await service.lookupUsersByEmail(["user@example.com"]) + + #expect(identities.count == 1) + #expect(identities.first?.userRecordName == "_user-0") + } + + @Test("lookupUsersByEmail() returns multiple identities") + internal func returnsMultipleIdentities() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeSuccessfulService(identityCount: 3) + + let identities = try await service.lookupUsersByEmail([ + "a@example.com", + "b@example.com", + "c@example.com", + ]) + + #expect(identities.count == 3) + } + + @Test("lookupUsersByEmail() returns empty array when no matches") + internal func returnsEmptyArray() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeSuccessfulService(identityCount: 0) + + let identities = try await service.lookupUsersByEmail(["unknown@example.com"]) + + #expect(identities.isEmpty) + } + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Validation.swift new file mode 100644 index 00000000..aa24cac1 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Validation.swift @@ -0,0 +1,52 @@ +// +// CloudKitServiceTests.LookupUsersByEmail+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByEmail { + @Suite("Validation") + internal struct Validation { + @Test("lookupUsersByEmail() throws on authentication error") + internal func throwsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByEmail + .makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.lookupUsersByEmail(["user@example.com"]) + } + } + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail.swift new file mode 100644 index 00000000..6fa2fcf6 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail.swift @@ -0,0 +1,41 @@ +// +// CloudKitServiceTests.LookupUsersByEmail.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite( + "CloudKitService LookupUsersByEmail Operations", + .enabled(if: Platform.isCryptoAvailable) + ) + internal enum LookupUsersByEmail {} +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift new file mode 100644 index 00000000..007ef0e0 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift @@ -0,0 +1,63 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByRecordName { + private static let testAPIToken = TestConstants.apiToken + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeSuccessfulService( + identityCount: Int = 1 + ) async throws -> CloudKitService { + let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( + identityCount: identityCount + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + apiToken: testAPIToken, + transport: transport + ) + } + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + apiToken: testAPIToken, + transport: transport + ) + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift new file mode 100644 index 00000000..dd620b66 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift @@ -0,0 +1,83 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByRecordName { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("lookupUsersByRecordName() returns a single identity") + internal func returnsSingleIdentity() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeSuccessfulService(identityCount: 1) + + let identities = try await service.lookupUsersByRecordName(["_user-0"]) + + #expect(identities.count == 1) + #expect(identities.first?.userRecordName == "_user-0") + } + + @Test("lookupUsersByRecordName() returns multiple identities") + internal func returnsMultipleIdentities() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeSuccessfulService(identityCount: 3) + + let identities = try await service.lookupUsersByRecordName([ + "_user-0", "_user-1", "_user-2", + ]) + + #expect(identities.count == 3) + } + + @Test("lookupUsersByRecordName() returns empty array when no matches") + internal func returnsEmptyArray() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeSuccessfulService(identityCount: 0) + + let identities = try await service.lookupUsersByRecordName(["_user-unknown"]) + + #expect(identities.isEmpty) + } + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Validation.swift new file mode 100644 index 00000000..cfccf5c6 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Validation.swift @@ -0,0 +1,52 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.LookupUsersByRecordName { + @Suite("Validation") + internal struct Validation { + @Test("lookupUsersByRecordName() throws on authentication error") + internal func throwsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.LookupUsersByRecordName + .makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.lookupUsersByRecordName(["_user-0"]) + } + } + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName.swift new file mode 100644 index 00000000..8bd53028 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName.swift @@ -0,0 +1,41 @@ +// +// CloudKitServiceTests.LookupUsersByRecordName.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + @Suite( + "CloudKitService LookupUsersByRecordName Operations", + .enabled(if: Platform.isCryptoAvailable) + ) + internal enum LookupUsersByRecordName {} +} diff --git a/openapi.yaml b/openapi.yaml index 23efc408..9d1688a1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -522,11 +522,15 @@ paths: '401': $ref: '#/components/responses/Unauthorized' - /database/{version}/{container}/{environment}/{database}/users/current: + /database/{version}/{container}/{environment}/{database}/users/caller: get: - summary: Get Current User - description: Fetch the current authenticated user's information - operationId: getCurrentUser + summary: Get the Caller (Current User) + description: | + Fetch the authenticated caller's user information. This replaces the deprecated + `users/current` endpoint. Requires public database with a web-auth token + (user-context auth); server-to-server credentials and the private database + will be rejected with `BAD_REQUEST: endpoint not applicable in the database type`. + operationId: getCaller tags: - Users parameters: @@ -605,6 +609,113 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + get: + summary: Discover All User Identities + description: | + Fetch every user identity in the caller's CloudKit address book. + Requires public-database routing with web-auth credentials (user-context + auth); only users who have run the app and granted discoverability are + returned. + operationId: discoverAllUserIdentities + tags: + - Users + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + responses: + '200': + description: All discoverable user identities returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /database/{version}/{container}/{environment}/{database}/users/lookup/email: + post: + summary: Lookup Users by Email + description: | + Look up user identities by email address. Requires public-database + routing with web-auth credentials (user-context auth). Each requested + email returns at most one identity in the `users` array. + operationId: lookupUsersByEmail + tags: + - Users + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + type: object + properties: + emailAddress: + type: string + responses: + '200': + description: User identities returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /database/{version}/{container}/{environment}/{database}/users/lookup/id: + post: + summary: Lookup Users by Record Name + description: | + Look up user identities by record name (CloudKit user record ID). + Requires public-database routing with web-auth credentials. + operationId: lookupUsersByRecordName + tags: + - Users + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + type: object + properties: + userRecordName: + type: string + responses: + '200': + description: User identities returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' /database/{version}/{container}/{environment}/{database}/users/lookup/contacts: post: From 4c809bd63081f05a6a84acac37cc3a4516821cbe Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 8 May 2026 14:14:08 -0400 Subject: [PATCH 02/11] #312 MistDemo: separate database from authentication and add user-context phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors MistDemo's CloudKit configuration model and integration runner to support the public+web-auth combination required by the user-identity endpoints landed in the prior commit. **Configuration refactor.** Replaces the `DatabaseCredentials` enum (which coupled database choice to a single auth method per case, baking in a public⇒S2S/private⇒webAuth assumption) with two orthogonal types: - `AuthenticationCredentials` — `serverToServer(keyID:privateKey:)` / `webAuth(apiToken:webAuthToken:)` - `DatabaseConfiguration` — pairs a `MistKit.Database` with an `AuthenticationCredentials`. The `make(database:authentication:)` factory rejects private+S2S and shared+S2S (which CloudKit rejects) so invalid combinations remain unrepresentable, while public+webAuth is now a valid construction. `MistKitClientFactory.create(for:)` consumes `toPrimaryConfiguration()`; the new `createUserContext(for:)` returns the optional public+web-auth service from `toUserContextConfiguration()` when web-auth tokens are configured. **Phase plumbing.** `PhaseContext` and `IntegrationTestRunner` now thread an optional `userContextService: CloudKitService?`. `PublicDatabaseTest` takes `includeUserContextPhases:` and conditionally appends the new user-identity phases: - `FetchCallerPhase` (renamed from `FetchCurrentUserPhase`) - `DiscoverUserIdentitiesPhase` (existed; updated to use userContextService) - `DiscoverAllUserIdentitiesPhase` (#28) - `LookupUsersByEmailPhase` (#34) - `LookupUsersByRecordNamePhase` (#35) `PrivateDatabaseTest` no longer includes `FetchCurrentUserPhase`: CloudKit rejects `users/caller` against the private database, matching the rest of the user-identity family. **Call-site updates.** `CurrentUserCommand` and `DemoErrorsRunner` swap `fetchCurrentUser()` → `fetchCaller()`. `TestIntegrationCommand` and `TestPrivateCommand` now build and pass `userContextService`. Tests for `AuthenticationCredentials`, `DatabaseConfiguration.make` validation, and `MistDemoConfig.toPrimaryConfiguration` / `toUserContextConfiguration` ship alongside. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CloudKit/MistKitClientFactory.swift | 46 ++++++- .../Commands/CurrentUserCommand.swift | 2 +- .../Commands/DemoErrorsRunner.swift | 2 +- .../Commands/TestIntegrationCommand.swift | 4 + .../Commands/TestPrivateCommand.swift | 3 + .../AuthenticationCredentials.swift | 43 +++--- .../Configuration/ConfigurationError.swift | 5 + .../Configuration/DatabaseConfiguration.swift | 64 +++++++++ ...MistDemoConfig+DatabaseConfiguration.swift | 51 +++++--- .../Integration/IntegrationTestError.swift | 5 + .../Integration/IntegrationTestRunner.swift | 11 +- .../Integration/PhaseContext.swift | 4 + .../DiscoverAllUserIdentitiesPhase.swift | 69 ++++++++++ .../Phases/DiscoverUserIdentitiesPhase.swift | 11 +- .../Integration/Phases/FetchCallerPhase.swift | 24 +++- .../Phases/LookupUsersByEmailPhase.swift | 71 ++++++++++ .../Phases/LookupUsersByRecordNamePhase.swift | 66 ++++++++++ .../Tests/PrivateDatabaseTest.swift | 10 +- .../Tests/PublicDatabaseTest.swift | 42 ++++-- ...tionCredentialsTests+ToConfiguration.swift | 91 +++++++++---- .../AuthenticationCredentialsTests.swift | 122 ++++++++++-------- 21 files changed, 598 insertions(+), 148 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift index 5a605322..212e23b6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -62,13 +62,33 @@ public struct MistKitClientFactory: Sendable { } return try create(from: config, tokenManager: makeBadCredentialsTokenManager()) } - let credentials = try config.toDatabaseCredentials() - let tokenManager = try credentials.makeTokenManager() - return try CloudKitService( + let configuration = try config.toPrimaryConfiguration() + return try makeService( containerIdentifier: config.containerIdentifier, - tokenManager: tokenManager, environment: config.environment, - database: credentials.database + configuration: configuration + ) + #endif + } + + /// Create the optional public+web-auth service used for user-context endpoints + /// (`users/caller`, `users/discover`, `users/lookup/*`). + /// + /// Returns `nil` when web-auth credentials are not configured, so callers can + /// gracefully skip user-identity coverage in integration runs. + public static func createUserContext( + for config: MistDemoConfig + ) throws -> CloudKitService? { + #if os(WASI) + return nil + #else + guard let configuration = config.toUserContextConfiguration() else { + return nil + } + return try makeService( + containerIdentifier: config.containerIdentifier, + environment: config.environment, + configuration: configuration ) #endif } @@ -106,4 +126,20 @@ public struct MistKitClientFactory: Sendable { ) #endif } + + #if !os(WASI) + private static func makeService( + containerIdentifier: String, + environment: MistKit.Environment, + configuration: DatabaseConfiguration + ) throws -> CloudKitService { + let tokenManager = try configuration.authentication.makeTokenManager() + return try CloudKitService( + containerIdentifier: containerIdentifier, + tokenManager: tokenManager, + environment: environment, + database: configuration.database + ) + } + #endif } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift index 529a5dcf..a129a1c1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift @@ -73,7 +73,7 @@ public struct CurrentUserCommand: MistDemoCommand, OutputFormatting { let client = try MistKitClientFactory.create(for: config.base) // Fetch current user information - let userInfo = try await client.fetchCurrentUser() + let userInfo = try await client.fetchCaller() // Filter fields if requested let filteredUser = filterUserFields(userInfo, fields: config.fields) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift index ddab742b..f16c492c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift @@ -74,7 +74,7 @@ internal struct DemoErrorsRunner { from: config.with(database: .private), tokenManager: badTokenManager ) - _ = try await service.fetchCurrentUser() + _ = try await service.fetchCaller() print("⚠️ Expected 401 but call succeeded — credentials may not be validated server-side.") } catch let error as CloudKitError { printCloudKitError(error, expectedStatus: 401) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift index 225386f8..3a77d64c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift @@ -75,9 +75,13 @@ public struct TestIntegrationCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { let service = try MistKitClientFactory.create(for: config.base) + let userContextService = try MistKitClientFactory.createUserContext( + for: config.base + ) let runner = IntegrationTestRunner( service: service, + userContextService: userContextService, containerIdentifier: config.base.containerIdentifier, database: config.base.database, recordCount: config.recordCount, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index afcd5195..e205e50f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -76,9 +76,12 @@ public struct TestPrivateCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { let service = try MistKitClientFactory.create(for: config.base) + // The private-database pipeline already authenticates with web-auth, so the + // primary `service` is sufficient — no separate userContextService is needed. let runner = IntegrationTestRunner( service: service, + userContextService: nil, containerIdentifier: config.base.containerIdentifier, database: .private, recordCount: config.recordCount, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift index bfc001d1..95a5c05d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift @@ -1,5 +1,5 @@ // -// DatabaseCredentials.swift +// AuthenticationCredentials.swift // MistDemo // // Created by Leo Dion. @@ -30,25 +30,23 @@ import Foundation import MistKit -/// A database choice paired with the credentials required to access it. +/// How MistDemo authenticates with CloudKit. /// -/// Bundling these together means a constructed value cannot represent an -/// invalid combination (e.g. `.public` without server-to-server signing -/// credentials), shifting the validation that previously lived in -/// `MistKitClientFactory.create(for:)` into the type system. -internal enum DatabaseCredentials: Sendable { - case publicDatabase(keyID: String, privateKey: PrivateKeyMaterial) - case privateDatabase(apiToken: String, webAuthToken: String) - case sharedDatabase(apiToken: String, webAuthToken: String) - - /// The corresponding `MistKit.Database` for this credentials variant. - internal var database: MistKit.Database { - switch self { - case .publicDatabase: return .public - case .privateDatabase: return .private - case .sharedDatabase: return .shared - } - } +/// Distinct from the database (`MistKit.Database`) the request targets — the +/// public/private/shared database axis and the auth-method axis are orthogonal. +/// CloudKit accepts: +/// +/// - public + server-to-server (CRUD with developer credentials) +/// - public + web-auth (user-context endpoints like `users/caller`, +/// `users/discover`, `users/lookup/*`) +/// - private + web-auth, shared + web-auth (per-user data) +/// +/// Server-to-server signing against the private/shared databases is rejected +/// by Apple, so `DatabaseConfiguration.make(database:authentication:)` +/// validates the combination at construction time. +internal enum AuthenticationCredentials: Sendable { + case serverToServer(keyID: String, privateKey: PrivateKeyMaterial) + case webAuth(apiToken: String, webAuthToken: String) /// Construct the appropriate `TokenManager` for these credentials. /// @@ -56,16 +54,15 @@ internal enum DatabaseCredentials: Sendable { /// from `ServerToServerAuthManager` if the PEM string is malformed. internal func makeTokenManager() throws -> any TokenManager { switch self { - case .publicDatabase(let keyID, let privateKey): + case .serverToServer(let keyID, let privateKey): guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { throw ConfigurationError.unsupportedPlatform( - "Public database access requires macOS 11.0+, iOS 14.0+, tvOS 14.0+, or watchOS 7.0+" + "Server-to-server authentication requires macOS 11.0+, iOS 14.0+, tvOS 14.0+, or watchOS 7.0+" ) } let pem = try privateKey.loadPEM() return try ServerToServerAuthManager(keyID: keyID, pemString: pem) - case .privateDatabase(let apiToken, let webAuthToken), - .sharedDatabase(let apiToken, let webAuthToken): + case .webAuth(let apiToken, let webAuthToken): return WebAuthTokenManager(apiToken: apiToken, webAuthToken: webAuthToken) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift index fa68895c..7c24740a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift @@ -37,6 +37,7 @@ internal enum ConfigurationError: LocalizedError { case missingRequired(String, suggestion: String) case unsupportedPlatform(String) case badCredentialsOnPublicDB + case unsupportedDatabaseAuthCombination(database: String, authentication: String) // MARK: Internal @@ -58,6 +59,10 @@ internal enum ConfigurationError: LocalizedError { "The bad-credentials error demo is only supported on the " + "private and shared databases (it uses web auth). " + "Re-run with `--database private`." + case .unsupportedDatabaseAuthCombination(let database, let authentication): + "Database '\(database)' does not accept '\(authentication)' authentication. " + + "CloudKit allows server-to-server only against the public database; " + + "private and shared databases require web-auth credentials." } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift new file mode 100644 index 00000000..a9aba7ba --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift @@ -0,0 +1,64 @@ +// +// DatabaseConfiguration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// A validated database + authentication pair, ready to construct a +/// `CloudKitService`. +/// +/// Database (`MistKit.Database`) and authentication +/// (`AuthenticationCredentials`) are independent axes — public+S2S, public+web-auth, +/// private+web-auth, and shared+web-auth are all valid CloudKit combinations. +/// Server-to-server signing against the private/shared databases is not, so use +/// `make(database:authentication:)` to construct values; the factory rejects the +/// invalid combination and never produces a misconfigured service. +internal struct DatabaseConfiguration: Sendable { + internal let database: MistKit.Database + internal let authentication: AuthenticationCredentials + + /// Validate the database/authentication pairing and return a configuration. + /// + /// - Throws: `ConfigurationError.unsupportedDatabaseAuthCombination` for + /// private/shared + server-to-server, which CloudKit rejects. + internal static func make( + database: MistKit.Database, + authentication: AuthenticationCredentials + ) throws -> Self { + switch (database, authentication) { + case (.private, .serverToServer), (.shared, .serverToServer): + throw ConfigurationError.unsupportedDatabaseAuthCombination( + database: database.rawValue, + authentication: "serverToServer" + ) + default: + return Self(database: database, authentication: authentication) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift index d496c088..1649aab2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift @@ -1,5 +1,5 @@ // -// MistDemoConfig+DatabaseCredentials.swift +// MistDemoConfig+DatabaseConfiguration.swift // MistDemo // // Created by Leo Dion. @@ -31,22 +31,43 @@ import Foundation import MistKit extension MistDemoConfig { - /// Bundle this config's flat auth fields into a `DatabaseCredentials` value - /// matching `self.database`, validating that the required credentials are - /// present. + /// Build the primary `DatabaseConfiguration` matching `self.database`. + /// + /// - `.public` → server-to-server (requires `keyID` + `privateKey`/`privateKeyFile`) + /// - `.private`, `.shared` → web-auth (requires `apiToken` + `webAuthToken`) /// /// - Throws: `ConfigurationError.missingRequired` if any required field for /// the chosen database is missing or empty. - internal func toDatabaseCredentials() throws -> DatabaseCredentials { + internal func toPrimaryConfiguration() throws -> DatabaseConfiguration { + let auth: AuthenticationCredentials switch database { case .public: - return try toPublicCredentials() + auth = try resolveServerToServerAuth() case .private, .shared: - return try toUserCredentials() + auth = try resolveWebAuth() } + return try DatabaseConfiguration.make( + database: database, + authentication: auth + ) } - private func toPublicCredentials() throws -> DatabaseCredentials { + /// Build a public+web-auth `DatabaseConfiguration` for user-context endpoints + /// (`users/caller`, `users/discover`, `users/lookup/*`). + /// + /// Returns `nil` when web-auth tokens are not available, allowing callers to + /// gracefully skip user-identity coverage instead of failing. + internal func toUserContextConfiguration() -> DatabaseConfiguration? { + guard let auth = try? resolveWebAuth() else { return nil } + return try? DatabaseConfiguration.make( + database: .public, + authentication: auth + ) + } + + // MARK: - Auth resolution helpers + + private func resolveServerToServerAuth() throws -> AuthenticationCredentials { guard let keyID, !keyID.isEmpty else { throw ConfigurationError.missingRequired( "key.id", @@ -54,7 +75,7 @@ extension MistDemoConfig { ) } let material = try resolvePrivateKeyMaterial() - return .publicDatabase(keyID: keyID, privateKey: material) + return .serverToServer(keyID: keyID, privateKey: material) } private func resolvePrivateKeyMaterial() throws -> PrivateKeyMaterial { @@ -69,7 +90,7 @@ extension MistDemoConfig { ) } - private func toUserCredentials() throws -> DatabaseCredentials { + private func resolveWebAuth() throws -> AuthenticationCredentials { let resolvedAPIToken = AuthenticationHelper.resolveAPIToken(apiToken) guard !resolvedAPIToken.isEmpty else { throw ConfigurationError.missingRequired( @@ -86,14 +107,6 @@ extension MistDemoConfig { suggestion: "Provide via CLOUDKIT_WEB_AUTH_TOKEN or run `mistdemo auth-token`" ) } - return database == .private - ? .privateDatabase( - apiToken: resolvedAPIToken, - webAuthToken: resolvedWebAuth - ) - : .sharedDatabase( - apiToken: resolvedAPIToken, - webAuthToken: resolvedWebAuth - ) + return .webAuth(apiToken: resolvedAPIToken, webAuthToken: resolvedWebAuth) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift index 75b13aaa..a68d50bb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift @@ -40,6 +40,7 @@ internal enum IntegrationTestError: LocalizedError, Sendable { case noRecordsCreated case missingWebAuthToken case missingPhaseState(String) + case missingUserContextService(phase: String) internal var errorDescription: String? { switch self { @@ -62,6 +63,10 @@ internal enum IntegrationTestError: LocalizedError, Sendable { "Web auth token is required for private database tests. Run 'mistdemo auth-token' first." case .missingPhaseState(let key): return "Required phase state '\(key)' is missing — preceding phase did not run" + case .missingUserContextService(let phase): + return + "Phase '\(phase)' requires public+web-auth user-context credentials. " + + "Set CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN to enable." } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift index 14844b7c..12f43313 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -34,6 +34,10 @@ import MistKit /// dispatches to the appropriate `PhasedIntegrationTest` implementation. internal struct IntegrationTestRunner { internal let service: CloudKitService + /// Optional public+web-auth service for user-identity endpoints. When `nil`, + /// user-identity phases (FetchCallerPhase, DiscoverUserIdentitiesPhase, etc.) + /// skip with a log message instead of failing the run. + internal let userContextService: CloudKitService? internal let containerIdentifier: String internal let database: MistKit.Database internal let recordCount: Int @@ -43,7 +47,11 @@ internal struct IntegrationTestRunner { /// Run the public-database workflow. internal func runBasicWorkflow() async throws { - try await PublicDatabaseTest(database: database).run(context: makeContext()) + let test = PublicDatabaseTest( + database: database, + includeUserContextPhases: userContextService != nil + ) + try await test.run(context: makeContext()) } /// Run the private-database workflow. @@ -54,6 +62,7 @@ internal struct IntegrationTestRunner { private func makeContext() -> PhaseContext { PhaseContext( service: service, + userContextService: userContextService, containerIdentifier: containerIdentifier, database: database, recordCount: recordCount, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift index cb98568e..678855ef 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift @@ -33,6 +33,10 @@ import MistKit /// Shared dependencies and configuration available to every phase. internal struct PhaseContext: Sendable { internal let service: CloudKitService + /// Optional public+web-auth service for user-identity endpoints + /// (`users/caller`, `users/discover`, `users/lookup/*`). When `nil`, + /// user-identity phases skip with a log message instead of failing. + internal let userContextService: CloudKitService? internal let containerIdentifier: String internal let database: MistKit.Database internal let recordCount: Int diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift new file mode 100644 index 00000000..4403e58b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift @@ -0,0 +1,69 @@ +// +// DiscoverAllUserIdentitiesPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Calls GET `/users/discover` to fetch every discoverable user identity in the +/// caller's CloudKit address book. +/// +/// Requires public-database web-auth (user-context) credentials. +internal struct DiscoverAllUserIdentitiesPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Discover all user identities" + internal static let emoji = "🌐" + internal static let apiName = "discoverAllUserIdentities" + + internal func run( + input: NoState, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let service = context.userContextService else { + throw IntegrationTestError.missingUserContextService(phase: Self.apiName) + } + + let identities = try await service.discoverAllUserIdentities() + + print( + "✅ Found \(identities.count) discoverable user identit" + + "\(identities.count == 1 ? "y" : "ies")" + ) + + if context.verbose { + for identity in identities { + if let name = identity.userRecordName { print(" - \(name)") } + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift index 5c3f74cb..f6edb0c5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift @@ -30,6 +30,11 @@ import Foundation import MistKit +/// Calls POST `/users/discover` to look up specific user identities. +/// +/// Requires public-database web-auth (user-context) credentials, so the phase +/// uses `context.userContextService` and only runs when the runner has wired +/// one in. internal struct DiscoverUserIdentitiesPhase: IntegrationPhase { internal typealias Input = UserInfo internal typealias Output = NoState @@ -43,8 +48,12 @@ internal struct DiscoverUserIdentitiesPhase: IntegrationPhase { ) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") + guard let service = context.userContextService else { + throw IntegrationTestError.missingUserContextService(phase: Self.apiName) + } + let lookupInfos = [UserIdentityLookupInfo(userRecordName: input.userRecordName)] - let identities = try await context.service.discoverUserIdentities(lookupInfos: lookupInfos) + let identities = try await service.discoverUserIdentities(lookupInfos: lookupInfos) print("✅ Discovered \(identities.count) user identit\(identities.count == 1 ? "y" : "ies")") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift index a14c3975..8e5d8e5b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift @@ -1,5 +1,5 @@ // -// FetchCurrentUserPhase.swift +// FetchCallerPhase.swift // MistDemo // // Created by Leo Dion. @@ -30,22 +30,34 @@ import Foundation import MistKit -internal struct FetchCurrentUserPhase: IntegrationPhase { +/// Calls `users/caller`, the user-context endpoint that replaced the deprecated +/// `users/current`. +/// +/// CloudKit only accepts this endpoint against the **public database with +/// web-auth credentials**, so the phase requires `context.userContextService`. +/// The runner only includes the phase when a user-context service is available; +/// if the precondition fails the phase throws a typed error rather than silently +/// hitting the wrong service. +internal struct FetchCallerPhase: IntegrationPhase { internal typealias Input = NoState internal typealias Output = UserInfo - internal static let title = "Fetch current user" + internal static let title = "Fetch caller (current user)" internal static let emoji = "👤" - internal static let apiName = "fetchCurrentUser" + internal static let apiName = "fetchCaller" internal func run( input: NoState, context: PhaseContext ) async throws -> UserInfo { print("\n\(Self.emoji) \(Self.title)") - let userInfo = try await context.service.fetchCurrentUser() + guard let service = context.userContextService else { + throw IntegrationTestError.missingUserContextService(phase: Self.apiName) + } + + let userInfo = try await service.fetchCaller() - print("✅ Current user: \(userInfo.userRecordName)") + print("✅ Caller: \(userInfo.userRecordName)") if context.verbose { if let firstName = userInfo.firstName { print(" First name: \(firstName)") } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift new file mode 100644 index 00000000..cba62595 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift @@ -0,0 +1,71 @@ +// +// LookupUsersByEmailPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Calls POST `/users/lookup/email` with the caller's own email (when known) to +/// exercise the endpoint without depending on third-party data. +internal struct LookupUsersByEmailPhase: IntegrationPhase { + internal typealias Input = UserInfo + internal typealias Output = NoState + + internal static let title = "Lookup users by email" + internal static let emoji = "📧" + internal static let apiName = "lookupUsersByEmail" + + internal func run( + input: UserInfo, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let service = context.userContextService else { + throw IntegrationTestError.missingUserContextService(phase: Self.apiName) + } + + guard let email = input.emailAddress, !email.isEmpty else { + print( + "⏭️ Skipping — caller's email address is not available; cannot self-lookup." + ) + return NoState() + } + + let identities = try await service.lookupUsersByEmail([email]) + + print("✅ Looked up \(identities.count) identit\(identities.count == 1 ? "y" : "ies") by email") + + if context.verbose { + for identity in identities { + if let name = identity.userRecordName { print(" - \(name)") } + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift new file mode 100644 index 00000000..a8b785d3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift @@ -0,0 +1,66 @@ +// +// LookupUsersByRecordNamePhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import MistKit + +/// Calls POST `/users/lookup/id` with the caller's own user record name to +/// exercise the endpoint via a self-lookup. +internal struct LookupUsersByRecordNamePhase: IntegrationPhase { + internal typealias Input = UserInfo + internal typealias Output = NoState + + internal static let title = "Lookup users by record name" + internal static let emoji = "🆔" + internal static let apiName = "lookupUsersByRecordName" + + internal func run( + input: UserInfo, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let service = context.userContextService else { + throw IntegrationTestError.missingUserContextService(phase: Self.apiName) + } + + let identities = try await service.lookupUsersByRecordName([input.userRecordName]) + + print( + "✅ Looked up \(identities.count) identit\(identities.count == 1 ? "y" : "ies") by record name" + ) + + if context.verbose { + for identity in identities { + if let name = identity.userRecordName { print(" - \(name)") } + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index b987f36e..009cf911 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -34,10 +34,11 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { internal let name = "Private Database" internal let database: MistKit.Database = .private - // `DiscoverUserIdentitiesPhase` is intentionally absent: CloudKit Web - // Services rejects `/users/discover` on the private database with - // "endpoint not applicable in the database type 'privatedb'", so the - // phase only belongs in a public-database test pipeline. + // User-identity phases (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, + // `users/lookup/*`) are intentionally absent: CloudKit Web Services rejects + // these endpoints on the private database with "endpoint not applicable in + // the database type 'privatedb'". They only belong in a public-database + // pipeline that has access to a public+web-auth `userContextService`. internal let phases: [any IntegrationPhase] = [ ListZonesPhase(), LookupZonePhase(), @@ -51,6 +52,5 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { IncrementalSyncPhase(), FinalVerificationPhase(), CleanupPhase(), - FetchCurrentUserPhase(), ] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index a1a48849..8d3de8a6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -33,23 +33,41 @@ import MistKit internal struct PublicDatabaseTest: PhasedIntegrationTest { internal let name = "Public Database" internal let database: MistKit.Database + internal let phases: [any IntegrationPhase] - internal let phases: [any IntegrationPhase] = [ - LookupZonePhase(), - UploadAssetPhase(), - CreateRecordsPhase(), - QueryRecordsPhase(), - LookupRecordsPhase(), - ModifyRecordsPhase(), - FinalVerificationPhase(), - CleanupPhase(), - ] - - internal init(database: MistKit.Database = .public) { + /// - Parameters: + /// - database: must be `.public`. Defaults to `.public`. + /// - includeUserContextPhases: when `true`, appends user-identity phases + /// (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, `users/lookup/*`) + /// that require a public+web-auth `userContextService`. The runner sets + /// this based on whether web-auth credentials are configured. + internal init( + database: MistKit.Database = .public, + includeUserContextPhases: Bool = false + ) { precondition( database == .public, "PublicDatabaseTest only supports the public database" ) self.database = database + + var phases: [any IntegrationPhase] = [ + LookupZonePhase(), + UploadAssetPhase(), + CreateRecordsPhase(), + QueryRecordsPhase(), + LookupRecordsPhase(), + ModifyRecordsPhase(), + FinalVerificationPhase(), + CleanupPhase(), + ] + if includeUserContextPhases { + phases.append(FetchCallerPhase()) + phases.append(DiscoverUserIdentitiesPhase()) + phases.append(DiscoverAllUserIdentitiesPhase()) + phases.append(LookupUsersByEmailPhase()) + phases.append(LookupUsersByRecordNamePhase()) + } + self.phases = phases } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift index f4c0cc7d..c4a0dbf0 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift @@ -1,5 +1,5 @@ // -// DatabaseCredentialsTests+ToDatabaseCredentials.swift +// AuthenticationCredentialsTests+ToConfiguration.swift // MistDemo // // Created by Leo Dion. @@ -33,16 +33,16 @@ import Testing @testable import MistDemoKit -extension DatabaseCredentialsTests { +extension AuthenticationCredentialsTests { @Suite( - "MistDemoConfig.toDatabaseCredentials", + "MistDemoConfig.toPrimaryConfiguration", .disabled( if: TestPlatform.isWasm32, "MistDemoConfig construction relies on Foundation IO unavailable on WASI" ) ) - internal struct ToDatabaseCredentialsTests { - @Test("public with raw private key produces .publicDatabase with .raw material") + internal struct ToPrimaryConfigurationTests { + @Test("public with raw private key produces .public + .serverToServer with .raw material") internal func publicWithRawKey() async throws { let config = try await MistKitClientFactoryTests.makeConfig( database: .public, @@ -50,9 +50,11 @@ extension DatabaseCredentialsTests { privateKey: MistKitClientFactoryTests.validPrivateKey ) - let creds = try config.toDatabaseCredentials() - guard case .publicDatabase(let keyID, let material) = creds else { - Issue.record("Expected .publicDatabase, got \(creds)") + let configuration = try config.toPrimaryConfiguration() + #expect(configuration.database == .public) + guard case .serverToServer(let keyID, let material) = configuration.authentication + else { + Issue.record("Expected .serverToServer, got \(configuration.authentication)") return } #expect(keyID == "test-key-id") @@ -63,7 +65,7 @@ extension DatabaseCredentialsTests { } } - @Test("public with private key file produces .publicDatabase with .file material") + @Test("public with private key file produces .serverToServer with .file material") internal func publicWithFilePath() throws { let config = MistDemoConfig( containerIdentifier: "iCloud.com.test.App", @@ -85,9 +87,9 @@ extension DatabaseCredentialsTests { badCredentials: false ) - let creds = try config.toDatabaseCredentials() - guard case .publicDatabase(_, let material) = creds else { - Issue.record("Expected .publicDatabase, got \(creds)") + let configuration = try config.toPrimaryConfiguration() + guard case .serverToServer(_, let material) = configuration.authentication else { + Issue.record("Expected .serverToServer, got \(configuration.authentication)") return } if case .file(let path) = material { @@ -106,7 +108,7 @@ extension DatabaseCredentialsTests { ) do { - _ = try config.toDatabaseCredentials() + _ = try config.toPrimaryConfiguration() Issue.record("Expected ConfigurationError.missingRequired") } catch let error as ConfigurationError { if case .missingRequired(let key, _) = error { @@ -125,7 +127,7 @@ extension DatabaseCredentialsTests { ) do { - _ = try config.toDatabaseCredentials() + _ = try config.toPrimaryConfiguration() Issue.record("Expected ConfigurationError.missingRequired") } catch let error as ConfigurationError { if case .missingRequired(let key, _) = error { @@ -136,7 +138,7 @@ extension DatabaseCredentialsTests { } } - @Test("private database resolves into .privateDatabase") + @Test("private database resolves into .private + .webAuth") internal func privateHappyPath() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", @@ -144,16 +146,17 @@ extension DatabaseCredentialsTests { webAuthToken: "web" ) - let creds = try config.toDatabaseCredentials() - if case .privateDatabase(let api, let web) = creds { + let configuration = try config.toPrimaryConfiguration() + #expect(configuration.database == .private) + if case .webAuth(let api, let web) = configuration.authentication { #expect(api == "api") #expect(web == "web") } else { - Issue.record("Expected .privateDatabase, got \(creds)") + Issue.record("Expected .webAuth, got \(configuration.authentication)") } } - @Test("shared database resolves into .sharedDatabase") + @Test("shared database resolves into .shared + .webAuth") internal func sharedHappyPath() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", @@ -161,13 +164,57 @@ extension DatabaseCredentialsTests { webAuthToken: "web" ) - let creds = try config.toDatabaseCredentials() - if case .sharedDatabase(let api, let web) = creds { + let configuration = try config.toPrimaryConfiguration() + #expect(configuration.database == .shared) + if case .webAuth(let api, let web) = configuration.authentication { #expect(api == "api") #expect(web == "web") } else { - Issue.record("Expected .sharedDatabase, got \(creds)") + Issue.record("Expected .webAuth, got \(configuration.authentication)") } } } + + @Suite( + "MistDemoConfig.toUserContextConfiguration", + .disabled( + if: TestPlatform.isWasm32, + "MistDemoConfig construction relies on Foundation IO unavailable on WASI" + ) + ) + internal struct ToUserContextConfigurationTests { + @Test("returns public+webAuth when web-auth tokens are populated") + internal func returnsPublicWebAuthWhenAvailable() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "api", + database: .public, + webAuthToken: "web", + keyID: "k", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let userContext = config.toUserContextConfiguration() + #expect(userContext != nil) + #expect(userContext?.database == .public) + if case .webAuth(let api, let web) = userContext?.authentication { + #expect(api == "api") + #expect(web == "web") + } else { + Issue.record("Expected .webAuth, got \(String(describing: userContext?.authentication))") + } + } + + @Test("returns nil when web-auth tokens are missing") + internal func returnsNilWhenWebAuthMissing() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "", + database: .public, + webAuthToken: nil, + keyID: "k", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + #expect(config.toUserContextConfiguration() == nil) + } + } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift index 3eaf2f04..a90d7214 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift @@ -1,5 +1,5 @@ // -// DatabaseCredentialsTests.swift +// AuthenticationCredentialsTests.swift // MistDemo // // Created by Leo Dion. @@ -33,8 +33,8 @@ import Testing @testable import MistDemoKit -@Suite("DatabaseCredentials") -internal enum DatabaseCredentialsTests { +@Suite("AuthenticationCredentials & DatabaseConfiguration") +internal enum AuthenticationCredentialsTests { @Suite("PrivateKeyMaterial") internal struct PrivateKeyMaterialTests { @Test("loadPEM raw returns content unchanged when no escapes present") @@ -88,72 +88,90 @@ internal enum DatabaseCredentialsTests { } } - @Suite("database getter") - internal struct DatabaseGetterTests { - @Test("publicDatabase returns .public") - internal func publicMapsToPublic() { - let creds = DatabaseCredentials.publicDatabase( - keyID: "k", - privateKey: .raw("pem") - ) - #expect(creds.database == .public) - } - - @Test("privateDatabase returns .private") - internal func privateMapsToPrivate() { - let creds = DatabaseCredentials.privateDatabase( - apiToken: "a", - webAuthToken: "w" - ) - #expect(creds.database == .private) - } - - @Test("sharedDatabase returns .shared") - internal func sharedMapsToShared() { - let creds = DatabaseCredentials.sharedDatabase( - apiToken: "a", - webAuthToken: "w" - ) - #expect(creds.database == .shared) - } - } - @Suite("makeTokenManager") internal struct MakeTokenManagerTests { - @Test("privateDatabase produces a WebAuthTokenManager") - internal func privateProducesWebAuthManager() throws { - let creds = DatabaseCredentials.privateDatabase( + @Test("webAuth produces a WebAuthTokenManager") + internal func webAuthProducesManager() throws { + let auth = AuthenticationCredentials.webAuth( apiToken: "api", webAuthToken: "web" ) - let manager = try creds.makeTokenManager() - #expect(manager is WebAuthTokenManager) - } - - @Test("sharedDatabase produces a WebAuthTokenManager") - internal func sharedProducesWebAuthManager() throws { - let creds = DatabaseCredentials.sharedDatabase( - apiToken: "api", - webAuthToken: "web" - ) - - let manager = try creds.makeTokenManager() + let manager = try auth.makeTokenManager() #expect(manager is WebAuthTokenManager) } @Test( - "publicDatabase with malformed PEM surfaces the auth manager error", + "serverToServer with malformed PEM surfaces the auth manager error", .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) ) - internal func publicWithBadPEMThrows() throws { - let creds = DatabaseCredentials.publicDatabase( + internal func serverToServerWithBadPEMThrows() throws { + let auth = AuthenticationCredentials.serverToServer( keyID: "test-key-id", privateKey: .raw("not-a-real-pem") ) #expect(throws: (any Error).self) { - _ = try creds.makeTokenManager() + _ = try auth.makeTokenManager() + } + } + } + + @Suite("DatabaseConfiguration.make validation") + internal struct DatabaseConfigurationMakeTests { + @Test("public + serverToServer is allowed") + internal func publicWithServerToServerSucceeds() throws { + let configuration = try DatabaseConfiguration.make( + database: .public, + authentication: .serverToServer(keyID: "k", privateKey: .raw("pem")) + ) + #expect(configuration.database == .public) + } + + @Test("public + webAuth is allowed") + internal func publicWithWebAuthSucceeds() throws { + let configuration = try DatabaseConfiguration.make( + database: .public, + authentication: .webAuth(apiToken: "a", webAuthToken: "w") + ) + #expect(configuration.database == .public) + } + + @Test("private + webAuth is allowed") + internal func privateWithWebAuthSucceeds() throws { + let configuration = try DatabaseConfiguration.make( + database: .private, + authentication: .webAuth(apiToken: "a", webAuthToken: "w") + ) + #expect(configuration.database == .private) + } + + @Test("shared + webAuth is allowed") + internal func sharedWithWebAuthSucceeds() throws { + let configuration = try DatabaseConfiguration.make( + database: .shared, + authentication: .webAuth(apiToken: "a", webAuthToken: "w") + ) + #expect(configuration.database == .shared) + } + + @Test("private + serverToServer is rejected") + internal func privateWithServerToServerThrows() throws { + #expect(throws: ConfigurationError.self) { + _ = try DatabaseConfiguration.make( + database: .private, + authentication: .serverToServer(keyID: "k", privateKey: .raw("pem")) + ) + } + } + + @Test("shared + serverToServer is rejected") + internal func sharedWithServerToServerThrows() throws { + #expect(throws: ConfigurationError.self) { + _ = try DatabaseConfiguration.make( + database: .shared, + authentication: .serverToServer(keyID: "k", privateKey: .raw("pem")) + ) } } } From 439d02e25aaabaf8923c42980b5eb6f11c46a960 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 8 May 2026 15:06:38 -0400 Subject: [PATCH 03/11] #312: mark discoverAllUserIdentities() unavailable pending #28 investigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live verification on 2026-05-08 against iCloud.com.brightdigit.MistDemo returned HTTP 500 from Apple's GET /users/discover. The first 12 phases of mistdemo test-integration --verbose run green (the 8 base public+S2S phases plus FetchCallerPhase, DiscoverUserIdentitiesPhase, LookupUsersByEmailPhase, LookupUsersByRecordNamePhase) — only discoverAllUserIdentities fails, blocking phases beyond it. The endpoint is referenced in CloudKitJS but does not appear in Apple's CloudKit Web Services REST documentation. The actual REST shape is still under investigation under #28. Changes: - Marked `CloudKitService.discoverAllUserIdentities()` `@available(*, unavailable, message: ...)` with a pointer to #28. - Removed `DiscoverAllUserIdentitiesPhase` from MistDemo and from `PublicDatabaseTest.phases`. - Removed the `CloudKitServiceDiscoverAllUserIdentities` test directory (the unavailable method cannot be called from Swift code). The OpenAPI definition, generated client, path builder, response processor, Output extension, and Swift wrapper are all retained. Unblocking is a one-line `@available` removal once the correct REST shape is determined under #28. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DiscoverAllUserIdentitiesPhase.swift | 69 --------------- .../Tests/PublicDatabaseTest.swift | 1 - .../CloudKitService+UserOperations.swift | 16 ++++ ...ts.DiscoverAllUserIdentities+Helpers.swift | 65 -------------- ...scoverAllUserIdentities+SuccessCases.swift | 84 ------------------- ...DiscoverAllUserIdentities+Validation.swift | 52 ------------ ...rviceTests.DiscoverAllUserIdentities.swift | 41 --------- 7 files changed, 16 insertions(+), 312 deletions(-) delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift delete mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift delete mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift delete mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift delete mode 100644 Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift deleted file mode 100644 index 4403e58b..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverAllUserIdentitiesPhase.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// DiscoverAllUserIdentitiesPhase.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// Calls GET `/users/discover` to fetch every discoverable user identity in the -/// caller's CloudKit address book. -/// -/// Requires public-database web-auth (user-context) credentials. -internal struct DiscoverAllUserIdentitiesPhase: IntegrationPhase { - internal typealias Input = NoState - internal typealias Output = NoState - - internal static let title = "Discover all user identities" - internal static let emoji = "🌐" - internal static let apiName = "discoverAllUserIdentities" - - internal func run( - input: NoState, context: PhaseContext - ) async throws -> NoState { - print("\n\(Self.emoji) \(Self.title)") - - guard let service = context.userContextService else { - throw IntegrationTestError.missingUserContextService(phase: Self.apiName) - } - - let identities = try await service.discoverAllUserIdentities() - - print( - "✅ Found \(identities.count) discoverable user identit" - + "\(identities.count == 1 ? "y" : "ies")" - ) - - if context.verbose { - for identity in identities { - if let name = identity.userRecordName { print(" - \(name)") } - } - } - - return NoState() - } -} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 8d3de8a6..e9a113f4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -64,7 +64,6 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { if includeUserContextPhases { phases.append(FetchCallerPhase()) phases.append(DiscoverUserIdentitiesPhase()) - phases.append(DiscoverAllUserIdentitiesPhase()) phases.append(LookupUsersByEmailPhase()) phases.append(LookupUsersByRecordNamePhase()) } diff --git a/Sources/MistKit/Service/CloudKitService+UserOperations.swift b/Sources/MistKit/Service/CloudKitService+UserOperations.swift index 54da9256..1118fb32 100644 --- a/Sources/MistKit/Service/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+UserOperations.swift @@ -73,6 +73,22 @@ extension CloudKitService { /// Hits CloudKit's GET `users/discover` endpoint. Requires public-database /// routing with web-auth credentials (user-context auth); only users who have /// run the app and granted discoverability are returned. + /// + /// > Important: Marked `unavailable` until #28 is resolved. Live testing + /// > on 2026-05-08 against `iCloud.com.brightdigit.MistDemo` returned + /// > HTTP 500 from Apple. The GET form of `/users/discover` is referenced + /// > in CloudKitJS but does not appear in Apple's CloudKit Web Services + /// > REST documentation, and the live endpoint did not respond + /// > successfully. The OpenAPI definition, generated client, path + /// > builder, response processor, and Swift wrapper are all in place; + /// > unblocking is a one-line `@available` removal once the correct + /// > REST shape is determined. Tracking: + /// > [#28](https://github.com/brightdigit/MistKit/issues/28). + @available( + *, unavailable, + message: + "Not yet ready: live testing on 2026-05-08 returned HTTP 500 from Apple's GET /users/discover. The REST request shape is still under investigation. See #28." + ) public func discoverAllUserIdentities() async throws(CloudKitError) -> [UserIdentity] { do { let response = try await client.discoverAllUserIdentities( diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift deleted file mode 100644 index e4f0975f..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// CloudKitServiceTests.DiscoverAllUserIdentities+Helpers.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceTests.DiscoverAllUserIdentities { - private static let testAPIToken = TestConstants.apiToken - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeSuccessfulService( - identityCount: Int = 1 - ) async throws -> CloudKitService { - // Reuses the DiscoverResponse fixture builder from DiscoverUserIdentities - // — the response shape is identical for both endpoints. - let responseProvider = try ResponseProvider.successfulDiscoverUserIdentities( - identityCount: identityCount - ) - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, - transport: transport - ) - } - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal static func makeAuthErrorService() async throws -> CloudKitService { - let responseProvider = ResponseProvider.authenticationError() - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( - containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, - transport: transport - ) - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift deleted file mode 100644 index 290c2b15..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// CloudKitServiceTests.DiscoverAllUserIdentities+SuccessCases.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceTests.DiscoverAllUserIdentities { - @Suite("Success Cases") - internal struct SuccessCases { - @Test("discoverAllUserIdentities() returns a single identity") - internal func returnsSingleIdentity() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceTests.DiscoverAllUserIdentities - .makeSuccessfulService(identityCount: 1) - - let identities = try await service.discoverAllUserIdentities() - - #expect(identities.count == 1) - #expect(identities.first?.userRecordName == "_user-0") - } - - @Test("discoverAllUserIdentities() returns multiple identities") - internal func returnsMultipleIdentities() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceTests.DiscoverAllUserIdentities - .makeSuccessfulService(identityCount: 3) - - let identities = try await service.discoverAllUserIdentities() - - #expect(identities.count == 3) - #expect(identities[0].userRecordName == "_user-0") - #expect(identities[1].userRecordName == "_user-1") - #expect(identities[2].userRecordName == "_user-2") - } - - @Test("discoverAllUserIdentities() returns empty array when address book is empty") - internal func returnsEmptyArray() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceTests.DiscoverAllUserIdentities - .makeSuccessfulService(identityCount: 0) - - let identities = try await service.discoverAllUserIdentities() - - #expect(identities.isEmpty) - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift deleted file mode 100644 index 63e1462d..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// CloudKitServiceTests.DiscoverAllUserIdentities+Validation.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceTests.DiscoverAllUserIdentities { - @Suite("Validation") - internal struct Validation { - @Test("discoverAllUserIdentities() throws on authentication error") - internal func throwsOnAuthError() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try await CloudKitServiceTests.DiscoverAllUserIdentities - .makeAuthErrorService() - - await #expect(throws: CloudKitError.self) { - try await service.discoverAllUserIdentities() - } - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift deleted file mode 100644 index 5d0b2719..00000000 --- a/Tests/MistKitTests/Service/CloudKitServiceDiscoverAllUserIdentities/CloudKitServiceTests.DiscoverAllUserIdentities.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// CloudKitServiceTests.DiscoverAllUserIdentities.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension CloudKitServiceTests { - @Suite( - "CloudKitService DiscoverAllUserIdentities Operations", - .enabled(if: Platform.isCryptoAvailable) - ) - internal enum DiscoverAllUserIdentities {} -} From 13eb5bcdc05fd6fd85d0855477c63e3d24022c06 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 8 May 2026 19:52:54 -0400 Subject: [PATCH 04/11] =?UTF-8?q?#315:=20resolve=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20Credentials=20API,=20per-call=20database,=20cascade=20unavai?= =?UTF-8?q?lable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all four review threads on PR #315: - Comment #1 (error wording): removed `unsupportedDatabaseAuthCombination` along with `MistDemo.DatabaseConfiguration`; invalid combos now surface as `CloudKitError.missingCredentials` from the library. - Comment #2 (per-call database): user-identity ops in `CloudKitService+UserOperations` hardcode `.public`; record/zone/asset/sync ops accept `database: Database? = nil` falling back to a service-level default. - Comment #3 (unified credentials): new `Credentials` / `ServerToServerCredentials` / `APICredentials` value types replace the legacy `apiToken:`/`webAuthToken:` initializers. The token manager is selected based on the target database (S2S for `.public`, web-auth for `.private`/`.shared`). Lifted `PrivateKeyMaterial` into the library. - Comment #4 (cascade unavailable): removed `Operations.discoverAllUserIdentities.Output: CloudKitResponseType` conformance entirely; `processDiscoverAllUserIdentitiesResponse` is now `@available(*, unavailable)` with a `fatalError` body. Also migrates ~15 MistKit test helpers and the MistDemo factory to the new Credentials API. Breaking changes (pre-1.0): removed legacy `CloudKitService` initializers taking `apiToken:`/`webAuthToken:`; `CloudKitService.apiToken` is removed, `.database` is now `internal`. Out of scope: per-call `TokenManager` dispatch (would let one service mix S2S-for-public and web-auth-for-user-context). MistDemo still constructs a separate `userContextService` for that scenario. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CloudKit/MistKitClientFactory.swift | 82 ++++----- .../AuthenticationCredentials.swift | 69 -------- .../Configuration/ConfigurationError.swift | 5 - .../Configuration/DatabaseConfiguration.swift | 64 ------- ...MistDemoConfig+DatabaseConfiguration.swift | 59 +++---- ...tionCredentialsTests+ToConfiguration.swift | 86 ++++------ .../AuthenticationCredentialsTests.swift | 134 ++++++--------- .../MistKit/Authentication/Credentials.swift | 92 +++++++++++ .../Authentication}/PrivateKeyMaterial.swift | 30 ++-- Sources/MistKit/Service/CloudKitError.swift | 6 +- .../CloudKitResponseProcessor+Changes.swift | 24 +-- .../CloudKitService+AssetOperations.swift | 13 +- .../CloudKitService+Initialization.swift | 156 +++++++++--------- .../CloudKitService+LookupOperations.swift | 14 +- .../Service/CloudKitService+Operations.swift | 7 +- .../CloudKitService+QueryPagination.swift | 6 +- .../CloudKitService+SyncOperations.swift | 13 +- .../CloudKitService+UserOperations.swift | 22 ++- .../CloudKitService+WriteOperations.swift | 21 ++- .../CloudKitService+ZoneOperations.swift | 30 +++- Sources/MistKit/Service/CloudKitService.swift | 114 ++++++------- ...ons.discoverAllUserIdentities.Output.swift | 62 ------- ...Tests.DiscoverUserIdentities+Helpers.swift | 4 +- ...KitServiceTests.FetchChanges+Helpers.swift | 6 +- ...rviceTests.FetchChanges+SuccessCases.swift | 2 +- ...ServiceTests.FetchChanges+Validation.swift | 2 +- ...erviceTests.FetchZoneChanges+Helpers.swift | 4 +- ...eTests.FetchZoneChanges+SuccessCases.swift | 2 +- ...viceTests.LookupUsersByEmail+Helpers.swift | 4 +- ...ests.LookupUsersByRecordName+Helpers.swift | 4 +- ...dKitServiceTests.LookupZones+Helpers.swift | 4 +- .../CloudKitServiceTests.Query+Helpers.swift | 6 +- ...ServiceTests.QueryPagination+Helpers.swift | 4 +- ...ceTests.QueryPagination+SuccessCases.swift | 2 +- .../CloudKitServiceTests+Helpers.swift | 2 +- .../CloudKitServiceTests.Upload+Helpers.swift | 6 +- ...oudKitServiceTests.Upload+Validation.swift | 2 +- 37 files changed, 524 insertions(+), 639 deletions(-) delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift create mode 100644 Sources/MistKit/Authentication/Credentials.swift rename {Examples/MistDemo/Sources/MistDemoKit/Configuration => Sources/MistKit/Authentication}/PrivateKeyMaterial.swift (66%) delete mode 100644 Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift index 212e23b6..c2b08d13 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -27,27 +27,27 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation public import MistKit -/// Factory for creating MistKit CloudKitService instances from MistDemo configuration +/// Factory for creating MistKit `CloudKitService` instances from MistDemo +/// configuration. public struct MistKitClientFactory: Sendable { - /// Create a CloudKitService for `config.database`, choosing auth method automatically. + /// Create a `CloudKitService` for `config.database`, choosing auth method + /// automatically. /// /// - `.public`: requires `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY[_FILE]` - /// - `.private` / `.shared`: requires `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` + /// - `.private` / `.shared`: requires `CLOUDKIT_API_TOKEN` + + /// `CLOUDKIT_WEB_AUTH_TOKEN` /// - /// When `config.badCredentials == true`, this short-circuits database-based auth - /// selection and returns a service backed by a deliberately invalid web-auth - /// `TokenManager` so the next CloudKit call yields a typed HTTP 401. Because - /// that path always uses web auth, it is **not** supported on `.public` (which - /// requires server-to-server signing) and will throw + /// When `config.badCredentials == true`, this short-circuits and returns a + /// service backed by a deliberately invalid web-auth `TokenManager` so the + /// next CloudKit call yields a typed HTTP 401. Because that path always uses + /// web auth, it is **not** supported on `.public` and will throw /// `ConfigurationError.badCredentialsOnPublicDB`. /// - /// - Parameter config: The base MistDemo configuration. - /// - Throws: `ConfigurationError` if required credentials are - /// missing, or if `badCredentials` is requested with `.public`. - /// - Returns: A configured `CloudKitService` instance. + /// - Throws: `ConfigurationError` if required credentials are missing, or + /// if `badCredentials` is requested with `.public`. public static func create( for config: MistDemoConfig ) throws -> CloudKitService { @@ -62,44 +62,48 @@ public struct MistKitClientFactory: Sendable { } return try create(from: config, tokenManager: makeBadCredentialsTokenManager()) } - let configuration = try config.toPrimaryConfiguration() - return try makeService( + let credentials = try config.toPrimaryCredentials() + return try CloudKitService( containerIdentifier: config.containerIdentifier, + credentials: credentials, environment: config.environment, - configuration: configuration + database: config.database ) #endif } - /// Create the optional public+web-auth service used for user-context endpoints - /// (`users/caller`, `users/discover`, `users/lookup/*`). + /// Create the optional public+web-auth service used for user-context + /// endpoints (`users/caller`, `users/discover`, `users/lookup/*`). + /// + /// Returns `nil` when web-auth credentials are not configured, so callers + /// can gracefully skip user-identity coverage in integration runs. /// - /// Returns `nil` when web-auth credentials are not configured, so callers can - /// gracefully skip user-identity coverage in integration runs. + /// > Note: This returns a separate `CloudKitService` because CloudKit + /// > rejects server-to-server signing for user-identity routes — a single + /// > service that uses S2S auth for record ops cannot also serve those + /// > routes. Per-call token-manager dispatch is a future enhancement. public static func createUserContext( for config: MistDemoConfig ) throws -> CloudKitService? { #if os(WASI) return nil #else - guard let configuration = config.toUserContextConfiguration() else { + guard let credentials = config.toUserContextCredentials() else { return nil } - return try makeService( + return try CloudKitService( containerIdentifier: config.containerIdentifier, + credentials: credentials, environment: config.environment, - configuration: configuration + database: .public ) #endif } /// Build a `WebAuthTokenManager` whose tokens pass `validateCredentials()`'s - /// local format check (64-char hex API token, ≥10-char web-auth token) but are - /// guaranteed to be rejected by Apple's servers, producing a real HTTP 401. - /// - /// Used by both the factory's `badCredentials` short-circuit and by - /// `DemoErrorsRunner.runUnauthorized` so the same definition feeds every - /// 401-demo path. + /// local format check (64-char hex API token, ≥10-char web-auth token) but + /// are guaranteed to be rejected by Apple's servers, producing a real HTTP + /// 401. internal static func makeBadCredentialsTokenManager() -> WebAuthTokenManager { WebAuthTokenManager( apiToken: String(repeating: "0", count: 64), @@ -107,8 +111,8 @@ public struct MistKitClientFactory: Sendable { ) } - /// Create a CloudKitService with a caller-supplied TokenManager, targeting - /// `config.database`. + /// Create a `CloudKitService` with a caller-supplied `TokenManager`, + /// targeting `config.database`. Used by the `--bad-credentials` demo path. public static func create( from config: MistDemoConfig, tokenManager: any TokenManager @@ -126,20 +130,4 @@ public struct MistKitClientFactory: Sendable { ) #endif } - - #if !os(WASI) - private static func makeService( - containerIdentifier: String, - environment: MistKit.Environment, - configuration: DatabaseConfiguration - ) throws -> CloudKitService { - let tokenManager = try configuration.authentication.makeTokenManager() - return try CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: environment, - database: configuration.database - ) - } - #endif } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift deleted file mode 100644 index 95a5c05d..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthenticationCredentials.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// AuthenticationCredentials.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// How MistDemo authenticates with CloudKit. -/// -/// Distinct from the database (`MistKit.Database`) the request targets — the -/// public/private/shared database axis and the auth-method axis are orthogonal. -/// CloudKit accepts: -/// -/// - public + server-to-server (CRUD with developer credentials) -/// - public + web-auth (user-context endpoints like `users/caller`, -/// `users/discover`, `users/lookup/*`) -/// - private + web-auth, shared + web-auth (per-user data) -/// -/// Server-to-server signing against the private/shared databases is rejected -/// by Apple, so `DatabaseConfiguration.make(database:authentication:)` -/// validates the combination at construction time. -internal enum AuthenticationCredentials: Sendable { - case serverToServer(keyID: String, privateKey: PrivateKeyMaterial) - case webAuth(apiToken: String, webAuthToken: String) - - /// Construct the appropriate `TokenManager` for these credentials. - /// - /// - Throws: A `ConfigurationError` (for unsupported platforms) or an error - /// from `ServerToServerAuthManager` if the PEM string is malformed. - internal func makeTokenManager() throws -> any TokenManager { - switch self { - case .serverToServer(let keyID, let privateKey): - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - throw ConfigurationError.unsupportedPlatform( - "Server-to-server authentication requires macOS 11.0+, iOS 14.0+, tvOS 14.0+, or watchOS 7.0+" - ) - } - let pem = try privateKey.loadPEM() - return try ServerToServerAuthManager(keyID: keyID, pemString: pem) - case .webAuth(let apiToken, let webAuthToken): - return WebAuthTokenManager(apiToken: apiToken, webAuthToken: webAuthToken) - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift index 7c24740a..fa68895c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift @@ -37,7 +37,6 @@ internal enum ConfigurationError: LocalizedError { case missingRequired(String, suggestion: String) case unsupportedPlatform(String) case badCredentialsOnPublicDB - case unsupportedDatabaseAuthCombination(database: String, authentication: String) // MARK: Internal @@ -59,10 +58,6 @@ internal enum ConfigurationError: LocalizedError { "The bad-credentials error demo is only supported on the " + "private and shared databases (it uses web auth). " + "Re-run with `--database private`." - case .unsupportedDatabaseAuthCombination(let database, let authentication): - "Database '\(database)' does not accept '\(authentication)' authentication. " - + "CloudKit allows server-to-server only against the public database; " - + "private and shared databases require web-auth credentials." } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift deleted file mode 100644 index a9aba7ba..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseConfiguration.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// DatabaseConfiguration.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import MistKit - -/// A validated database + authentication pair, ready to construct a -/// `CloudKitService`. -/// -/// Database (`MistKit.Database`) and authentication -/// (`AuthenticationCredentials`) are independent axes — public+S2S, public+web-auth, -/// private+web-auth, and shared+web-auth are all valid CloudKit combinations. -/// Server-to-server signing against the private/shared databases is not, so use -/// `make(database:authentication:)` to construct values; the factory rejects the -/// invalid combination and never produces a misconfigured service. -internal struct DatabaseConfiguration: Sendable { - internal let database: MistKit.Database - internal let authentication: AuthenticationCredentials - - /// Validate the database/authentication pairing and return a configuration. - /// - /// - Throws: `ConfigurationError.unsupportedDatabaseAuthCombination` for - /// private/shared + server-to-server, which CloudKit rejects. - internal static func make( - database: MistKit.Database, - authentication: AuthenticationCredentials - ) throws -> Self { - switch (database, authentication) { - case (.private, .serverToServer), (.shared, .serverToServer): - throw ConfigurationError.unsupportedDatabaseAuthCombination( - database: database.rawValue, - authentication: "serverToServer" - ) - default: - return Self(database: database, authentication: authentication) - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift index 1649aab2..f98535f4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift @@ -27,47 +27,47 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit extension MistDemoConfig { - /// Build the primary `DatabaseConfiguration` matching `self.database`. + /// Build `Credentials` for the primary `CloudKitService` targeting + /// `self.database`. /// - /// - `.public` → server-to-server (requires `keyID` + `privateKey`/`privateKeyFile`) - /// - `.private`, `.shared` → web-auth (requires `apiToken` + `webAuthToken`) + /// - `.public`: requires server-to-server material (`keyID` + + /// `privateKey`/`privateKeyFile`). If web-auth env vars are also set, + /// they're populated alongside so the same `Credentials` can back a + /// user-context service. + /// - `.private` / `.shared`: requires `apiToken` + `webAuthToken`. /// /// - Throws: `ConfigurationError.missingRequired` if any required field for /// the chosen database is missing or empty. - internal func toPrimaryConfiguration() throws -> DatabaseConfiguration { - let auth: AuthenticationCredentials + internal func toPrimaryCredentials() throws -> Credentials { switch database { case .public: - auth = try resolveServerToServerAuth() + let s2s = try resolveServerToServerCredentials() + // Optional: also include web-auth so user-context services share creds + let webAuth = try? resolveAPICredentials() + return Credentials(serverToServer: s2s, apiAuth: webAuth) case .private, .shared: - auth = try resolveWebAuth() + let apiAuth = try resolveAPICredentials() + return Credentials(apiAuth: apiAuth) } - return try DatabaseConfiguration.make( - database: database, - authentication: auth - ) } - /// Build a public+web-auth `DatabaseConfiguration` for user-context endpoints - /// (`users/caller`, `users/discover`, `users/lookup/*`). + /// Build `Credentials` carrying public+web-auth material for user-context + /// endpoints (`users/caller`, `users/discover`, `users/lookup/*`). /// - /// Returns `nil` when web-auth tokens are not available, allowing callers to - /// gracefully skip user-identity coverage instead of failing. - internal func toUserContextConfiguration() -> DatabaseConfiguration? { - guard let auth = try? resolveWebAuth() else { return nil } - return try? DatabaseConfiguration.make( - database: .public, - authentication: auth - ) + /// Returns `nil` when API token + web-auth token aren't configured, so + /// callers can gracefully skip user-identity coverage. + internal func toUserContextCredentials() -> Credentials? { + guard let apiAuth = try? resolveAPICredentials() else { return nil } + return Credentials(apiAuth: apiAuth) } - // MARK: - Auth resolution helpers + // MARK: - Resolution helpers - private func resolveServerToServerAuth() throws -> AuthenticationCredentials { + private func resolveServerToServerCredentials() throws -> ServerToServerCredentials { guard let keyID, !keyID.isEmpty else { throw ConfigurationError.missingRequired( "key.id", @@ -75,7 +75,7 @@ extension MistDemoConfig { ) } let material = try resolvePrivateKeyMaterial() - return .serverToServer(keyID: keyID, privateKey: material) + return ServerToServerCredentials(keyID: keyID, privateKey: material) } private func resolvePrivateKeyMaterial() throws -> PrivateKeyMaterial { @@ -90,7 +90,7 @@ extension MistDemoConfig { ) } - private func resolveWebAuth() throws -> AuthenticationCredentials { + private func resolveAPICredentials() throws -> APICredentials { let resolvedAPIToken = AuthenticationHelper.resolveAPIToken(apiToken) guard !resolvedAPIToken.isEmpty else { throw ConfigurationError.missingRequired( @@ -107,6 +107,9 @@ extension MistDemoConfig { suggestion: "Provide via CLOUDKIT_WEB_AUTH_TOKEN or run `mistdemo auth-token`" ) } - return .webAuth(apiToken: resolvedAPIToken, webAuthToken: resolvedWebAuth) + return APICredentials( + apiToken: resolvedAPIToken, + webAuthToken: resolvedWebAuth + ) } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift index c4a0dbf0..68e6239a 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift @@ -35,14 +35,14 @@ import Testing extension AuthenticationCredentialsTests { @Suite( - "MistDemoConfig.toPrimaryConfiguration", + "MistDemoConfig.toPrimaryCredentials", .disabled( if: TestPlatform.isWasm32, "MistDemoConfig construction relies on Foundation IO unavailable on WASI" ) ) - internal struct ToPrimaryConfigurationTests { - @Test("public with raw private key produces .public + .serverToServer with .raw material") + internal struct ToPrimaryCredentialsTests { + @Test("public with raw private key produces serverToServer with .raw material") internal func publicWithRawKey() async throws { let config = try await MistKitClientFactoryTests.makeConfig( database: .public, @@ -50,22 +50,20 @@ extension AuthenticationCredentialsTests { privateKey: MistKitClientFactoryTests.validPrivateKey ) - let configuration = try config.toPrimaryConfiguration() - #expect(configuration.database == .public) - guard case .serverToServer(let keyID, let material) = configuration.authentication - else { - Issue.record("Expected .serverToServer, got \(configuration.authentication)") + let credentials = try config.toPrimaryCredentials() + guard let s2s = credentials.serverToServer else { + Issue.record("Expected serverToServer credentials") return } - #expect(keyID == "test-key-id") - if case .raw = material { + #expect(s2s.keyID == "test-key-id") + if case .raw = s2s.privateKey { // expected } else { - Issue.record("Expected .raw material, got \(material)") + Issue.record("Expected .raw material, got \(s2s.privateKey)") } } - @Test("public with private key file produces .serverToServer with .file material") + @Test("public with private key file produces serverToServer with .file material") internal func publicWithFilePath() throws { let config = MistDemoConfig( containerIdentifier: "iCloud.com.test.App", @@ -87,15 +85,15 @@ extension AuthenticationCredentialsTests { badCredentials: false ) - let configuration = try config.toPrimaryConfiguration() - guard case .serverToServer(_, let material) = configuration.authentication else { - Issue.record("Expected .serverToServer, got \(configuration.authentication)") + let credentials = try config.toPrimaryCredentials() + guard let s2s = credentials.serverToServer else { + Issue.record("Expected serverToServer credentials") return } - if case .file(let path) = material { + if case .file(let path) = s2s.privateKey { #expect(path == "/tmp/fake.pem") } else { - Issue.record("Expected .file material, got \(material)") + Issue.record("Expected .file material, got \(s2s.privateKey)") } } @@ -108,7 +106,7 @@ extension AuthenticationCredentialsTests { ) do { - _ = try config.toPrimaryConfiguration() + _ = try config.toPrimaryCredentials() Issue.record("Expected ConfigurationError.missingRequired") } catch let error as ConfigurationError { if case .missingRequired(let key, _) = error { @@ -127,7 +125,7 @@ extension AuthenticationCredentialsTests { ) do { - _ = try config.toPrimaryConfiguration() + _ = try config.toPrimaryCredentials() Issue.record("Expected ConfigurationError.missingRequired") } catch let error as ConfigurationError { if case .missingRequired(let key, _) = error { @@ -138,7 +136,7 @@ extension AuthenticationCredentialsTests { } } - @Test("private database resolves into .private + .webAuth") + @Test("private database resolves to apiAuth credentials with web-auth token") internal func privateHappyPath() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", @@ -146,17 +144,13 @@ extension AuthenticationCredentialsTests { webAuthToken: "web" ) - let configuration = try config.toPrimaryConfiguration() - #expect(configuration.database == .private) - if case .webAuth(let api, let web) = configuration.authentication { - #expect(api == "api") - #expect(web == "web") - } else { - Issue.record("Expected .webAuth, got \(configuration.authentication)") - } + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer == nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") } - @Test("shared database resolves into .shared + .webAuth") + @Test("shared database resolves to apiAuth credentials with web-auth token") internal func sharedHappyPath() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", @@ -164,27 +158,23 @@ extension AuthenticationCredentialsTests { webAuthToken: "web" ) - let configuration = try config.toPrimaryConfiguration() - #expect(configuration.database == .shared) - if case .webAuth(let api, let web) = configuration.authentication { - #expect(api == "api") - #expect(web == "web") - } else { - Issue.record("Expected .webAuth, got \(configuration.authentication)") - } + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer == nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") } } @Suite( - "MistDemoConfig.toUserContextConfiguration", + "MistDemoConfig.toUserContextCredentials", .disabled( if: TestPlatform.isWasm32, "MistDemoConfig construction relies on Foundation IO unavailable on WASI" ) ) - internal struct ToUserContextConfigurationTests { - @Test("returns public+webAuth when web-auth tokens are populated") - internal func returnsPublicWebAuthWhenAvailable() async throws { + internal struct ToUserContextCredentialsTests { + @Test("returns apiAuth with web-auth when configured") + internal func returnsAPIAuthWhenAvailable() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", database: .public, @@ -193,15 +183,11 @@ extension AuthenticationCredentialsTests { privateKey: MistKitClientFactoryTests.validPrivateKey ) - let userContext = config.toUserContextConfiguration() + let userContext = config.toUserContextCredentials() #expect(userContext != nil) - #expect(userContext?.database == .public) - if case .webAuth(let api, let web) = userContext?.authentication { - #expect(api == "api") - #expect(web == "web") - } else { - Issue.record("Expected .webAuth, got \(String(describing: userContext?.authentication))") - } + #expect(userContext?.apiAuth?.apiToken == "api") + #expect(userContext?.apiAuth?.webAuthToken == "web") + #expect(userContext?.serverToServer == nil) } @Test("returns nil when web-auth tokens are missing") @@ -214,7 +200,7 @@ extension AuthenticationCredentialsTests { privateKey: MistKitClientFactoryTests.validPrivateKey ) - #expect(config.toUserContextConfiguration() == nil) + #expect(config.toUserContextCredentials() == nil) } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift index a90d7214..eef44c7d 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift @@ -33,7 +33,7 @@ import Testing @testable import MistDemoKit -@Suite("AuthenticationCredentials & DatabaseConfiguration") +@Suite("Credentials helpers") internal enum AuthenticationCredentialsTests { @Suite("PrivateKeyMaterial") internal struct PrivateKeyMaterialTests { @@ -71,108 +71,84 @@ internal enum AuthenticationCredentialsTests { #expect(try material.loadPEM() == pem) } - @Test("loadPEM file throws missingRequired when file is unreadable") + @Test("loadPEM file throws when file is unreadable") internal func loadPEMFileMissingThrows() throws { let material = PrivateKeyMaterial.file(path: "/non/existent/key-\(UUID().uuidString).pem") - do { + #expect(throws: (any Error).self) { _ = try material.loadPEM() - Issue.record("Expected ConfigurationError.missingRequired") - } catch let error as ConfigurationError { - if case .missingRequired(let key, _) = error { - #expect(key == "private.key") - } else { - Issue.record("Wrong ConfigurationError case: \(error)") - } } } } - @Suite("makeTokenManager") - internal struct MakeTokenManagerTests { - @Test("webAuth produces a WebAuthTokenManager") - internal func webAuthProducesManager() throws { - let auth = AuthenticationCredentials.webAuth( - apiToken: "api", - webAuthToken: "web" + @Suite("CloudKitService(credentials:database:) validation") + internal struct CredentialsValidationTests { + @Test( + "private + serverToServer-only credentials throws missingCredentials", + .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) + ) + internal func privateRejectsS2SOnly() throws { + let credentials = Credentials( + serverToServer: ServerToServerCredentials( + keyID: "k", + privateKey: .raw(MistKitClientFactoryTests.validPrivateKey) + ) ) - - let manager = try auth.makeTokenManager() - #expect(manager is WebAuthTokenManager) + #expect(throws: CloudKitError.self) { + _ = try CloudKitService( + containerIdentifier: "iCloud.test", + credentials: credentials, + environment: .development, + database: .private + ) + } } @Test( - "serverToServer with malformed PEM surfaces the auth manager error", + "shared + serverToServer-only credentials throws missingCredentials", .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) ) - internal func serverToServerWithBadPEMThrows() throws { - let auth = AuthenticationCredentials.serverToServer( - keyID: "test-key-id", - privateKey: .raw("not-a-real-pem") + internal func sharedRejectsS2SOnly() throws { + let credentials = Credentials( + serverToServer: ServerToServerCredentials( + keyID: "k", + privateKey: .raw(MistKitClientFactoryTests.validPrivateKey) + ) ) - - #expect(throws: (any Error).self) { - _ = try auth.makeTokenManager() + #expect(throws: CloudKitError.self) { + _ = try CloudKitService( + containerIdentifier: "iCloud.test", + credentials: credentials, + environment: .development, + database: .shared + ) } } - } - @Suite("DatabaseConfiguration.make validation") - internal struct DatabaseConfigurationMakeTests { - @Test("public + serverToServer is allowed") - internal func publicWithServerToServerSucceeds() throws { - let configuration = try DatabaseConfiguration.make( - database: .public, - authentication: .serverToServer(keyID: "k", privateKey: .raw("pem")) + @Test("private with apiAuth + webAuthToken constructs successfully") + internal func privateWithWebAuthSucceeds() throws { + let credentials = Credentials( + apiAuth: APICredentials(apiToken: "api", webAuthToken: "web") ) - #expect(configuration.database == .public) - } - - @Test("public + webAuth is allowed") - internal func publicWithWebAuthSucceeds() throws { - let configuration = try DatabaseConfiguration.make( - database: .public, - authentication: .webAuth(apiToken: "a", webAuthToken: "w") + _ = try CloudKitService( + containerIdentifier: "iCloud.test", + credentials: credentials, + environment: .development, + database: .private ) - #expect(configuration.database == .public) } - @Test("private + webAuth is allowed") - internal func privateWithWebAuthSucceeds() throws { - let configuration = try DatabaseConfiguration.make( - database: .private, - authentication: .webAuth(apiToken: "a", webAuthToken: "w") + @Test("public with apiAuth-only constructs successfully") + internal func publicWithAPIOnlySucceeds() throws { + let credentials = Credentials( + apiAuth: APICredentials(apiToken: "api") ) - #expect(configuration.database == .private) - } - - @Test("shared + webAuth is allowed") - internal func sharedWithWebAuthSucceeds() throws { - let configuration = try DatabaseConfiguration.make( - database: .shared, - authentication: .webAuth(apiToken: "a", webAuthToken: "w") + _ = try CloudKitService( + containerIdentifier: "iCloud.test", + credentials: credentials, + environment: .development, + database: .public ) - #expect(configuration.database == .shared) - } - - @Test("private + serverToServer is rejected") - internal func privateWithServerToServerThrows() throws { - #expect(throws: ConfigurationError.self) { - _ = try DatabaseConfiguration.make( - database: .private, - authentication: .serverToServer(keyID: "k", privateKey: .raw("pem")) - ) - } - } - - @Test("shared + serverToServer is rejected") - internal func sharedWithServerToServerThrows() throws { - #expect(throws: ConfigurationError.self) { - _ = try DatabaseConfiguration.make( - database: .shared, - authentication: .serverToServer(keyID: "k", privateKey: .raw("pem")) - ) - } } } } diff --git a/Sources/MistKit/Authentication/Credentials.swift b/Sources/MistKit/Authentication/Credentials.swift new file mode 100644 index 00000000..33b03e26 --- /dev/null +++ b/Sources/MistKit/Authentication/Credentials.swift @@ -0,0 +1,92 @@ +// +// Credentials.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Server-to-server signing credentials for the public CloudKit database. +/// +/// CloudKit accepts server-to-server signing only against the **public** +/// database. Private and shared databases require web-auth credentials. +public struct ServerToServerCredentials: Sendable { + public let keyID: String + public let privateKey: PrivateKeyMaterial + + public init(keyID: String, privateKey: PrivateKeyMaterial) { + self.keyID = keyID + self.privateKey = privateKey + } +} + +/// API-token credentials, optionally augmented with a web-auth token for +/// user-context routes. +/// +/// - `apiToken` alone is sufficient for read access against the public +/// database. +/// - `webAuthToken` is required for any route that operates as a specific +/// user — that includes every user-identity endpoint (`fetchCaller`, +/// `lookupUsersByEmail`, …) and any write/read against the private or +/// shared databases. +public struct APICredentials: Sendable { + public let apiToken: String + public let webAuthToken: String? + + public init(apiToken: String, webAuthToken: String? = nil) { + self.apiToken = apiToken + self.webAuthToken = webAuthToken + } +} + +/// CloudKit credentials for a `CloudKitService`. +/// +/// Holds either set of authentication material — server-to-server (public +/// database only) and/or API/web-auth (any database). At call time +/// `CloudKitService` picks the appropriate token manager based on the +/// operation's database and whether user-context auth is required. +/// +/// Provide both when a single service must hit public-database routes via +/// server-to-server signing **and** user-context routes via web-auth. +public struct Credentials: Sendable { + public let serverToServer: ServerToServerCredentials? + public let apiAuth: APICredentials? + + /// Construct credentials. + /// + /// - Precondition: at least one of `serverToServer` or `apiAuth` must be + /// non-nil. A `Credentials` with neither populated would fail every + /// request with a missing-credentials error. + public init( + serverToServer: ServerToServerCredentials? = nil, + apiAuth: APICredentials? = nil + ) { + precondition( + serverToServer != nil || apiAuth != nil, + "Credentials must include at least one of serverToServer or apiAuth" + ) + self.serverToServer = serverToServer + self.apiAuth = apiAuth + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/PrivateKeyMaterial.swift b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift similarity index 66% rename from Examples/MistDemo/Sources/MistDemoKit/Configuration/PrivateKeyMaterial.swift rename to Sources/MistKit/Authentication/PrivateKeyMaterial.swift index c4eb20b9..6cdd02b9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/PrivateKeyMaterial.swift +++ b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift @@ -1,6 +1,6 @@ // // PrivateKeyMaterial.swift -// MistDemo +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -27,27 +27,29 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation -/// Source of a server-to-server private key — either inline PEM or a path to a `.pem` file. -internal enum PrivateKeyMaterial: Sendable { +/// Source of a server-to-server private key — either inline PEM or a path to a +/// `.pem` file on disk. +/// +/// Used by `ServerToServerCredentials` to defer reading the private key until +/// the credentials are actually consumed by `CloudKitService`. Inline PEM may +/// contain literal `\n` escape sequences (common when stored in environment +/// variables); `loadPEM()` normalizes them to real newlines. +public enum PrivateKeyMaterial: Sendable { case raw(String) case file(path: String) - internal func loadPEM() throws -> String { + /// Resolve the PEM text for this material. + /// + /// - Throws: Any error from the underlying file read when `.file(path:)` is + /// used (e.g. file not found, permission denied). + public func loadPEM() throws -> String { switch self { case .raw(let pem): return pem.replacingOccurrences(of: "\\n", with: "\n") case .file(let path): - do { - return try String(contentsOfFile: path, encoding: .utf8) - } catch { - throw ConfigurationError.missingRequired( - "private.key", - suggestion: - "Failed to read private key from '\(path)': \(error.localizedDescription)" - ) - } + return try String(contentsOfFile: path, encoding: .utf8) } } } diff --git a/Sources/MistKit/Service/CloudKitError.swift b/Sources/MistKit/Service/CloudKitError.swift index 713ab834..3eae5d09 100644 --- a/Sources/MistKit/Service/CloudKitError.swift +++ b/Sources/MistKit/Service/CloudKitError.swift @@ -45,6 +45,7 @@ public enum CloudKitError: LocalizedError, Sendable { case networkError(URLError) case unsupportedOperationType(String) case paginationLimitExceeded(maxPages: Int, recordsCollected: Int) + case missingCredentials(database: Database, reason: String) /// HTTP status code if this error originated from an HTTP response, otherwise nil. public var httpStatusCode: Int? { @@ -54,7 +55,7 @@ public enum CloudKitError: LocalizedError, Sendable { .httpErrorWithRawResponse(let statusCode, _): return statusCode case .invalidResponse, .underlyingError, .decodingError, .networkError, - .unsupportedOperationType, .paginationLimitExceeded: + .unsupportedOperationType, .paginationLimitExceeded, .missingCredentials: return nil } } @@ -124,6 +125,9 @@ public enum CloudKitError: LocalizedError, Sendable { return "CloudKit query exceeded pagination limit of \(maxPages) pages " + "(collected \(recordsCollected) records)" + case .missingCredentials(let database, let reason): + return + "Missing credentials for database '\(database.rawValue)': \(reason)" } } } diff --git a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift index 4a75493b..2034991b 100644 --- a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift +++ b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift @@ -74,24 +74,18 @@ extension CloudKitResponseProcessor { } } - /// Process discoverAllUserIdentities response + /// Process discoverAllUserIdentities response. + /// + /// Marked unavailable in lockstep with `CloudKitService.discoverAllUserIdentities()`. + /// The body is intentionally a `fatalError`: the only caller is itself + /// `@available(*, unavailable)`, so this code is unreachable. When #28 is + /// resolved, restore the protocol-generic implementation and re-add the + /// `CloudKitResponseType` conformance for `Operations.discoverAllUserIdentities.Output`. + @available(*, unavailable, message: "Pending #28: discoverAllUserIdentities is not yet ready.") internal func processDiscoverAllUserIdentitiesResponse( _ response: Operations.discoverAllUserIdentities.Output ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { - if let error = CloudKitError(response) { - throw error - } - switch response { - case .ok(let okResponse): - switch okResponse.body { - case .json(let discoverData): - return discoverData - } - default: - // Should never reach here since all errors are handled above - assertionFailure("Unexpected response case after error handling") - throw CloudKitError.invalidResponse - } + fatalError("discoverAllUserIdentities is not yet ready (pending #28)") } /// Process lookupUsersByEmail response diff --git a/Sources/MistKit/Service/CloudKitService+AssetOperations.swift b/Sources/MistKit/Service/CloudKitService+AssetOperations.swift index c1bef11f..e17911bf 100644 --- a/Sources/MistKit/Service/CloudKitService+AssetOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+AssetOperations.swift @@ -74,7 +74,8 @@ extension CloudKitService { recordType: String, fieldName: String, recordName: String? = nil, - using uploader: AssetUploader? = nil + using uploader: AssetUploader? = nil, + database: Database? = nil ) async throws(CloudKitError) -> AssetUploadReceipt { let maxSize: Int = 15 * 1_024 * 1_024 guard data.count <= maxSize else { @@ -96,7 +97,8 @@ extension CloudKitService { let urlToken = try await requestAssetUploadURL( recordType: recordType, fieldName: fieldName, - recordName: recordName + recordName: recordName, + database: database ) guard let uploadURL = urlToken.url else { @@ -135,8 +137,10 @@ extension CloudKitService { recordType: String, fieldName: String, recordName: String? = nil, - zoneID: ZoneID? = nil + zoneID: ZoneID? = nil, + database: Database? = nil ) async throws(CloudKitError) -> AssetUploadToken { + let effectiveDatabase = database ?? self.database do { let tokenRequest = Operations.uploadAssets.Input.Body @@ -153,7 +157,8 @@ extension CloudKitService { let response = try await client.uploadAssets( path: createUploadAssetsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: effectiveDatabase ), body: .json(requestBody) ) diff --git a/Sources/MistKit/Service/CloudKitService+Initialization.swift b/Sources/MistKit/Service/CloudKitService+Initialization.swift index 199ae870..4a69a68d 100644 --- a/Sources/MistKit/Service/CloudKitService+Initialization.swift +++ b/Sources/MistKit/Service/CloudKitService+Initialization.swift @@ -27,76 +27,61 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation public import OpenAPIRuntime #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif -// MARK: - Generic Initializers (All Platforms) +// MARK: - Credentials-based Initializer (All Platforms) @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { - /// Initialize CloudKit service with web authentication - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + /// Initialize CloudKit service with `Credentials`. + /// + /// Accepts any combination of `serverToServer` and `apiAuth` material. + /// The token manager is selected based on the target `database` and what + /// credentials are populated: + /// + /// - `.public`: prefers `serverToServer`. Falls back to `apiAuth` (web-auth + /// if `webAuthToken` is set, otherwise API-token-only). + /// - `.private` / `.shared`: requires `apiAuth.webAuthToken`. CloudKit + /// rejects server-to-server signing for these databases, so any + /// `serverToServer` credential is ignored on this code path. + /// + /// - Throws: `CloudKitError.missingCredentials` when the target `database` + /// cannot be served by any populated credential set, or any error from + /// `ServerToServerAuthManager.init` when the PEM is malformed. public init( containerIdentifier: String, - apiToken: String, - webAuthToken: String, + credentials: Credentials, environment: Environment = .development, - database: Database = .private, + database: Database = .public, transport: any ClientTransport ) throws { - self.containerIdentifier = containerIdentifier - self.apiToken = apiToken - self.environment = environment - self.database = database - - let config = MistKitConfiguration( - container: containerIdentifier, - environment: environment, - database: database, - apiToken: apiToken, - webAuthToken: webAuthToken + let tokenManager = try Self.makeTokenManager( + credentials: credentials, + database: database ) - self.mistKitClient = try MistKitClient( - configuration: config, - transport: transport - ) - } - /// Initialize CloudKit service with API-only authentication - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - public init( - containerIdentifier: String, - apiToken: String, - environment: Environment = .development, - database: Database = .public, - transport: any ClientTransport - ) throws { self.containerIdentifier = containerIdentifier - self.apiToken = apiToken self.environment = environment self.database = database - let config = MistKitConfiguration( + self.mistKitClient = try MistKitClient( container: containerIdentifier, environment: environment, database: database, - apiToken: apiToken, - webAuthToken: nil, - keyID: nil, - privateKeyData: nil - ) - self.mistKitClient = try MistKitClient( - configuration: config, + tokenManager: tokenManager, transport: transport ) } - /// Initialize CloudKit service with a custom TokenManager - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + /// Initialize CloudKit service with a caller-supplied `TokenManager`. + /// + /// Useful for tests and bespoke auth setups where the standard + /// `Credentials`-driven token-manager selection isn't appropriate. public init( containerIdentifier: String, tokenManager: any TokenManager, @@ -105,7 +90,6 @@ extension CloudKitService { transport: any ClientTransport ) throws { self.containerIdentifier = containerIdentifier - self.apiToken = "" // Not used when providing TokenManager directly self.environment = environment self.database = database @@ -117,59 +101,81 @@ extension CloudKitService { transport: transport ) } + + internal static func makeTokenManager( + credentials: Credentials, + database: Database + ) throws -> any TokenManager { + switch database { + case .public: + if let s2s = credentials.serverToServer { + let pem = try s2s.privateKey.loadPEM() + return try ServerToServerAuthManager( + keyID: s2s.keyID, + pemString: pem + ) + } + if let api = credentials.apiAuth { + if let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + return try APITokenManager(apiToken: api.apiToken) + } + throw CloudKitError.missingCredentials( + database: .public, + reason: "expected serverToServer or apiAuth credentials" + ) + case .private, .shared: + guard let api = credentials.apiAuth, let webAuthToken = api.webAuthToken else { + throw CloudKitError.missingCredentials( + database: database, + reason: + "private and shared databases require apiAuth with a webAuthToken" + ) + } + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + } } // MARK: - URLSession Convenience Initializers (Non-WASI Platforms) #if !os(WASI) - import OpenAPIURLSession + internal import OpenAPIURLSession @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { - /// Initialize CloudKit service with web authentication using default URLSessionTransport - /// - /// This convenience initializer is only available on platforms that support URLSession. - /// For WASI builds, use the generic initializer that accepts a transport parameter. - public init( - containerIdentifier: String, - apiToken: String, - webAuthToken: String, - environment: Environment = .development, - database: Database = .private - ) throws { - try self.init( - containerIdentifier: containerIdentifier, - apiToken: apiToken, - webAuthToken: webAuthToken, - environment: environment, - database: database, - transport: URLSessionTransport() - ) - } - - /// Initialize CloudKit service with API-only authentication using default URLSessionTransport + /// Initialize CloudKit service with `Credentials` using default + /// `URLSessionTransport`. /// - /// This convenience initializer is only available on platforms that support URLSession. - /// For WASI builds, use the generic initializer that accepts a transport parameter. + /// Available on platforms that support URLSession. For WASI builds, use + /// the generic initializer that accepts a transport parameter. public init( containerIdentifier: String, - apiToken: String, + credentials: Credentials, environment: Environment = .development, database: Database = .public ) throws { try self.init( containerIdentifier: containerIdentifier, - apiToken: apiToken, + credentials: credentials, environment: environment, database: database, transport: URLSessionTransport() ) } - /// Initialize CloudKit service with a custom TokenManager using default URLSessionTransport + /// Initialize CloudKit service with a custom `TokenManager` using default + /// `URLSessionTransport`. /// - /// This convenience initializer is only available on platforms that support URLSession. - /// For WASI builds, use the generic initializer that accepts a transport parameter. + /// Available on platforms that support URLSession. For WASI builds, use + /// the generic initializer that accepts a transport parameter. public init( containerIdentifier: String, tokenManager: any TokenManager, diff --git a/Sources/MistKit/Service/CloudKitService+LookupOperations.swift b/Sources/MistKit/Service/CloudKitService+LookupOperations.swift index 69631db3..8b88ef70 100644 --- a/Sources/MistKit/Service/CloudKitService+LookupOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+LookupOperations.swift @@ -38,13 +38,16 @@ extension CloudKitService { ) internal func modifyRecords( operations: [Components.Schemas.RecordOperation], - atomic: Bool = true + atomic: Bool = true, + database: Database? = nil ) async throws(CloudKitError) -> [RecordInfo] { + let effectiveDatabase = database ?? self.database do { let response = try await client.modifyRecords( .init( path: createModifyRecordsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: effectiveDatabase ), body: .json( .init( @@ -66,13 +69,16 @@ extension CloudKitService { /// Lookup records by record names public func lookupRecords( recordNames: [String], - desiredKeys: [String]? = nil + desiredKeys: [String]? = nil, + database: Database? = nil ) async throws(CloudKitError) -> [RecordInfo] { + let effectiveDatabase = database ?? self.database do { let response = try await client.lookupRecords( .init( path: createLookupRecordsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: effectiveDatabase ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService+Operations.swift b/Sources/MistKit/Service/CloudKitService+Operations.swift index ff66efc2..4150906a 100644 --- a/Sources/MistKit/Service/CloudKitService+Operations.swift +++ b/Sources/MistKit/Service/CloudKitService+Operations.swift @@ -146,9 +146,11 @@ extension CloudKitService { sortBy: [QuerySort]? = nil, limit: Int? = nil, desiredKeys: [String]? = nil, - continuationMarker: String? = nil + continuationMarker: String? = nil, + database: Database? = nil ) async throws(CloudKitError) -> QueryResult { let effectiveLimit = limit ?? defaultQueryLimit + let effectiveDatabase = database ?? self.database guard !recordType.isEmpty else { throw CloudKitError.httpErrorWithRawResponse( @@ -176,7 +178,8 @@ extension CloudKitService { let response = try await client.queryRecords( .init( path: createQueryRecordsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: effectiveDatabase ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService+QueryPagination.swift b/Sources/MistKit/Service/CloudKitService+QueryPagination.swift index 6926b8da..2c780f9e 100644 --- a/Sources/MistKit/Service/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/Service/CloudKitService+QueryPagination.swift @@ -58,7 +58,8 @@ extension CloudKitService { sortBy: [QuerySort]? = nil, pageSize: Int? = nil, desiredKeys: [String]? = nil, - maxPages: Int = 1_000 + maxPages: Int = 1_000, + database: Database? = nil ) async throws(CloudKitError) -> [RecordInfo] { var allRecords: [RecordInfo] = [] var currentMarker: String? @@ -84,7 +85,8 @@ extension CloudKitService { sortBy: sortBy, limit: pageSize, desiredKeys: desiredKeys, - continuationMarker: currentMarker + continuationMarker: currentMarker, + database: database ) // Stuck-marker detection diff --git a/Sources/MistKit/Service/CloudKitService+SyncOperations.swift b/Sources/MistKit/Service/CloudKitService+SyncOperations.swift index 9086840c..7c7218e7 100644 --- a/Sources/MistKit/Service/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+SyncOperations.swift @@ -80,8 +80,10 @@ extension CloudKitService { public func fetchRecordChanges( zoneID: ZoneID? = nil, syncToken: String? = nil, - resultsLimit: Int? = nil + resultsLimit: Int? = nil, + database: Database? = nil ) async throws(CloudKitError) -> RecordChangesResult { + let effectiveDatabase = database ?? self.database if let limit = resultsLimit { guard limit > 0 && limit <= 200 else { throw CloudKitError.httpErrorWithRawResponse( @@ -98,7 +100,8 @@ extension CloudKitService { let response = try await client.fetchRecordChanges( .init( path: createFetchRecordChangesPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: effectiveDatabase ), body: .json( .init( @@ -156,7 +159,8 @@ extension CloudKitService { public func fetchAllRecordChanges( zoneID: ZoneID? = nil, syncToken: String? = nil, - resultsLimit: Int? = nil + resultsLimit: Int? = nil, + database: Database? = nil ) async throws(CloudKitError) -> (records: [RecordInfo], syncToken: String?) { var allRecords: [RecordInfo] = [] var currentToken = syncToken @@ -178,7 +182,8 @@ extension CloudKitService { let result = try await fetchRecordChanges( zoneID: zoneID, syncToken: currentToken, - resultsLimit: resultsLimit + resultsLimit: resultsLimit, + database: database ) if result.records.isEmpty && result.moreComing && result.syncToken == currentToken { diff --git a/Sources/MistKit/Service/CloudKitService+UserOperations.swift b/Sources/MistKit/Service/CloudKitService+UserOperations.swift index 1118fb32..3a926f8b 100644 --- a/Sources/MistKit/Service/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+UserOperations.swift @@ -50,7 +50,10 @@ extension CloudKitService { do { let response = try await client.getCaller( .init( - path: createGetCallerPath(containerIdentifier: containerIdentifier) + path: createGetCallerPath( + containerIdentifier: containerIdentifier, + database: .public + ) ) ) @@ -63,7 +66,10 @@ extension CloudKitService { } /// Fetch the current authenticated user's information. - @available(*, deprecated, renamed: "fetchCaller", message: "users/current is deprecated by Apple. Use fetchCaller() instead.") + @available( + *, deprecated, renamed: "fetchCaller", + message: "users/current is deprecated by Apple. Use fetchCaller() instead." + ) public func fetchCurrentUser() async throws(CloudKitError) -> UserInfo { try await fetchCaller() } @@ -94,7 +100,8 @@ extension CloudKitService { let response = try await client.discoverAllUserIdentities( .init( path: createDiscoverAllUserIdentitiesPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: .public ) ) ) @@ -121,7 +128,8 @@ extension CloudKitService { let response = try await client.lookupUsersByEmail( .init( path: createLookupUsersByEmailPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: .public ), body: .json( .init(users: emails.map { .init(emailAddress: $0) }) @@ -148,7 +156,8 @@ extension CloudKitService { let response = try await client.lookupUsersByRecordName( .init( path: createLookupUsersByRecordNamePath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: .public ), body: .json( .init(users: recordNames.map { .init(userRecordName: $0) }) @@ -172,7 +181,8 @@ extension CloudKitService { let response = try await client.discoverUserIdentities( .init( path: createDiscoverUserIdentitiesPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: .public ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService+WriteOperations.swift b/Sources/MistKit/Service/CloudKitService+WriteOperations.swift index b537d54b..c9d88f84 100644 --- a/Sources/MistKit/Service/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+WriteOperations.swift @@ -48,8 +48,10 @@ extension CloudKitService { /// - Throws: CloudKitError if the operation fails public func modifyRecords( _ operations: [RecordOperation], - atomic: Bool = false + atomic: Bool = false, + database: Database? = nil ) async throws(CloudKitError) -> [RecordInfo] { + let effectiveDatabase = database ?? self.database do { let apiOperations = try operations.map { try Components.Schemas.RecordOperation(from: $0) @@ -61,7 +63,7 @@ extension CloudKitService { version: "1", container: containerIdentifier, environment: .init(from: environment), - database: .init(from: database) + database: .init(from: effectiveDatabase) ), body: .json( .init( @@ -94,7 +96,8 @@ extension CloudKitService { public func createRecord( recordType: String, recordName: String? = nil, - fields: [String: FieldValue] + fields: [String: FieldValue], + database: Database? = nil ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.create( recordType: recordType, @@ -102,7 +105,7 @@ extension CloudKitService { fields: fields ) - let results = try await modifyRecords([operation]) + let results = try await modifyRecords([operation], database: database) guard let record = results.first else { throw CloudKitError.invalidResponse } @@ -121,7 +124,8 @@ extension CloudKitService { recordType: String, recordName: String, fields: [String: FieldValue], - recordChangeTag: String? = nil + recordChangeTag: String? = nil, + database: Database? = nil ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.update( recordType: recordType, @@ -130,7 +134,7 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - let results = try await modifyRecords([operation]) + let results = try await modifyRecords([operation], database: database) guard let record = results.first else { throw CloudKitError.invalidResponse } @@ -146,7 +150,8 @@ extension CloudKitService { public func deleteRecord( recordType: String, recordName: String, - recordChangeTag: String? = nil + recordChangeTag: String? = nil, + database: Database? = nil ) async throws(CloudKitError) { let operation = RecordOperation.delete( recordType: recordType, @@ -154,6 +159,6 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - _ = try await modifyRecords([operation]) + _ = try await modifyRecords([operation], database: database) } } diff --git a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift b/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift index 5326d03f..bf8cbe8f 100644 --- a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift @@ -40,12 +40,22 @@ import OpenAPIRuntime @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { - /// List zones in the user's private database - public func listZones() async throws(CloudKitError) -> [ZoneInfo] { + /// List zones in the target database. + /// + /// > Note: Public database only contains the default zone (`_defaultZone`), + /// > so listing zones against `.public` is degenerate. Use `.private` or + /// > `.shared` for meaningful results. + public func listZones( + database: Database? = nil + ) async throws(CloudKitError) -> [ZoneInfo] { + let effectiveDatabase = database ?? self.database do { let response = try await client.listZones( .init( - path: createListZonesPath(containerIdentifier: containerIdentifier) + path: createListZonesPath( + containerIdentifier: containerIdentifier, + database: effectiveDatabase + ) ) ) @@ -86,8 +96,10 @@ extension CloudKitService { /// ) /// ``` public func lookupZones( - zoneIDs: [ZoneID] + zoneIDs: [ZoneID], + database: Database? = nil ) async throws(CloudKitError) -> [ZoneInfo] { + let effectiveDatabase = database ?? self.database guard !zoneIDs.isEmpty else { throw CloudKitError.httpErrorWithRawResponse( statusCode: 400, @@ -105,7 +117,8 @@ extension CloudKitService { let response = try await client.lookupZones( .init( path: createLookupZonesPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: effectiveDatabase ), body: .json( .init( @@ -157,13 +170,16 @@ extension CloudKitService { /// ) /// ``` public func fetchZoneChanges( - syncToken: String? = nil + syncToken: String? = nil, + database: Database? = nil ) async throws(CloudKitError) -> ZoneChangesResult { + let effectiveDatabase = database ?? self.database do { let response = try await client.fetchZoneChanges( .init( path: createFetchZoneChangesPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: effectiveDatabase ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService.swift b/Sources/MistKit/Service/CloudKitService.swift index 9a1b95fc..464e5a3b 100644 --- a/Sources/MistKit/Service/CloudKitService.swift +++ b/Sources/MistKit/Service/CloudKitService.swift @@ -27,18 +27,25 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import OpenAPIRuntime +internal import Foundation +internal import OpenAPIRuntime #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif #if !os(WASI) - import OpenAPIURLSession + internal import OpenAPIURLSession #endif -/// Service for interacting with CloudKit Web Services +/// Service for interacting with CloudKit Web Services. +/// +/// `CloudKitService` is configured with a CloudKit container identifier, an +/// `Environment`, and a `Credentials` value that may carry server-to-server +/// material, API/web-auth material, or both. The database to target is chosen +/// **per call** on each operation that supports multiple databases; user-identity +/// endpoints (e.g. `fetchCaller`) hard-code `.public` since CloudKit only +/// accepts those routes against the public database. @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct CloudKitService: Sendable { /// CloudKit's maximum number of records returned per query/modify request. @@ -46,12 +53,13 @@ public struct CloudKitService: Sendable { /// The CloudKit container identifier public let containerIdentifier: String - /// The API token for authentication - public let apiToken: String /// The CloudKit environment (development or production) public let environment: Environment - /// The CloudKit database (public, private, or shared) - public let database: Database + + /// Default database used by operation methods when the caller does not + /// supply a `database:` argument. Set at construction; operations may + /// override per-call via their `database:` parameter. + internal let database: Database /// Default limit for query operations (1-200, default: 100) internal let defaultQueryLimit: Int = 100 @@ -63,16 +71,14 @@ public struct CloudKitService: Sendable { } } -// MARK: - Private Helper Methods +// MARK: - Path builders @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { - /// Create a standard path for getCaller requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createGetCallerPath(containerIdentifier: String) - -> Operations.getCaller.Input.Path - { + internal func createGetCallerPath( + containerIdentifier: String, + database: Database + ) -> Operations.getCaller.Input.Path { .init( version: "1", container: containerIdentifier, @@ -81,12 +87,10 @@ extension CloudKitService { ) } - /// Create a standard path for listZones requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request - internal func createListZonesPath(containerIdentifier: String) - -> Operations.listZones.Input.Path - { + internal func createListZonesPath( + containerIdentifier: String, + database: Database + ) -> Operations.listZones.Input.Path { .init( version: "1", container: containerIdentifier, @@ -95,11 +99,9 @@ extension CloudKitService { ) } - /// Create a standard path for queryRecords requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createQueryRecordsPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.queryRecords.Input.Path { .init( version: "1", @@ -109,11 +111,9 @@ extension CloudKitService { ) } - /// Create a standard path for modifyRecords requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createModifyRecordsPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.modifyRecords.Input.Path { .init( version: "1", @@ -123,11 +123,9 @@ extension CloudKitService { ) } - /// Create a standard path for lookupRecords requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createLookupRecordsPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.lookupRecords.Input.Path { .init( version: "1", @@ -137,11 +135,9 @@ extension CloudKitService { ) } - /// Create a standard path for lookupZones requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createLookupZonesPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.lookupZones.Input.Path { .init( version: "1", @@ -151,11 +147,9 @@ extension CloudKitService { ) } - /// Create a standard path for fetchRecordChanges requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createFetchRecordChangesPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.fetchRecordChanges.Input.Path { .init( version: "1", @@ -165,11 +159,9 @@ extension CloudKitService { ) } - /// Create a standard path for uploadAssets requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createUploadAssetsPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.uploadAssets.Input.Path { .init( version: "1", @@ -179,11 +171,9 @@ extension CloudKitService { ) } - /// Create a standard path for discoverUserIdentities requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createDiscoverUserIdentitiesPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.discoverUserIdentities.Input.Path { .init( version: "1", @@ -193,11 +183,9 @@ extension CloudKitService { ) } - /// Create a standard path for discoverAllUserIdentities requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createDiscoverAllUserIdentitiesPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.discoverAllUserIdentities.Input.Path { .init( version: "1", @@ -207,11 +195,9 @@ extension CloudKitService { ) } - /// Create a standard path for lookupUsersByEmail requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createLookupUsersByEmailPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.lookupUsersByEmail.Input.Path { .init( version: "1", @@ -221,11 +207,9 @@ extension CloudKitService { ) } - /// Create a standard path for lookupUsersByRecordName requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createLookupUsersByRecordNamePath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.lookupUsersByRecordName.Input.Path { .init( version: "1", @@ -235,11 +219,9 @@ extension CloudKitService { ) } - /// Create a standard path for fetchZoneChanges requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request internal func createFetchZoneChangesPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.fetchZoneChanges.Input.Path { .init( version: "1", diff --git a/Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift b/Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift deleted file mode 100644 index 25c6a5b2..00000000 --- a/Sources/MistKit/Service/Operations.discoverAllUserIdentities.Output.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// Operations.discoverAllUserIdentities.Output.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -extension Operations.discoverAllUserIdentities.Output: CloudKitResponseType { - internal var badRequestResponse: Components.Responses.BadRequest? { - if case .badRequest(let response) = self { - return response - } else { - return nil - } - } - - internal var unauthorizedResponse: Components.Responses.Unauthorized? { - if case .unauthorized(let response) = self { - return response - } else { - return nil - } - } - - internal var isOk: Bool { - if case .ok = self { - return true - } else { - return false - } - } - - internal var undocumentedStatusCode: Int? { - if case .undocumented(let statusCode, _) = self { - return statusCode - } else { - return nil - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift index 14d8b46a..e04cbfc9 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift @@ -47,7 +47,7 @@ extension CloudKitServiceTests.DiscoverUserIdentities { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -58,7 +58,7 @@ extension CloudKitServiceTests.DiscoverUserIdentities { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Helpers.swift index ac9ed604..fed91e06 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Helpers.swift @@ -51,7 +51,7 @@ extension CloudKitServiceTests.FetchChanges { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -77,7 +77,7 @@ extension CloudKitServiceTests.FetchChanges { let transport = MockTransport(responseProvider: provider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -88,7 +88,7 @@ extension CloudKitServiceTests.FetchChanges { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+SuccessCases.swift index 396220f0..35c9bb6b 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+SuccessCases.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+SuccessCases.swift @@ -200,7 +200,7 @@ extension CloudKitServiceTests.FetchChanges { let transport = MockTransport(responseProvider: responseProvider) let service = try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: TestConstants.apiToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), transport: transport ) diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Validation.swift index b0446c75..df577adb 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Validation.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+Validation.swift @@ -100,7 +100,7 @@ extension CloudKitServiceTests.FetchChanges { let transport = MockTransport(responseProvider: responseProvider) let service = try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: TestConstants.apiToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), transport: transport ) diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift index bcd9bc93..b1584c6f 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift @@ -49,7 +49,7 @@ extension CloudKitServiceTests.FetchZoneChanges { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -60,7 +60,7 @@ extension CloudKitServiceTests.FetchZoneChanges { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift index 37322272..b2548883 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift @@ -112,7 +112,7 @@ extension CloudKitServiceTests.FetchZoneChanges { let transport = MockTransport(responseProvider: responseProvider) let service = try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: TestConstants.apiToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), transport: transport ) diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift index aa342681..5925a425 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift @@ -47,7 +47,7 @@ extension CloudKitServiceTests.LookupUsersByEmail { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -58,7 +58,7 @@ extension CloudKitServiceTests.LookupUsersByEmail { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift index 007ef0e0..feb0b86f 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift @@ -45,7 +45,7 @@ extension CloudKitServiceTests.LookupUsersByRecordName { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -56,7 +56,7 @@ extension CloudKitServiceTests.LookupUsersByRecordName { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+Helpers.swift index 97a073e9..edd4322d 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+Helpers.swift @@ -45,7 +45,7 @@ extension CloudKitServiceTests.LookupZones { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -56,7 +56,7 @@ extension CloudKitServiceTests.LookupZones { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceQuery/CloudKitServiceTests.Query+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceQuery/CloudKitServiceTests.Query+Helpers.swift index b1fa5169..0ffb4597 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceQuery/CloudKitServiceTests.Query+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceQuery/CloudKitServiceTests.Query+Helpers.swift @@ -43,7 +43,7 @@ extension CloudKitServiceTests.Query { ) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: "test-token", + credentials: Credentials(apiAuth: APICredentials(apiToken: "test-token")), transport: transport ) } @@ -58,7 +58,7 @@ extension CloudKitServiceTests.Query { ) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: "test-token", + credentials: Credentials(apiAuth: APICredentials(apiToken: "test-token")), transport: transport ) } @@ -71,7 +71,7 @@ extension CloudKitServiceTests.Query { ) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: "test-token", + credentials: Credentials(apiAuth: APICredentials(apiToken: "test-token")), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+Helpers.swift index 48d71c3e..a3c0faf6 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+Helpers.swift @@ -51,7 +51,7 @@ extension CloudKitServiceTests.QueryPagination { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -75,7 +75,7 @@ extension CloudKitServiceTests.QueryPagination { let transport = MockTransport(responseProvider: provider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift index e7f5b3ef..46ba56ff 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift @@ -176,7 +176,7 @@ extension CloudKitServiceTests.QueryPagination { let transport = MockTransport(responseProvider: provider) let service = try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: TestConstants.apiToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), transport: transport ) diff --git a/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift index b53918a9..3428620a 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift @@ -46,7 +46,7 @@ extension CloudKitServiceTests { let transport = MockTransport(responseProvider: provider) return try CloudKitService( containerIdentifier: containerIdentifier, - apiToken: apiToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: apiToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Helpers.swift index 0eee6634..412876d3 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Helpers.swift @@ -72,7 +72,7 @@ extension CloudKitServiceTests.Upload { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -87,7 +87,7 @@ extension CloudKitServiceTests.Upload { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } @@ -100,7 +100,7 @@ extension CloudKitServiceTests.Upload { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: testAPIToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Validation.swift index a423ca71..13f21d6e 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Validation.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceUpload/CloudKitServiceTests.Upload+Validation.swift @@ -136,7 +136,7 @@ extension CloudKitServiceTests.Upload { let transport = MockTransport(responseProvider: responseProvider) let service = try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - apiToken: TestConstants.apiToken, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), transport: transport ) From 7debe8d603f73ca34bc019f45d532295d92e538e Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 9 May 2026 11:25:09 -0400 Subject: [PATCH 05/11] #315: drop service-level database, per-call credential resolution [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the architectural feedback in the PR-315 review: * CloudKitService no longer carries `database` — operations take `database:` per call (defaulting to `.public`); user-identity routes drop the parameter since CloudKit pins them to `.public`. Subsumes Claude's "fetchCaller bypasses self.database" finding. * Credentials.makeTokenManager(for:requiresUserContext:) resolves the appropriate token manager at dispatch time. A single service can now serve public-database S2S record ops and user-identity web-auth routes from one fully-populated `Credentials`. MistKitClient.swift is obsolete and removed; per-call dispatch lives in CloudKitService+ClientDispatch. * Credentials.swift split per SwiftLint one_file_per_declaration into ServerToServerCredentials.swift + APICredentials.swift + Credentials.swift. New typed CredentialsValidationError; init asserts in debug, throws in release (no more precondition crash for dynamic config). * MistDemo: userContextService workaround collapsed — single service handles all phases via per-call resolution. * CI hotfix: 11 unused `public import` lines demoted to `internal` (the warnings-as-errors regression flagged in the review). * Tests: 12-case routing-matrix unit suite for makeTokenManager and a fetchCaller suite parallel to LookupUsers* (success + validation). Obsolete MistKitClient tests removed. * Polish: shorter @available message on discoverAllUserIdentities, structural comment for GET /users/discover in openapi.yaml, ConfigurationError.missingAPIToken (unused) removed. 475/475 tests pass. Library + MistDemo build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CloudKit/MistKitClientFactory.swift | 55 ++--- .../Commands/TestIntegrationCommand.swift | 10 +- .../Commands/TestPrivateCommand.swift | 7 +- .../Configuration/ConfigurationError.swift | 4 - ...MistDemoConfig+DatabaseConfiguration.swift | 21 +- .../Integration/IntegrationTestError.swift | 5 - .../Integration/IntegrationTestRunner.swift | 12 +- .../Integration/PhaseContext.swift | 4 - .../Phases/DiscoverUserIdentitiesPhase.swift | 14 +- .../Integration/Phases/FetchCallerPhase.swift | 13 +- .../Phases/LookupUsersByEmailPhase.swift | 6 +- .../Phases/LookupUsersByRecordNamePhase.swift | 8 +- .../Tests/PrivateDatabaseTest.swift | 4 +- .../Tests/PublicDatabaseTest.swift | 7 +- .../Authentication/APICredentials.swift | 47 ++++ .../AdaptiveTokenManager+Transitions.swift | 2 +- .../Authentication/AuthenticationMode.swift | 2 +- .../Authentication/CharacterMapEncoder.swift | 2 +- .../Credentials+TokenManager.swift | 105 +++++++++ .../MistKit/Authentication/Credentials.swift | 49 +--- .../CredentialsValidationError.swift | 43 ++++ .../InMemoryTokenStorage+Convenience.swift | 2 +- .../Authentication/RequestSignature.swift | 2 +- .../ServerToServerAuthenticator+Signing.swift | 2 +- .../ServerToServerCredentials.swift | 42 ++++ .../RecordManaging+RecordCollection.swift | 2 +- Sources/MistKit/MistKitClient.swift | 209 ------------------ ...onfiguration+ConvenienceInitializers.swift | 2 +- Sources/MistKit/MistKitConfiguration.swift | 2 +- .../MistKit/Protocols/CloudKitRecord.swift | 2 +- .../MistKit/Service/AssetUploadResponse.swift | 2 +- .../CloudKitService+AssetOperations.swift | 8 +- .../CloudKitService+ClientDispatch.swift | 74 +++++++ .../CloudKitService+Initialization.swift | 118 +++------- .../CloudKitService+LookupOperations.swift | 12 +- .../Service/CloudKitService+Operations.swift | 12 +- .../CloudKitService+QueryPagination.swift | 2 +- .../CloudKitService+SyncOperations.swift | 8 +- .../CloudKitService+UserOperations.swift | 46 ++-- .../CloudKitService+WriteOperations.swift | 12 +- .../CloudKitService+ZoneOperations.swift | 18 +- Sources/MistKit/Service/CloudKitService.swift | 29 ++- Sources/MistKit/Service/ZoneID.swift | 2 +- .../CredentialsTokenManagerTests.swift | 195 ++++++++++++++++ .../MistKitClientTests+Configuration.swift | 112 ---------- .../MistKitClientTests+Initialization.swift | 191 ---------------- .../MistKitClientTests+ServerToServer.swift | 82 ------- ...Tests.DiscoverUserIdentities+Helpers.swift | 14 +- ...dKitServiceTests.FetchCaller+Helpers.swift | 132 +++++++++++ ...erviceTests.FetchCaller+SuccessCases.swift | 80 +++++++ ...tServiceTests.FetchCaller+Validation.swift | 81 +++++++ .../CloudKitServiceTests.FetchCaller.swift} | 14 +- ...viceTests.LookupUsersByEmail+Helpers.swift | 14 +- ...ests.LookupUsersByRecordName+Helpers.swift | 14 +- .../CloudKitServiceTests+Helpers.swift | 7 +- openapi.yaml | 1 + 56 files changed, 1048 insertions(+), 917 deletions(-) create mode 100644 Sources/MistKit/Authentication/APICredentials.swift create mode 100644 Sources/MistKit/Authentication/Credentials+TokenManager.swift create mode 100644 Sources/MistKit/Authentication/CredentialsValidationError.swift create mode 100644 Sources/MistKit/Authentication/ServerToServerCredentials.swift delete mode 100644 Sources/MistKit/MistKitClient.swift create mode 100644 Sources/MistKit/Service/CloudKitService+ClientDispatch.swift create mode 100644 Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift delete mode 100644 Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Configuration.swift delete mode 100644 Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Initialization.swift delete mode 100644 Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+ServerToServer.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Helpers.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+SuccessCases.swift create mode 100644 Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Validation.swift rename Tests/MistKitTests/{Client/MistKitClient/MistKitClientTests.swift => Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller.swift} (82%) diff --git a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift index c2b08d13..2eb9c63d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -33,12 +33,21 @@ public import MistKit /// Factory for creating MistKit `CloudKitService` instances from MistDemo /// configuration. public struct MistKitClientFactory: Sendable { - /// Create a `CloudKitService` for `config.database`, choosing auth method - /// automatically. + /// Create a `CloudKitService` configured for `config.database`, choosing + /// auth material automatically based on the populated environment. /// - /// - `.public`: requires `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY[_FILE]` + /// - `.public`: requires `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY[_FILE]`, + /// optionally augmented with `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` + /// so the same service can also satisfy user-identity routes. /// - `.private` / `.shared`: requires `CLOUDKIT_API_TOKEN` + - /// `CLOUDKIT_WEB_AUTH_TOKEN` + /// `CLOUDKIT_WEB_AUTH_TOKEN`. The resulting web-auth credentials cover + /// user-identity routes too (which CloudKit pins to `.public`). + /// + /// The service is database-agnostic — operations pick their database at the + /// call site, and `Credentials` resolves the appropriate token manager per + /// call. A single returned service therefore covers every phase the + /// integration runner exercises, including the user-context routes that + /// previously required a second service. /// /// When `config.badCredentials == true`, this short-circuits and returns a /// service backed by a deliberately invalid web-auth `TokenManager` so the @@ -66,36 +75,7 @@ public struct MistKitClientFactory: Sendable { return try CloudKitService( containerIdentifier: config.containerIdentifier, credentials: credentials, - environment: config.environment, - database: config.database - ) - #endif - } - - /// Create the optional public+web-auth service used for user-context - /// endpoints (`users/caller`, `users/discover`, `users/lookup/*`). - /// - /// Returns `nil` when web-auth credentials are not configured, so callers - /// can gracefully skip user-identity coverage in integration runs. - /// - /// > Note: This returns a separate `CloudKitService` because CloudKit - /// > rejects server-to-server signing for user-identity routes — a single - /// > service that uses S2S auth for record ops cannot also serve those - /// > routes. Per-call token-manager dispatch is a future enhancement. - public static func createUserContext( - for config: MistDemoConfig - ) throws -> CloudKitService? { - #if os(WASI) - return nil - #else - guard let credentials = config.toUserContextCredentials() else { - return nil - } - return try CloudKitService( - containerIdentifier: config.containerIdentifier, - credentials: credentials, - environment: config.environment, - database: .public + environment: config.environment ) #endif } @@ -111,8 +91,8 @@ public struct MistKitClientFactory: Sendable { ) } - /// Create a `CloudKitService` with a caller-supplied `TokenManager`, - /// targeting `config.database`. Used by the `--bad-credentials` demo path. + /// Create a `CloudKitService` with a caller-supplied `TokenManager`. Used + /// by the `--bad-credentials` demo path. public static func create( from config: MistDemoConfig, tokenManager: any TokenManager @@ -125,8 +105,7 @@ public struct MistKitClientFactory: Sendable { return try CloudKitService( containerIdentifier: config.containerIdentifier, tokenManager: tokenManager, - environment: config.environment, - database: config.database + environment: config.environment ) #endif } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift index 3a77d64c..c00e876f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift @@ -75,13 +75,15 @@ public struct TestIntegrationCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { let service = try MistKitClientFactory.create(for: config.base) - let userContextService = try MistKitClientFactory.createUserContext( - for: config.base - ) + // A single service handles every phase: server-to-server signing on + // `.public` for record ops, plus web-auth for user-identity routes when + // the API/web-auth env vars are populated. The resolver picks the right + // token manager per call. + let supportsUserContextPhases = config.base.hasUserContextCredentials let runner = IntegrationTestRunner( service: service, - userContextService: userContextService, + supportsUserContextPhases: supportsUserContextPhases, containerIdentifier: config.base.containerIdentifier, database: config.base.database, recordCount: config.recordCount, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index e205e50f..d693c0f6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -76,12 +76,13 @@ public struct TestPrivateCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { let service = try MistKitClientFactory.create(for: config.base) - // The private-database pipeline already authenticates with web-auth, so the - // primary `service` is sufficient — no separate userContextService is needed. + // Private-database flows always carry web-auth credentials, so the same + // service can also serve user-identity routes when this command needs + // them. Per-call resolution picks the right token manager. let runner = IntegrationTestRunner( service: service, - userContextService: nil, + supportsUserContextPhases: true, containerIdentifier: config.base.containerIdentifier, database: .private, recordCount: config.recordCount, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift index fa68895c..b9eb0e66 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift @@ -31,7 +31,6 @@ import Foundation /// Configuration errors. internal enum ConfigurationError: LocalizedError { - case missingAPIToken case invalidEnvironment(String) case invalidDatabase(String) case missingRequired(String, suggestion: String) @@ -42,9 +41,6 @@ internal enum ConfigurationError: LocalizedError { internal var errorDescription: String? { switch self { - case .missingAPIToken: - "CloudKit API token is required. " - + "Set CLOUDKIT_API_TOKEN environment variable or use --api-token" case .invalidEnvironment(let env): "Invalid environment '\(env)'. Must be 'development' or 'production'" case .invalidDatabase(let database): diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift index f98535f4..d2a7ff6c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift @@ -46,23 +46,24 @@ extension MistDemoConfig { switch database { case .public: let s2s = try resolveServerToServerCredentials() - // Optional: also include web-auth so user-context services share creds + // Optional: also include web-auth so a single service can serve + // user-identity routes (`fetchCaller`, `lookupUsers*`) alongside + // S2S-signed record operations on `.public`. let webAuth = try? resolveAPICredentials() - return Credentials(serverToServer: s2s, apiAuth: webAuth) + return try Credentials(serverToServer: s2s, apiAuth: webAuth) case .private, .shared: let apiAuth = try resolveAPICredentials() - return Credentials(apiAuth: apiAuth) + return try Credentials(apiAuth: apiAuth) } } - /// Build `Credentials` carrying public+web-auth material for user-context - /// endpoints (`users/caller`, `users/discover`, `users/lookup/*`). + /// Indicates whether `toPrimaryCredentials()` will produce credentials that + /// can satisfy user-identity endpoints (`fetchCaller`, `lookupUsers*`). /// - /// Returns `nil` when API token + web-auth token aren't configured, so - /// callers can gracefully skip user-identity coverage. - internal func toUserContextCredentials() -> Credentials? { - guard let apiAuth = try? resolveAPICredentials() else { return nil } - return Credentials(apiAuth: apiAuth) + /// Those routes require web-auth even on `.public`. Used by the integration + /// runner to decide whether to schedule user-identity phases. + internal var hasUserContextCredentials: Bool { + (try? resolveAPICredentials()) != nil } // MARK: - Resolution helpers diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift index a68d50bb..75b13aaa 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift @@ -40,7 +40,6 @@ internal enum IntegrationTestError: LocalizedError, Sendable { case noRecordsCreated case missingWebAuthToken case missingPhaseState(String) - case missingUserContextService(phase: String) internal var errorDescription: String? { switch self { @@ -63,10 +62,6 @@ internal enum IntegrationTestError: LocalizedError, Sendable { "Web auth token is required for private database tests. Run 'mistdemo auth-token' first." case .missingPhaseState(let key): return "Required phase state '\(key)' is missing — preceding phase did not run" - case .missingUserContextService(let phase): - return - "Phase '\(phase)' requires public+web-auth user-context credentials. " - + "Set CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN to enable." } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift index 12f43313..15809aa1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -34,10 +34,11 @@ import MistKit /// dispatches to the appropriate `PhasedIntegrationTest` implementation. internal struct IntegrationTestRunner { internal let service: CloudKitService - /// Optional public+web-auth service for user-identity endpoints. When `nil`, - /// user-identity phases (FetchCallerPhase, DiscoverUserIdentitiesPhase, etc.) - /// skip with a log message instead of failing the run. - internal let userContextService: CloudKitService? + /// Whether the configured `Credentials` carry web-auth material — i.e. + /// whether the single `service` can satisfy user-identity routes + /// (`fetchCaller`, `lookupUsers*`, `discoverUserIdentities`). User-identity + /// phases are scheduled only when this is true. + internal let supportsUserContextPhases: Bool internal let containerIdentifier: String internal let database: MistKit.Database internal let recordCount: Int @@ -49,7 +50,7 @@ internal struct IntegrationTestRunner { internal func runBasicWorkflow() async throws { let test = PublicDatabaseTest( database: database, - includeUserContextPhases: userContextService != nil + includeUserContextPhases: supportsUserContextPhases ) try await test.run(context: makeContext()) } @@ -62,7 +63,6 @@ internal struct IntegrationTestRunner { private func makeContext() -> PhaseContext { PhaseContext( service: service, - userContextService: userContextService, containerIdentifier: containerIdentifier, database: database, recordCount: recordCount, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift index 678855ef..cb98568e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift @@ -33,10 +33,6 @@ import MistKit /// Shared dependencies and configuration available to every phase. internal struct PhaseContext: Sendable { internal let service: CloudKitService - /// Optional public+web-auth service for user-identity endpoints - /// (`users/caller`, `users/discover`, `users/lookup/*`). When `nil`, - /// user-identity phases skip with a log message instead of failing. - internal let userContextService: CloudKitService? internal let containerIdentifier: String internal let database: MistKit.Database internal let recordCount: Int diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift index f6edb0c5..f6fb5b4e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift @@ -32,9 +32,9 @@ import MistKit /// Calls POST `/users/discover` to look up specific user identities. /// -/// Requires public-database web-auth (user-context) credentials, so the phase -/// uses `context.userContextService` and only runs when the runner has wired -/// one in. +/// Requires public-database web-auth (user-context) credentials. The runner +/// only schedules this phase when the configured `Credentials` carries +/// web-auth material; the service resolves the right token manager per call. internal struct DiscoverUserIdentitiesPhase: IntegrationPhase { internal typealias Input = UserInfo internal typealias Output = NoState @@ -48,12 +48,10 @@ internal struct DiscoverUserIdentitiesPhase: IntegrationPhase { ) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - guard let service = context.userContextService else { - throw IntegrationTestError.missingUserContextService(phase: Self.apiName) - } - let lookupInfos = [UserIdentityLookupInfo(userRecordName: input.userRecordName)] - let identities = try await service.discoverUserIdentities(lookupInfos: lookupInfos) + let identities = try await context.service.discoverUserIdentities( + lookupInfos: lookupInfos + ) print("✅ Discovered \(identities.count) user identit\(identities.count == 1 ? "y" : "ies")") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift index 8e5d8e5b..327984e6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift @@ -34,10 +34,9 @@ import MistKit /// `users/current`. /// /// CloudKit only accepts this endpoint against the **public database with -/// web-auth credentials**, so the phase requires `context.userContextService`. -/// The runner only includes the phase when a user-context service is available; -/// if the precondition fails the phase throws a typed error rather than silently -/// hitting the wrong service. +/// web-auth credentials**. The runner only schedules this phase when the +/// configured `Credentials` carries web-auth material; the service resolves +/// the right token manager per call. internal struct FetchCallerPhase: IntegrationPhase { internal typealias Input = NoState internal typealias Output = UserInfo @@ -51,11 +50,7 @@ internal struct FetchCallerPhase: IntegrationPhase { ) async throws -> UserInfo { print("\n\(Self.emoji) \(Self.title)") - guard let service = context.userContextService else { - throw IntegrationTestError.missingUserContextService(phase: Self.apiName) - } - - let userInfo = try await service.fetchCaller() + let userInfo = try await context.service.fetchCaller() print("✅ Caller: \(userInfo.userRecordName)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift index cba62595..4b84e656 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift @@ -45,10 +45,6 @@ internal struct LookupUsersByEmailPhase: IntegrationPhase { ) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - guard let service = context.userContextService else { - throw IntegrationTestError.missingUserContextService(phase: Self.apiName) - } - guard let email = input.emailAddress, !email.isEmpty else { print( "⏭️ Skipping — caller's email address is not available; cannot self-lookup." @@ -56,7 +52,7 @@ internal struct LookupUsersByEmailPhase: IntegrationPhase { return NoState() } - let identities = try await service.lookupUsersByEmail([email]) + let identities = try await context.service.lookupUsersByEmail([email]) print("✅ Looked up \(identities.count) identit\(identities.count == 1 ? "y" : "ies") by email") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift index a8b785d3..3d3465c5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift @@ -45,11 +45,9 @@ internal struct LookupUsersByRecordNamePhase: IntegrationPhase { ) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - guard let service = context.userContextService else { - throw IntegrationTestError.missingUserContextService(phase: Self.apiName) - } - - let identities = try await service.lookupUsersByRecordName([input.userRecordName]) + let identities = try await context.service.lookupUsersByRecordName( + [input.userRecordName] + ) print( "✅ Looked up \(identities.count) identit\(identities.count == 1 ? "y" : "ies") by record name" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 009cf911..3fbaac55 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -37,8 +37,8 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { // User-identity phases (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, // `users/lookup/*`) are intentionally absent: CloudKit Web Services rejects // these endpoints on the private database with "endpoint not applicable in - // the database type 'privatedb'". They only belong in a public-database - // pipeline that has access to a public+web-auth `userContextService`. + // the database type 'privatedb'". They only belong in the public-database + // pipeline; the service resolves web-auth credentials per call when needed. internal let phases: [any IntegrationPhase] = [ ListZonesPhase(), LookupZonePhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index e9a113f4..5b23f7db 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -38,9 +38,10 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { /// - Parameters: /// - database: must be `.public`. Defaults to `.public`. /// - includeUserContextPhases: when `true`, appends user-identity phases - /// (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, `users/lookup/*`) - /// that require a public+web-auth `userContextService`. The runner sets - /// this based on whether web-auth credentials are configured. + /// (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, `users/lookup/*`). + /// Those phases need web-auth credentials, which the resolver picks per + /// call from the service's `Credentials`. The runner sets this based on + /// whether web-auth credentials are configured. internal init( database: MistKit.Database = .public, includeUserContextPhases: Bool = false diff --git a/Sources/MistKit/Authentication/APICredentials.swift b/Sources/MistKit/Authentication/APICredentials.swift new file mode 100644 index 00000000..d40d2dd0 --- /dev/null +++ b/Sources/MistKit/Authentication/APICredentials.swift @@ -0,0 +1,47 @@ +// +// APICredentials.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// API-token credentials, optionally augmented with a web-auth token for +/// user-context routes. +/// +/// - `apiToken` alone is sufficient for read access against the public +/// database. +/// - `webAuthToken` is required for any route that operates as a specific +/// user — that includes every user-identity endpoint (`fetchCaller`, +/// `lookupUsersByEmail`, …) and any write/read against the private or +/// shared databases. +public struct APICredentials: Sendable { + public let apiToken: String + public let webAuthToken: String? + + public init(apiToken: String, webAuthToken: String? = nil) { + self.apiToken = apiToken + self.webAuthToken = webAuthToken + } +} diff --git a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift index a6405b2f..42c0aa19 100644 --- a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift +++ b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation // MARK: - Transition Methods diff --git a/Sources/MistKit/Authentication/AuthenticationMode.swift b/Sources/MistKit/Authentication/AuthenticationMode.swift index 7b1c4a2b..c8640cfd 100644 --- a/Sources/MistKit/Authentication/AuthenticationMode.swift +++ b/Sources/MistKit/Authentication/AuthenticationMode.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Represents the current authentication mode public enum AuthenticationMode: Sendable, Equatable { diff --git a/Sources/MistKit/Authentication/CharacterMapEncoder.swift b/Sources/MistKit/Authentication/CharacterMapEncoder.swift index 91f0995f..43d5a4ee 100644 --- a/Sources/MistKit/Authentication/CharacterMapEncoder.swift +++ b/Sources/MistKit/Authentication/CharacterMapEncoder.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// A token encoder that replaces specific characters with URL-encoded equivalents internal struct CharacterMapEncoder: Sendable { diff --git a/Sources/MistKit/Authentication/Credentials+TokenManager.swift b/Sources/MistKit/Authentication/Credentials+TokenManager.swift new file mode 100644 index 00000000..3ae4d134 --- /dev/null +++ b/Sources/MistKit/Authentication/Credentials+TokenManager.swift @@ -0,0 +1,105 @@ +// +// Credentials+TokenManager.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension Credentials { + /// Resolve the appropriate token manager for an outgoing request. + /// + /// Picks among the populated `serverToServer` and `apiAuth` credentials + /// based on the target `database` and whether the route requires + /// user-context authentication: + /// + /// - `requiresUserContext == true`: web-auth is mandatory regardless of + /// database. CloudKit's user-identity routes (`fetchCaller`, + /// `lookupUsersByEmail`, `lookupUsersByRecordName`, + /// `discoverAllUserIdentities`) live on `.public` but still need + /// web-auth to identify the caller. + /// - `.public` + no user context: prefers server-to-server signing, falls + /// back to web-auth, then bare API-token. + /// - `.private` / `.shared`: requires `apiAuth.webAuthToken`. CloudKit + /// rejects server-to-server signing for these databases, so any + /// `serverToServer` material is ignored on this path. + /// + /// - Throws: `CloudKitError.missingCredentials` when no populated credential + /// set can satisfy the requested combination, or any error from + /// `ServerToServerAuthManager.init` when the PEM is malformed. + internal func makeTokenManager( + for database: Database, + requiresUserContext: Bool = false + ) throws -> any TokenManager { + if requiresUserContext { + guard let api = apiAuth, let webAuthToken = api.webAuthToken else { + throw CloudKitError.missingCredentials( + database: database, + reason: "user-context routes require apiAuth with a webAuthToken" + ) + } + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + + switch database { + case .public: + if let s2s = serverToServer { + let pem = try s2s.privateKey.loadPEM() + return try ServerToServerAuthManager( + keyID: s2s.keyID, + pemString: pem + ) + } + if let api = apiAuth { + if let webAuthToken = api.webAuthToken { + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + return APITokenManager(apiToken: api.apiToken) + } + throw CloudKitError.missingCredentials( + database: .public, + reason: "expected serverToServer or apiAuth credentials" + ) + case .private, .shared: + guard let api = apiAuth, let webAuthToken = api.webAuthToken else { + throw CloudKitError.missingCredentials( + database: database, + reason: + "private and shared databases require apiAuth with a webAuthToken" + ) + } + return WebAuthTokenManager( + apiToken: api.apiToken, + webAuthToken: webAuthToken + ) + } + } +} diff --git a/Sources/MistKit/Authentication/Credentials.swift b/Sources/MistKit/Authentication/Credentials.swift index 33b03e26..906c1df4 100644 --- a/Sources/MistKit/Authentication/Credentials.swift +++ b/Sources/MistKit/Authentication/Credentials.swift @@ -27,39 +27,6 @@ // OTHER DEALINGS IN THE SOFTWARE. // -/// Server-to-server signing credentials for the public CloudKit database. -/// -/// CloudKit accepts server-to-server signing only against the **public** -/// database. Private and shared databases require web-auth credentials. -public struct ServerToServerCredentials: Sendable { - public let keyID: String - public let privateKey: PrivateKeyMaterial - - public init(keyID: String, privateKey: PrivateKeyMaterial) { - self.keyID = keyID - self.privateKey = privateKey - } -} - -/// API-token credentials, optionally augmented with a web-auth token for -/// user-context routes. -/// -/// - `apiToken` alone is sufficient for read access against the public -/// database. -/// - `webAuthToken` is required for any route that operates as a specific -/// user — that includes every user-identity endpoint (`fetchCaller`, -/// `lookupUsersByEmail`, …) and any write/read against the private or -/// shared databases. -public struct APICredentials: Sendable { - public let apiToken: String - public let webAuthToken: String? - - public init(apiToken: String, webAuthToken: String? = nil) { - self.apiToken = apiToken - self.webAuthToken = webAuthToken - } -} - /// CloudKit credentials for a `CloudKitService`. /// /// Holds either set of authentication material — server-to-server (public @@ -75,17 +42,23 @@ public struct Credentials: Sendable { /// Construct credentials. /// - /// - Precondition: at least one of `serverToServer` or `apiAuth` must be - /// non-nil. A `Credentials` with neither populated would fail every - /// request with a missing-credentials error. + /// At least one of `serverToServer` or `apiAuth` must be non-nil. In debug + /// builds an empty `Credentials` triggers an `assert` so the misconfiguration + /// surfaces during development; in release builds the same misconfiguration + /// throws `CredentialsValidationError.empty` so callers loading credentials + /// from dynamic config (env vars, JSON, keychain) get a typed, recoverable + /// error instead of a crash. public init( serverToServer: ServerToServerCredentials? = nil, apiAuth: APICredentials? = nil - ) { - precondition( + ) throws(CredentialsValidationError) { + assert( serverToServer != nil || apiAuth != nil, "Credentials must include at least one of serverToServer or apiAuth" ) + guard serverToServer != nil || apiAuth != nil else { + throw .empty + } self.serverToServer = serverToServer self.apiAuth = apiAuth } diff --git a/Sources/MistKit/Authentication/CredentialsValidationError.swift b/Sources/MistKit/Authentication/CredentialsValidationError.swift new file mode 100644 index 00000000..494de0e3 --- /dev/null +++ b/Sources/MistKit/Authentication/CredentialsValidationError.swift @@ -0,0 +1,43 @@ +// +// CredentialsValidationError.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Construction-time validation errors for `Credentials`. +public enum CredentialsValidationError: LocalizedError, Sendable { + /// `Credentials` was constructed without any populated credential set. + case empty + + public var errorDescription: String? { + switch self { + case .empty: + return "Credentials must include at least one of serverToServer or apiAuth" + } + } +} diff --git a/Sources/MistKit/Authentication/InMemoryTokenStorage+Convenience.swift b/Sources/MistKit/Authentication/InMemoryTokenStorage+Convenience.swift index 5d6f3712..90f0a21a 100644 --- a/Sources/MistKit/Authentication/InMemoryTokenStorage+Convenience.swift +++ b/Sources/MistKit/Authentication/InMemoryTokenStorage+Convenience.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation // MARK: - Convenience Methods diff --git a/Sources/MistKit/Authentication/RequestSignature.swift b/Sources/MistKit/Authentication/RequestSignature.swift index e7e875ca..3d767b7a 100644 --- a/Sources/MistKit/Authentication/RequestSignature.swift +++ b/Sources/MistKit/Authentication/RequestSignature.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// CloudKit Web Services request signature components public struct RequestSignature: Sendable { diff --git a/Sources/MistKit/Authentication/ServerToServerAuthenticator+Signing.swift b/Sources/MistKit/Authentication/ServerToServerAuthenticator+Signing.swift index 03811f4b..6c0646ff 100644 --- a/Sources/MistKit/Authentication/ServerToServerAuthenticator+Signing.swift +++ b/Sources/MistKit/Authentication/ServerToServerAuthenticator+Signing.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Crypto +internal import Crypto public import Foundation @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) diff --git a/Sources/MistKit/Authentication/ServerToServerCredentials.swift b/Sources/MistKit/Authentication/ServerToServerCredentials.swift new file mode 100644 index 00000000..875d36b0 --- /dev/null +++ b/Sources/MistKit/Authentication/ServerToServerCredentials.swift @@ -0,0 +1,42 @@ +// +// ServerToServerCredentials.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Server-to-server signing credentials for the public CloudKit database. +/// +/// CloudKit accepts server-to-server signing only against the **public** +/// database. Private and shared databases require web-auth credentials. +public struct ServerToServerCredentials: Sendable { + public let keyID: String + public let privateKey: PrivateKeyMaterial + + public init(keyID: String, privateKey: PrivateKeyMaterial) { + self.keyID = keyID + self.privateKey = privateKey + } +} diff --git a/Sources/MistKit/Extensions/RecordManaging+RecordCollection.swift b/Sources/MistKit/Extensions/RecordManaging+RecordCollection.swift index 9378aa81..6c7da8f7 100644 --- a/Sources/MistKit/Extensions/RecordManaging+RecordCollection.swift +++ b/Sources/MistKit/Extensions/RecordManaging+RecordCollection.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Default implementations for RecordManaging when conforming to CloudKitRecordCollection /// diff --git a/Sources/MistKit/MistKitClient.swift b/Sources/MistKit/MistKitClient.swift deleted file mode 100644 index d30c2d62..00000000 --- a/Sources/MistKit/MistKitClient.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// MistKitClient.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Crypto -import Foundation -import HTTPTypes -import OpenAPIRuntime - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if !os(WASI) - import OpenAPIURLSession -#endif - -/// A client for interacting with CloudKit Web Services -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -internal struct MistKitClient { - /// The underlying OpenAPI client - internal let client: Client - - /// Initialize a new MistKit client - /// - Parameters: - /// - configuration: The CloudKit configuration including container, - /// environment, and authentication - /// - transport: Custom transport for network requests - /// - Throws: ClientError if initialization fails - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal init( - configuration: MistKitConfiguration, - transport: any ClientTransport - ) throws { - // Create appropriate TokenManager from configuration - let tokenManager = try configuration.createTokenManager() - - // Create the OpenAPI client with custom server URL and middleware - self.client = Client( - serverURL: configuration.serverURL, - transport: transport, - middlewares: [ - AuthenticationMiddleware(tokenManager: tokenManager), - LoggingMiddleware(), - ] - ) - } - - /// Initialize a new MistKit client with a custom TokenManager - /// - Parameters: - /// - configuration: The CloudKit configuration - /// - tokenManager: Custom token manager for authentication - /// - transport: Custom transport for network requests - /// - Throws: ClientError if initialization fails - internal init( - configuration: MistKitConfiguration, - tokenManager: any TokenManager, - transport: any ClientTransport - ) throws { - // Validate server-to-server authentication restrictions - try Self.validateServerToServerConfiguration( - configuration: configuration, - tokenManager: tokenManager - ) - - // Create the OpenAPI client with custom server URL and middleware - self.client = Client( - serverURL: configuration.serverURL, - transport: transport, - middlewares: [ - AuthenticationMiddleware(tokenManager: tokenManager), - LoggingMiddleware(), - ] - ) - } - - /// Initialize a new MistKit client with a custom TokenManager and individual parameters - /// - Parameters: - /// - container: CloudKit container identifier - /// - environment: CloudKit environment (development/production) - /// - database: CloudKit database (public/private/shared) - /// - tokenManager: Custom token manager for authentication - /// - transport: Custom transport for network requests - /// - Throws: ClientError if initialization fails - internal init( - container: String, - environment: Environment, - database: Database, - tokenManager: any TokenManager, - transport: any ClientTransport - ) throws { - // Check if this is a server-to-server token manager - var keyID: String? - var privateKeyData: Data? - var apiToken: String = "" - - if let serverManager = tokenManager as? ServerToServerAuthManager { - // Extract keyID and privateKeyData from ServerToServerAuthManager - keyID = serverManager.keyID - privateKeyData = serverManager.privateKeyData - } else if let apiManager = tokenManager as? APITokenManager { - // Extract API token from APITokenManager - apiToken = apiManager.token - } - - let configuration = MistKitConfiguration( - container: container, - environment: environment, - database: database, - apiToken: apiToken, // Use extracted API token if available - keyID: keyID, - privateKeyData: privateKeyData - ) - - try self.init( - configuration: configuration, - tokenManager: tokenManager, - transport: transport - ) - } - - // MARK: - Convenience Initializers - - #if !os(WASI) - /// Initialize a new MistKit client with default URLSessionTransport - /// - Parameter configuration: The CloudKit configuration including container, - /// environment, and authentication - /// - Throws: ClientError if initialization fails - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - internal init( - configuration: MistKitConfiguration - ) throws { - try self.init( - configuration: configuration, - transport: URLSessionTransport() - ) - } - - /// Initialize a new MistKit client with a custom TokenManager and individual parameters - /// using default URLSessionTransport - /// - Parameters: - /// - container: CloudKit container identifier - /// - environment: CloudKit environment (development/production) - /// - database: CloudKit database (public/private/shared) - /// - tokenManager: Custom token manager for authentication - /// - Throws: ClientError if initialization fails - internal init( - container: String, - environment: Environment, - database: Database, - tokenManager: any TokenManager - ) throws { - try self.init( - container: container, - environment: environment, - database: database, - tokenManager: tokenManager, - transport: URLSessionTransport() - ) - } - #endif - - // MARK: - Server-to-Server Validation - - /// Validates that server-to-server authentication is only used with the public database - /// - Parameters: - /// - configuration: The CloudKit configuration - /// - tokenManager: The token manager being used - /// - Throws: TokenManagerError if server-to-server auth is used with non-public database - private static func validateServerToServerConfiguration( - configuration: MistKitConfiguration, - tokenManager: any TokenManager - ) throws { - // Check if this is a server-to-server token manager - if tokenManager is ServerToServerAuthManager { - // Server-to-server authentication only supports the public database - guard configuration.database == .public else { - throw TokenManagerError.invalidCredentials( - .serverToServerOnlySupportsPublicDatabase(configuration.database.rawValue) - ) - } - } - } -} diff --git a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift b/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift index 44be062c..1887d1c0 100644 --- a/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift +++ b/Sources/MistKit/MistKitConfiguration+ConvenienceInitializers.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation extension MistKitConfiguration { /// Initialize configuration with API token only (container-level access) diff --git a/Sources/MistKit/MistKitConfiguration.swift b/Sources/MistKit/MistKitConfiguration.swift index 8371b28e..9f2ab0d2 100644 --- a/Sources/MistKit/MistKitConfiguration.swift +++ b/Sources/MistKit/MistKitConfiguration.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Configuration for MistKit client internal struct MistKitConfiguration: Sendable { diff --git a/Sources/MistKit/Protocols/CloudKitRecord.swift b/Sources/MistKit/Protocols/CloudKitRecord.swift index 8213ede3..06d02f3c 100644 --- a/Sources/MistKit/Protocols/CloudKitRecord.swift +++ b/Sources/MistKit/Protocols/CloudKitRecord.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Protocol for types that can be serialized to and from CloudKit records /// diff --git a/Sources/MistKit/Service/AssetUploadResponse.swift b/Sources/MistKit/Service/AssetUploadResponse.swift index 0f0fa24f..9aae7f15 100644 --- a/Sources/MistKit/Service/AssetUploadResponse.swift +++ b/Sources/MistKit/Service/AssetUploadResponse.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Response structure for CloudKit CDN asset upload /// diff --git a/Sources/MistKit/Service/CloudKitService+AssetOperations.swift b/Sources/MistKit/Service/CloudKitService+AssetOperations.swift index e17911bf..27460391 100644 --- a/Sources/MistKit/Service/CloudKitService+AssetOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+AssetOperations.swift @@ -75,7 +75,7 @@ extension CloudKitService { fieldName: String, recordName: String? = nil, using uploader: AssetUploader? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> AssetUploadReceipt { let maxSize: Int = 15 * 1_024 * 1_024 guard data.count <= maxSize else { @@ -138,9 +138,8 @@ extension CloudKitService { fieldName: String, recordName: String? = nil, zoneID: ZoneID? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> AssetUploadToken { - let effectiveDatabase = database ?? self.database do { let tokenRequest = Operations.uploadAssets.Input.Body @@ -155,10 +154,11 @@ extension CloudKitService { tokens: [tokenRequest] ) + let client = try self.client(for: database) let response = try await client.uploadAssets( path: createUploadAssetsPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ), body: .json(requestBody) ) diff --git a/Sources/MistKit/Service/CloudKitService+ClientDispatch.swift b/Sources/MistKit/Service/CloudKitService+ClientDispatch.swift new file mode 100644 index 00000000..b8ffdf7a --- /dev/null +++ b/Sources/MistKit/Service/CloudKitService+ClientDispatch.swift @@ -0,0 +1,74 @@ +// +// CloudKitService+ClientDispatch.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import OpenAPIRuntime + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension CloudKitService { + /// Resolve the token manager for an outgoing request and build a fresh + /// OpenAPI `Client` whose middleware chain authenticates against it. + /// + /// Called once per dispatched operation. When the service was built with a + /// caller-supplied `tokenManager:`, that fixed manager is used regardless of + /// `database` / `requiresUserContext`. Otherwise `Credentials` picks an + /// appropriate manager via its `makeTokenManager(for:requiresUserContext:)` + /// extension. + /// + /// - Throws: `CloudKitError.missingCredentials` when `Credentials` cannot + /// satisfy the requested combination. + internal func client( + for database: Database, + requiresUserContext: Bool = false + ) throws -> Client { + let tokenManager: any TokenManager + if let fixedTokenManager { + tokenManager = fixedTokenManager + } else if let credentials { + tokenManager = try credentials.makeTokenManager( + for: database, + requiresUserContext: requiresUserContext + ) + } else { + throw CloudKitError.missingCredentials( + database: database, + reason: "service has neither credentials nor a fixed token manager" + ) + } + + return Client( + serverURL: URL.MistKit.cloudKitAPI, + transport: transport, + middlewares: [ + AuthenticationMiddleware(tokenManager: tokenManager), + LoggingMiddleware(), + ] + ) + } +} diff --git a/Sources/MistKit/Service/CloudKitService+Initialization.swift b/Sources/MistKit/Service/CloudKitService+Initialization.swift index 4a69a68d..c08954cc 100644 --- a/Sources/MistKit/Service/CloudKitService+Initialization.swift +++ b/Sources/MistKit/Service/CloudKitService+Initialization.swift @@ -40,107 +40,49 @@ public import OpenAPIRuntime extension CloudKitService { /// Initialize CloudKit service with `Credentials`. /// - /// Accepts any combination of `serverToServer` and `apiAuth` material. - /// The token manager is selected based on the target `database` and what - /// credentials are populated: + /// Accepts any combination of `serverToServer` and `apiAuth` material. The + /// service does **not** carry a database — every operation that supports + /// multiple databases takes a `database:` argument at the call site, and + /// the appropriate token manager is resolved from `credentials` per call. /// - /// - `.public`: prefers `serverToServer`. Falls back to `apiAuth` (web-auth - /// if `webAuthToken` is set, otherwise API-token-only). - /// - `.private` / `.shared`: requires `apiAuth.webAuthToken`. CloudKit - /// rejects server-to-server signing for these databases, so any - /// `serverToServer` credential is ignored on this code path. + /// Provide both credential sets when a single service must serve, for + /// example, public-database record operations via server-to-server signing + /// **and** user-identity routes (`fetchCaller`, `lookupUsers*`) via + /// web-auth — those are picked apart at dispatch time. /// - /// - Throws: `CloudKitError.missingCredentials` when the target `database` - /// cannot be served by any populated credential set, or any error from - /// `ServerToServerAuthManager.init` when the PEM is malformed. + /// Misconfiguration (no credential set covers a given call's database + + /// user-context combination) surfaces at call time as + /// `CloudKitError.missingCredentials`, not at construction. public init( containerIdentifier: String, credentials: Credentials, environment: Environment = .development, - database: Database = .public, transport: any ClientTransport - ) throws { - let tokenManager = try Self.makeTokenManager( - credentials: credentials, - database: database - ) - + ) { self.containerIdentifier = containerIdentifier self.environment = environment - self.database = database - - self.mistKitClient = try MistKitClient( - container: containerIdentifier, - environment: environment, - database: database, - tokenManager: tokenManager, - transport: transport - ) + self.credentials = credentials + self.fixedTokenManager = nil + self.transport = transport } /// Initialize CloudKit service with a caller-supplied `TokenManager`. /// + /// The supplied manager is used for **every** dispatched operation + /// regardless of database or whether the route requires user context. /// Useful for tests and bespoke auth setups where the standard - /// `Credentials`-driven token-manager selection isn't appropriate. + /// `Credentials`-driven per-call selection isn't appropriate. public init( containerIdentifier: String, tokenManager: any TokenManager, environment: Environment = .development, - database: Database = .private, transport: any ClientTransport - ) throws { + ) { self.containerIdentifier = containerIdentifier self.environment = environment - self.database = database - - self.mistKitClient = try MistKitClient( - container: containerIdentifier, - environment: environment, - database: database, - tokenManager: tokenManager, - transport: transport - ) - } - - internal static func makeTokenManager( - credentials: Credentials, - database: Database - ) throws -> any TokenManager { - switch database { - case .public: - if let s2s = credentials.serverToServer { - let pem = try s2s.privateKey.loadPEM() - return try ServerToServerAuthManager( - keyID: s2s.keyID, - pemString: pem - ) - } - if let api = credentials.apiAuth { - if let webAuthToken = api.webAuthToken { - return WebAuthTokenManager( - apiToken: api.apiToken, - webAuthToken: webAuthToken - ) - } - return try APITokenManager(apiToken: api.apiToken) - } - throw CloudKitError.missingCredentials( - database: .public, - reason: "expected serverToServer or apiAuth credentials" - ) - case .private, .shared: - guard let api = credentials.apiAuth, let webAuthToken = api.webAuthToken else { - throw CloudKitError.missingCredentials( - database: database, - reason: - "private and shared databases require apiAuth with a webAuthToken" - ) - } - return WebAuthTokenManager( - apiToken: api.apiToken, - webAuthToken: webAuthToken - ) - } + self.credentials = nil + self.fixedTokenManager = tokenManager + self.transport = transport } } @@ -159,14 +101,12 @@ extension CloudKitService { public init( containerIdentifier: String, credentials: Credentials, - environment: Environment = .development, - database: Database = .public - ) throws { - try self.init( + environment: Environment = .development + ) { + self.init( containerIdentifier: containerIdentifier, credentials: credentials, environment: environment, - database: database, transport: URLSessionTransport() ) } @@ -179,14 +119,12 @@ extension CloudKitService { public init( containerIdentifier: String, tokenManager: any TokenManager, - environment: Environment = .development, - database: Database = .private - ) throws { - try self.init( + environment: Environment = .development + ) { + self.init( containerIdentifier: containerIdentifier, tokenManager: tokenManager, environment: environment, - database: database, transport: URLSessionTransport() ) } diff --git a/Sources/MistKit/Service/CloudKitService+LookupOperations.swift b/Sources/MistKit/Service/CloudKitService+LookupOperations.swift index 8b88ef70..427b4b39 100644 --- a/Sources/MistKit/Service/CloudKitService+LookupOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+LookupOperations.swift @@ -39,15 +39,15 @@ extension CloudKitService { internal func modifyRecords( operations: [Components.Schemas.RecordOperation], atomic: Bool = true, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> [RecordInfo] { - let effectiveDatabase = database ?? self.database do { + let client = try self.client(for: database) let response = try await client.modifyRecords( .init( path: createModifyRecordsPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ), body: .json( .init( @@ -70,15 +70,15 @@ extension CloudKitService { public func lookupRecords( recordNames: [String], desiredKeys: [String]? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> [RecordInfo] { - let effectiveDatabase = database ?? self.database do { + let client = try self.client(for: database) let response = try await client.lookupRecords( .init( path: createLookupRecordsPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService+Operations.swift b/Sources/MistKit/Service/CloudKitService+Operations.swift index 4150906a..0e770694 100644 --- a/Sources/MistKit/Service/CloudKitService+Operations.swift +++ b/Sources/MistKit/Service/CloudKitService+Operations.swift @@ -95,7 +95,8 @@ extension CloudKitService { filters: [QueryFilter]? = nil, sortBy: [QuerySort]? = nil, limit: Int? = nil, - desiredKeys: [String]? = nil + desiredKeys: [String]? = nil, + database: Database = .public ) async throws(CloudKitError) -> [RecordInfo] { let result: QueryResult = try await queryRecords( recordType: recordType, @@ -103,7 +104,8 @@ extension CloudKitService { sortBy: sortBy, limit: limit, desiredKeys: desiredKeys, - continuationMarker: nil + continuationMarker: nil, + database: database ) return result.records } @@ -147,10 +149,9 @@ extension CloudKitService { limit: Int? = nil, desiredKeys: [String]? = nil, continuationMarker: String? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> QueryResult { let effectiveLimit = limit ?? defaultQueryLimit - let effectiveDatabase = database ?? self.database guard !recordType.isEmpty else { throw CloudKitError.httpErrorWithRawResponse( @@ -175,11 +176,12 @@ extension CloudKitService { } do { + let client = try self.client(for: database) let response = try await client.queryRecords( .init( path: createQueryRecordsPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService+QueryPagination.swift b/Sources/MistKit/Service/CloudKitService+QueryPagination.swift index 2c780f9e..d4c11b41 100644 --- a/Sources/MistKit/Service/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/Service/CloudKitService+QueryPagination.swift @@ -59,7 +59,7 @@ extension CloudKitService { pageSize: Int? = nil, desiredKeys: [String]? = nil, maxPages: Int = 1_000, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> [RecordInfo] { var allRecords: [RecordInfo] = [] var currentMarker: String? diff --git a/Sources/MistKit/Service/CloudKitService+SyncOperations.swift b/Sources/MistKit/Service/CloudKitService+SyncOperations.swift index 7c7218e7..14332655 100644 --- a/Sources/MistKit/Service/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+SyncOperations.swift @@ -81,9 +81,8 @@ extension CloudKitService { zoneID: ZoneID? = nil, syncToken: String? = nil, resultsLimit: Int? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> RecordChangesResult { - let effectiveDatabase = database ?? self.database if let limit = resultsLimit { guard limit > 0 && limit <= 200 else { throw CloudKitError.httpErrorWithRawResponse( @@ -97,11 +96,12 @@ extension CloudKitService { let effectiveZoneID = zoneID ?? .defaultZone do { + let client = try self.client(for: database) let response = try await client.fetchRecordChanges( .init( path: createFetchRecordChangesPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ), body: .json( .init( @@ -160,7 +160,7 @@ extension CloudKitService { zoneID: ZoneID? = nil, syncToken: String? = nil, resultsLimit: Int? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> (records: [RecordInfo], syncToken: String?) { var allRecords: [RecordInfo] = [] var currentToken = syncToken diff --git a/Sources/MistKit/Service/CloudKitService+UserOperations.swift b/Sources/MistKit/Service/CloudKitService+UserOperations.swift index 3a926f8b..a702bb3c 100644 --- a/Sources/MistKit/Service/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+UserOperations.swift @@ -43,11 +43,14 @@ extension CloudKitService { /// Fetch the caller's (current authenticated user's) information. /// /// Hits CloudKit's `users/caller` endpoint, which replaces the deprecated - /// `users/current`. Requires public-database routing with web-auth credentials - /// (user-context auth); calling against the private database returns - /// `BAD_REQUEST: endpoint not applicable in the database type`. + /// `users/current`. Routed against the public database with web-auth + /// credentials — calling against private/shared returns + /// `BAD_REQUEST: endpoint not applicable in the database type`, so the + /// database is fixed in the path and not exposed to callers. The service's + /// `Credentials` must include an `apiAuth` with a `webAuthToken`. public func fetchCaller() async throws(CloudKitError) -> UserInfo { do { + let client = try self.client(for: .public, requiresUserContext: true) let response = try await client.getCaller( .init( path: createGetCallerPath( @@ -76,27 +79,18 @@ extension CloudKitService { /// Discover all user identities in the caller's CloudKit address book. /// - /// Hits CloudKit's GET `users/discover` endpoint. Requires public-database - /// routing with web-auth credentials (user-context auth); only users who have - /// run the app and granted discoverability are returned. + /// Hits CloudKit's GET `users/discover` endpoint. Routed against the public + /// database with web-auth credentials. /// - /// > Important: Marked `unavailable` until #28 is resolved. Live testing - /// > on 2026-05-08 against `iCloud.com.brightdigit.MistDemo` returned - /// > HTTP 500 from Apple. The GET form of `/users/discover` is referenced - /// > in CloudKitJS but does not appear in Apple's CloudKit Web Services - /// > REST documentation, and the live endpoint did not respond - /// > successfully. The OpenAPI definition, generated client, path - /// > builder, response processor, and Swift wrapper are all in place; - /// > unblocking is a one-line `@available` removal once the correct - /// > REST shape is determined. Tracking: - /// > [#28](https://github.com/brightdigit/MistKit/issues/28). + /// > Important: Marked `unavailable` until #28 is resolved — see issue for + /// > the live-testing investigation log. @available( *, unavailable, - message: - "Not yet ready: live testing on 2026-05-08 returned HTTP 500 from Apple's GET /users/discover. The REST request shape is still under investigation. See #28." + message: "Not yet ready: GET /users/discover returns HTTP 500 in live testing. See #28." ) public func discoverAllUserIdentities() async throws(CloudKitError) -> [UserIdentity] { do { + let client = try self.client(for: .public, requiresUserContext: true) let response = try await client.discoverAllUserIdentities( .init( path: createDiscoverAllUserIdentitiesPath( @@ -119,12 +113,13 @@ extension CloudKitService { /// Look up user identities by email address. /// /// Hits CloudKit's POST `users/lookup/email` endpoint. Each requested email - /// returns at most one identity in the result array. Requires public-database - /// routing with web-auth credentials (user-context auth). + /// returns at most one identity in the result array. Routed against the + /// public database with web-auth credentials. public func lookupUsersByEmail( _ emails: [String] ) async throws(CloudKitError) -> [UserIdentity] { do { + let client = try self.client(for: .public, requiresUserContext: true) let response = try await client.lookupUsersByEmail( .init( path: createLookupUsersByEmailPath( @@ -147,12 +142,13 @@ extension CloudKitService { /// Look up user identities by record name (CloudKit user record ID). /// - /// Hits CloudKit's POST `users/lookup/id` endpoint. Requires public-database - /// routing with web-auth credentials (user-context auth). + /// Hits CloudKit's POST `users/lookup/id` endpoint. Routed against the + /// public database with web-auth credentials. public func lookupUsersByRecordName( _ recordNames: [String] ) async throws(CloudKitError) -> [UserIdentity] { do { + let client = try self.client(for: .public, requiresUserContext: true) let response = try await client.lookupUsersByRecordName( .init( path: createLookupUsersByRecordNamePath( @@ -173,11 +169,15 @@ extension CloudKitService { } } - /// Discover user identities by email addresses or record names + /// Discover user identities by email addresses or record names. + /// + /// Hits CloudKit's POST `users/discover` endpoint. Routed against the public + /// database with web-auth credentials. public func discoverUserIdentities( lookupInfos: [UserIdentityLookupInfo] ) async throws(CloudKitError) -> [UserIdentity] { do { + let client = try self.client(for: .public, requiresUserContext: true) let response = try await client.discoverUserIdentities( .init( path: createDiscoverUserIdentitiesPath( diff --git a/Sources/MistKit/Service/CloudKitService+WriteOperations.swift b/Sources/MistKit/Service/CloudKitService+WriteOperations.swift index c9d88f84..2cf7874c 100644 --- a/Sources/MistKit/Service/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+WriteOperations.swift @@ -49,21 +49,21 @@ extension CloudKitService { public func modifyRecords( _ operations: [RecordOperation], atomic: Bool = false, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> [RecordInfo] { - let effectiveDatabase = database ?? self.database do { let apiOperations = try operations.map { try Components.Schemas.RecordOperation(from: $0) } + let client = try self.client(for: database) let response = try await client.modifyRecords( .init( path: .init( version: "1", container: containerIdentifier, environment: .init(from: environment), - database: .init(from: effectiveDatabase) + database: .init(from: database) ), body: .json( .init( @@ -97,7 +97,7 @@ extension CloudKitService { recordType: String, recordName: String? = nil, fields: [String: FieldValue], - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.create( recordType: recordType, @@ -125,7 +125,7 @@ extension CloudKitService { recordName: String, fields: [String: FieldValue], recordChangeTag: String? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.update( recordType: recordType, @@ -151,7 +151,7 @@ extension CloudKitService { recordType: String, recordName: String, recordChangeTag: String? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) { let operation = RecordOperation.delete( recordType: recordType, diff --git a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift b/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift index bf8cbe8f..ba6d7023 100644 --- a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift @@ -46,15 +46,15 @@ extension CloudKitService { /// > so listing zones against `.public` is degenerate. Use `.private` or /// > `.shared` for meaningful results. public func listZones( - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> [ZoneInfo] { - let effectiveDatabase = database ?? self.database do { + let client = try self.client(for: database) let response = try await client.listZones( .init( path: createListZonesPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ) ) ) @@ -97,9 +97,8 @@ extension CloudKitService { /// ``` public func lookupZones( zoneIDs: [ZoneID], - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> [ZoneInfo] { - let effectiveDatabase = database ?? self.database guard !zoneIDs.isEmpty else { throw CloudKitError.httpErrorWithRawResponse( statusCode: 400, @@ -114,11 +113,12 @@ extension CloudKitService { } do { + let client = try self.client(for: database) let response = try await client.lookupZones( .init( path: createLookupZonesPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ), body: .json( .init( @@ -171,15 +171,15 @@ extension CloudKitService { /// ``` public func fetchZoneChanges( syncToken: String? = nil, - database: Database? = nil + database: Database = .public ) async throws(CloudKitError) -> ZoneChangesResult { - let effectiveDatabase = database ?? self.database do { + let client = try self.client(for: database) let response = try await client.fetchZoneChanges( .init( path: createFetchZoneChangesPath( containerIdentifier: containerIdentifier, - database: effectiveDatabase + database: database ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService.swift b/Sources/MistKit/Service/CloudKitService.swift index 464e5a3b..8031f18d 100644 --- a/Sources/MistKit/Service/CloudKitService.swift +++ b/Sources/MistKit/Service/CloudKitService.swift @@ -46,6 +46,12 @@ internal import OpenAPIRuntime /// **per call** on each operation that supports multiple databases; user-identity /// endpoints (e.g. `fetchCaller`) hard-code `.public` since CloudKit only /// accepts those routes against the public database. +/// +/// At dispatch time the service resolves the appropriate token manager from +/// `Credentials` based on the target database and whether the operation +/// requires user-context auth. A single service can therefore serve, for +/// example, public-database record reads via server-to-server signing **and** +/// `fetchCaller` via web-auth from one fully-populated `Credentials`. @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct CloudKitService: Sendable { /// CloudKit's maximum number of records returned per query/modify request. @@ -56,19 +62,24 @@ public struct CloudKitService: Sendable { /// The CloudKit environment (development or production) public let environment: Environment - /// Default database used by operation methods when the caller does not - /// supply a `database:` argument. Set at construction; operations may - /// override per-call via their `database:` parameter. - internal let database: Database - /// Default limit for query operations (1-200, default: 100) internal let defaultQueryLimit: Int = 100 - internal let mistKitClient: MistKitClient internal let responseProcessor = CloudKitResponseProcessor() - internal var client: Client { - mistKitClient.client - } + + /// Resolved at construction from `Credentials`. `nil` when this service + /// was built with a caller-supplied fixed `tokenManager`. + internal let credentials: Credentials? + + /// Caller-supplied token manager that overrides per-call resolution. + /// Set by the bespoke `tokenManager:` initializer for tests and special + /// cases; otherwise `nil`. + internal let fixedTokenManager: (any TokenManager)? + + /// Transport used for every dispatched request. Each operation builds a + /// fresh OpenAPI `Client` against this transport with the resolved token + /// manager wired into its middleware chain. + internal let transport: any ClientTransport } // MARK: - Path builders diff --git a/Sources/MistKit/Service/ZoneID.swift b/Sources/MistKit/Service/ZoneID.swift index 07b7b0a7..7fe7697c 100644 --- a/Sources/MistKit/Service/ZoneID.swift +++ b/Sources/MistKit/Service/ZoneID.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation +internal import Foundation /// Identifies a specific CloudKit zone /// diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift new file mode 100644 index 00000000..e6ff6100 --- /dev/null +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift @@ -0,0 +1,195 @@ +// +// CredentialsTokenManagerTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Crypto +import Foundation +import Testing + +@testable import MistKit + +/// Direct unit coverage for `Credentials.makeTokenManager(for:requiresUserContext:)`. +/// +/// Each `CloudKitService` operation calls this resolver to pick a token +/// manager based on the target database and whether the route requires +/// user-context auth. The test cases below cover every cell of the routing +/// matrix: the four combinations on `.public` plus the two error cases on +/// `.private`/`.shared`, and the user-context branch. +@Suite("Credentials.makeTokenManager", .enabled(if: Platform.isCryptoAvailable)) +internal struct CredentialsTokenManagerTests { + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + private static func makeServerToServerCredentials() -> ServerToServerCredentials { + let pem = P256.Signing.PrivateKey().pemRepresentation + return ServerToServerCredentials( + keyID: "test-key-id-12345678", + privateKey: .raw(pem) + ) + } + + private static func makeAPICredentialsWithWebAuth() -> APICredentials { + APICredentials( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + } + + private static func makeAPICredentialsTokenOnly() -> APICredentials { + APICredentials(apiToken: TestConstants.apiToken) + } + + // MARK: - .public + + @Test(".public + serverToServer → ServerToServerAuthManager") + internal func publicPicksServerToServer() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("ServerToServerAuthManager is not available on this operating system.") + return + } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials() + ) + let manager = try credentials.makeTokenManager(for: .public) + #expect(manager is ServerToServerAuthManager) + } + + @Test(".public + apiAuth.webAuthToken → WebAuthTokenManager") + internal func publicPicksWebAuthOverAPIToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsWithWebAuth()) + let manager = try credentials.makeTokenManager(for: .public) + #expect(manager is WebAuthTokenManager) + } + + @Test(".public + apiAuth (token only) → APITokenManager") + internal func publicPicksAPITokenWhenNoWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsTokenOnly()) + let manager = try credentials.makeTokenManager(for: .public) + #expect(manager is APITokenManager) + } + + @Test(".public + serverToServer prefers S2S over apiAuth") + internal func publicPrefersServerToServerOverAPIAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials(), + apiAuth: Self.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager(for: .public) + #expect(manager is ServerToServerAuthManager) + } + + // MARK: - .private / .shared + + @Test(".private + apiAuth.webAuthToken → WebAuthTokenManager") + internal func privatePicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsWithWebAuth()) + let manager = try credentials.makeTokenManager(for: .private) + #expect(manager is WebAuthTokenManager) + } + + @Test(".shared + apiAuth.webAuthToken → WebAuthTokenManager") + internal func sharedPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsWithWebAuth()) + let manager = try credentials.makeTokenManager(for: .shared) + #expect(manager is WebAuthTokenManager) + } + + @Test(".private + serverToServer only → throws missingCredentials") + internal func privateRejectsServerToServerOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .private) + } + } + + @Test(".shared + serverToServer only → throws missingCredentials") + internal func sharedRejectsServerToServerOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .shared) + } + } + + @Test(".private + apiAuth without webAuthToken → throws missingCredentials") + internal func privateRejectsAPITokenOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsTokenOnly()) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .private) + } + } + + // MARK: - User-context branch + + @Test("requiresUserContext on .public → WebAuthTokenManager") + internal func userContextOnPublicPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials(), + apiAuth: Self.makeAPICredentialsWithWebAuth() + ) + let manager = try credentials.makeTokenManager( + for: .public, requiresUserContext: true + ) + // S2S is present, but user-context routes ignore it — must pick web-auth. + #expect(manager is WebAuthTokenManager) + } + + @Test("requiresUserContext without web-auth → throws missingCredentials") + internal func userContextWithoutWebAuthThrows() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager( + for: .public, requiresUserContext: true + ) + } + } + + @Test("requiresUserContext with apiAuth (token only) → throws missingCredentials") + internal func userContextWithAPITokenOnlyThrows() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsTokenOnly()) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager( + for: .public, requiresUserContext: true + ) + } + } +} diff --git a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Configuration.swift b/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Configuration.swift deleted file mode 100644 index 2f1a0d52..00000000 --- a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Configuration.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// MistKitClientTests+Configuration.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension MistKitClientTests { - @Suite("Configuration") - internal struct Configuration { - @Test( - "MistKitClient supports all environments", - arguments: [ - Environment.development, - Environment.production, - ] - ) - internal func supportsAllEnvironments( - environment: Environment - ) throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: environment, - database: .public, - apiToken: String(repeating: "3", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - @Test( - "MistKitClient supports all databases with API token", - arguments: [ - Database.public, - Database.private, - Database.shared, - ] - ) - internal func supportsAllDatabases(database: Database) throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: .development, - database: database, - apiToken: String(repeating: "4", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - @Test("MistKitClient accepts various container formats") - internal func acceptsVariousContainerFormats() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let containers = [ - TestConstants.appContainerIdentifier, - "iCloud.com.example.MyApp", - "iCloud.com.company.product", - ] - - for container in containers { - let config = MistKitConfiguration( - container: container, - environment: .development, - database: .public, - apiToken: String(repeating: "5", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - } - } -} diff --git a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Initialization.swift b/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Initialization.swift deleted file mode 100644 index 0fd30b2d..00000000 --- a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+Initialization.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// MistKitClientTests+Initialization.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension MistKitClientTests { - @Suite("Initialization") - internal struct Initialization { - @Test("MistKitClient initializes with valid configuration and transport") - internal func initWithConfiguration() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: .development, - database: .public, - apiToken: String(repeating: "a", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - @Test("MistKitClient initializes with API token configuration") - internal func initWithAPIToken() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: .production, - database: .public, - apiToken: String(repeating: "f", count: 64) - ) - - let transport = MockTransport() - _ = try MistKitClient(configuration: config, transport: transport) - } - - @Test("MistKitClient initializes with custom TokenManager") - internal func initWithCustomTokenManager() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: .development, - database: .public, - apiToken: "" - ) - - let tokenManager = APITokenManager(apiToken: String(repeating: "b", count: 64)) - let transport = MockTransport() - - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - } - - @Test("MistKitClient initializes with individual parameters") - internal func initWithIndividualParameters() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let tokenManager = APITokenManager(apiToken: String(repeating: "c", count: 64)) - let transport = MockTransport() - - _ = try MistKitClient( - container: TestConstants.appContainerIdentifier, - environment: .development, - database: .public, - tokenManager: tokenManager, - transport: transport - ) - } - - @Test("MistKitClient allows ServerToServerAuthManager with public database") - internal func serverToServerWithPublicDatabase() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - let tokenManager = try ServerToServerAuthManager( - keyID: String(repeating: "e", count: 64), - pemString: privateKeyPEM - ) - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: .development, - database: .public, - apiToken: "" - ) - - let transport = MockTransport() - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - } - - @Test("MistKitClient rejects ServerToServerAuthManager with private database") - internal func serverToServerWithPrivateDatabase() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - let tokenManager = try ServerToServerAuthManager( - keyID: String(repeating: "f", count: 64), - pemString: privateKeyPEM - ) - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: .development, - database: .private, - apiToken: "" - ) - - let transport = MockTransport() - - do { - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - Issue.record("Expected TokenManagerError for server-to-server with private database") - } catch let error as TokenManagerError { - if case .invalidCredentials = error { - // Success - } else { - Issue.record("Expected invalidCredentials error, got \(error)") - } - } - } - } -} diff --git a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+ServerToServer.swift b/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+ServerToServer.swift deleted file mode 100644 index 7aadd42b..00000000 --- a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests+ServerToServer.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// MistKitClientTests+ServerToServer.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -import Foundation -import Testing - -@testable import MistKit - -extension MistKitClientTests { - @Suite("Server To Server") - internal struct ServerToServer { - @Test("MistKitClient rejects ServerToServerAuthManager with shared database") - internal func serverToServerWithSharedDatabase() throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - return - } - - let privateKeyPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2 - OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r - 1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G - -----END PRIVATE KEY----- - """ - - let tokenManager = try ServerToServerAuthManager( - keyID: String(repeating: "0", count: 64), - pemString: privateKeyPEM - ) - - let config = MistKitConfiguration( - container: TestConstants.appContainerIdentifier, - environment: .development, - database: .shared, - apiToken: "" - ) - - let transport = MockTransport() - - do { - _ = try MistKitClient( - configuration: config, - tokenManager: tokenManager, - transport: transport - ) - Issue.record("Expected TokenManagerError for server-to-server with shared database") - } catch let error as TokenManagerError { - if case .invalidCredentials = error { - // Success - } else { - Issue.record("Expected invalidCredentials error, got \(error)") - } - } - } - } -} diff --git a/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift index e04cbfc9..e03bae37 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceDiscoverUserIdentities/CloudKitServiceTests.DiscoverUserIdentities+Helpers.swift @@ -47,7 +47,12 @@ extension CloudKitServiceTests.DiscoverUserIdentities { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), transport: transport ) } @@ -58,7 +63,12 @@ extension CloudKitServiceTests.DiscoverUserIdentities { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Helpers.swift new file mode 100644 index 00000000..044f4d61 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Helpers.swift @@ -0,0 +1,132 @@ +// +// CloudKitServiceTests.FetchCaller+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import HTTPTypes +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchCaller { + private static let testAPIToken = TestConstants.apiToken + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeSuccessfulService( + userRecordName: String = "_user-caller", + firstName: String? = "Test", + lastName: String? = "User", + emailAddress: String? = "caller@example.com" + ) async throws -> CloudKitService { + let responseProvider = try ResponseProvider.successfulFetchCaller( + userRecordName: userRecordName, + firstName: firstName, + lastName: lastName, + emailAddress: emailAddress + ) + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + internal static func makeAuthErrorService() async throws -> CloudKitService { + let responseProvider = ResponseProvider.authenticationError() + let transport = MockTransport(responseProvider: responseProvider) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + } +} + +// MARK: - FetchCaller Response Builders + +extension ResponseProvider { + internal static func successfulFetchCaller( + userRecordName: String = "_user-caller", + firstName: String? = "Test", + lastName: String? = "User", + emailAddress: String? = "caller@example.com" + ) throws -> ResponseProvider { + ResponseProvider( + defaultResponse: try .successfulFetchCallerResponse( + userRecordName: userRecordName, + firstName: firstName, + lastName: lastName, + emailAddress: emailAddress + ) + ) + } +} + +extension ResponseConfig { + internal static func successfulFetchCallerResponse( + userRecordName: String = "_user-caller", + firstName: String? = "Test", + lastName: String? = "User", + emailAddress: String? = "caller@example.com" + ) throws -> ResponseConfig { + var fields: [String] = ["\"userRecordName\": \"\(userRecordName)\""] + if let firstName { + fields.append("\"firstName\": \"\(firstName)\"") + } + if let lastName { + fields.append("\"lastName\": \"\(lastName)\"") + } + if let emailAddress { + fields.append("\"emailAddress\": \"\(emailAddress)\"") + } + + let responseJSON = "{ \(fields.joined(separator: ", ")) }" + + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: responseJSON.data(using: .utf8), + error: nil + ) + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+SuccessCases.swift new file mode 100644 index 00000000..b36b2afc --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+SuccessCases.swift @@ -0,0 +1,80 @@ +// +// CloudKitServiceTests.FetchCaller+SuccessCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchCaller { + @Suite("Success Cases") + internal struct SuccessCases { + @Test("fetchCaller() returns the caller's user info") + internal func returnsCallerInfo() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchCaller.makeSuccessfulService( + userRecordName: "_user-caller", + firstName: "Test", + lastName: "User", + emailAddress: "caller@example.com" + ) + + let userInfo = try await service.fetchCaller() + + #expect(userInfo.userRecordName == "_user-caller") + #expect(userInfo.firstName == "Test") + #expect(userInfo.lastName == "User") + #expect(userInfo.emailAddress == "caller@example.com") + } + + @Test("fetchCaller() omits optional fields when absent in response") + internal func handlesOmittedOptionalFields() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchCaller.makeSuccessfulService( + userRecordName: "_user-anon", + firstName: nil, + lastName: nil, + emailAddress: nil + ) + + let userInfo = try await service.fetchCaller() + + #expect(userInfo.userRecordName == "_user-anon") + #expect(userInfo.firstName == nil) + #expect(userInfo.lastName == nil) + #expect(userInfo.emailAddress == nil) + } + } +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Validation.swift new file mode 100644 index 00000000..6016f062 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller+Validation.swift @@ -0,0 +1,81 @@ +// +// CloudKitServiceTests.FetchCaller+Validation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchCaller { + @Suite("Validation") + internal struct Validation { + @Test("fetchCaller() throws on authentication error") + internal func throwsOnAuthError() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.FetchCaller.makeAuthErrorService() + + await #expect(throws: CloudKitError.self) { + try await service.fetchCaller() + } + } + + @Test("fetchCaller() throws missingCredentials when web-auth is absent") + internal func throwsWhenWebAuthMissing() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // A service with API token only (no webAuthToken) cannot satisfy the + // user-context requirement of fetchCaller. The resolver should throw + // before any HTTP request is dispatched. + let provider = ResponseProvider( + defaultResponse: try .successfulFetchCallerResponse() + ) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials(apiToken: TestConstants.apiToken) + ), + transport: MockTransport(responseProvider: provider) + ) + + await #expect { + _ = try await service.fetchCaller() + } throws: { error in + guard let ckError = error as? CloudKitError, + case .missingCredentials = ckError + else { return false } + return true + } + } + } +} diff --git a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller.swift similarity index 82% rename from Tests/MistKitTests/Client/MistKitClient/MistKitClientTests.swift rename to Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller.swift index 0f51f00d..0a6c9566 100644 --- a/Tests/MistKitTests/Client/MistKitClient/MistKitClientTests.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchCaller/CloudKitServiceTests.FetchCaller.swift @@ -1,5 +1,5 @@ // -// MistKitClientTests.swift +// CloudKitServiceTests.FetchCaller.swift // MistKit // // Created by Leo Dion. @@ -27,7 +27,15 @@ // OTHER DEALINGS IN THE SOFTWARE. // +import Foundation import Testing -@Suite("MistKit Client") -internal enum MistKitClientTests {} +@testable import MistKit + +extension CloudKitServiceTests { + @Suite( + "CloudKitService FetchCaller Operations", + .enabled(if: Platform.isCryptoAvailable) + ) + internal enum FetchCaller {} +} diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift index 5925a425..af7babcf 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift @@ -47,7 +47,12 @@ extension CloudKitServiceTests.LookupUsersByEmail { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), transport: transport ) } @@ -58,7 +63,12 @@ extension CloudKitServiceTests.LookupUsersByEmail { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift index feb0b86f..50de841b 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift @@ -45,7 +45,12 @@ extension CloudKitServiceTests.LookupUsersByRecordName { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), transport: transport ) } @@ -56,7 +61,12 @@ extension CloudKitServiceTests.LookupUsersByRecordName { let transport = MockTransport(responseProvider: responseProvider) return try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, - credentials: Credentials(apiAuth: APICredentials(apiToken: testAPIToken)), + credentials: Credentials( + apiAuth: APICredentials( + apiToken: testAPIToken, + webAuthToken: TestConstants.webAuthToken + ) + ), transport: transport ) } diff --git a/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift index 3428620a..7b6d7304 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceTests+Helpers.swift @@ -46,7 +46,12 @@ extension CloudKitServiceTests { let transport = MockTransport(responseProvider: provider) return try CloudKitService( containerIdentifier: containerIdentifier, - credentials: Credentials(apiAuth: APICredentials(apiToken: apiToken)), + credentials: Credentials( + apiAuth: APICredentials( + apiToken: apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), transport: transport ) } diff --git a/openapi.yaml b/openapi.yaml index 9d1688a1..2a70fed2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -609,6 +609,7 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + # GET /users/discover — see #28 get: summary: Discover All User Identities description: | From 22a17603ceea1750500a7e15fe1de404ffbb4df8 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 9 May 2026 13:24:32 -0400 Subject: [PATCH 06/11] Per review on PR #315: listZones, lookupZones, fetchZoneChanges now default to .private since the public database only contains _defaultZone, making .public a degenerate default. MistDemo callers pass context.database / config.base.database explicitly so the --database flag still drives the test runs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also repairs MistDemo test breakage from 7debe8d: toUserContextCredentials() was removed but tests still referenced it; rewritten against the replacement surface (toPrimaryCredentials embeds apiAuth on .public, plus the new hasUserContextCredentials boolean). The CredentialsValidationTests suite was deleted — it asserted init-time validation that no longer exists under per-call credential resolution; the equivalent .missingCredentials behavior is covered in MistKitTests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/LookupZonesCommand.swift | 5 +- .../Phases/FetchZoneChangesPhase.swift | 2 +- .../Phases/FinalVerificationPhase.swift | 5 +- .../Integration/Phases/ListZonesPhase.swift | 2 +- .../Integration/Phases/LookupZonePhase.swift | 5 +- ...tionCredentialsTests+ToConfiguration.swift | 27 +++---- .../AuthenticationCredentialsTests.swift | 70 ------------------- .../CloudKitService+ZoneOperations.swift | 12 ++-- ...eTests.FetchZoneChanges+SuccessCases.swift | 13 ++-- ...iceTests.FetchZoneChanges+Validation.swift | 2 +- ...erviceTests.LookupZones+SuccessCases.swift | 9 ++- 11 files changed, 50 insertions(+), 102 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift index a1574a7f..3d38519e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift @@ -81,7 +81,10 @@ public struct LookupZonesCommand: MistDemoCommand, OutputFormatting { print(" - \(name)") } - let zones = try await service.lookupZones(zoneIDs: zoneIDs) + let zones = try await service.lookupZones( + zoneIDs: zoneIDs, + database: config.base.database + ) print("\n✅ Found \(zones.count) zone(s):") for zone in zones { print(" - \(zone.zoneName)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift index 898e2df1..f0c96345 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift @@ -42,7 +42,7 @@ internal struct FetchZoneChangesPhase: IntegrationPhase { print("\n\(Self.emoji) \(Self.title)") do { - let result = try await context.service.fetchZoneChanges() + let result = try await context.service.fetchZoneChanges(database: context.database) print("✅ Fetched \(result.zones.count) zone(s)") if context.verbose { for zone in result.zones { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift index 28b26d51..639f9a93 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift @@ -41,7 +41,10 @@ internal struct FinalVerificationPhase: IntegrationPhase { internal func run(input: NoState, context: PhaseContext) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - let finalZones = try await context.service.lookupZones(zoneIDs: [.defaultZone]) + let finalZones = try await context.service.lookupZones( + zoneIDs: [.defaultZone], + database: context.database + ) guard !finalZones.isEmpty else { throw IntegrationTestError.verificationFailed("Zone not found after operations") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift index 8ddcc5cf..4f6881a8 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift @@ -41,7 +41,7 @@ internal struct ListZonesPhase: IntegrationPhase { internal func run(input: NoState, context: PhaseContext) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - let zones = try await context.service.listZones() + let zones = try await context.service.listZones(database: context.database) guard !zones.isEmpty else { throw IntegrationTestError.zoneNotFound("(any zone)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift index 4b37d141..6ebbd1b9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift @@ -43,7 +43,10 @@ internal struct LookupZonePhase: IntegrationPhase { ) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - let zones = try await context.service.lookupZones(zoneIDs: [.defaultZone]) + let zones = try await context.service.lookupZones( + zoneIDs: [.defaultZone], + database: context.database + ) guard !zones.isEmpty else { throw IntegrationTestError.zoneNotFound("_defaultZone") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift index 68e6239a..1ec9d3af 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift @@ -166,15 +166,15 @@ extension AuthenticationCredentialsTests { } @Suite( - "MistDemoConfig.toUserContextCredentials", + "MistDemoConfig user-context credentials", .disabled( if: TestPlatform.isWasm32, "MistDemoConfig construction relies on Foundation IO unavailable on WASI" ) ) - internal struct ToUserContextCredentialsTests { - @Test("returns apiAuth with web-auth when configured") - internal func returnsAPIAuthWhenAvailable() async throws { + internal struct UserContextCredentialsTests { + @Test("public with web-auth embeds apiAuth alongside serverToServer") + internal func publicEmbedsAPIAuthWhenAvailable() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", database: .public, @@ -183,15 +183,15 @@ extension AuthenticationCredentialsTests { privateKey: MistKitClientFactoryTests.validPrivateKey ) - let userContext = config.toUserContextCredentials() - #expect(userContext != nil) - #expect(userContext?.apiAuth?.apiToken == "api") - #expect(userContext?.apiAuth?.webAuthToken == "web") - #expect(userContext?.serverToServer == nil) + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer != nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") + #expect(config.hasUserContextCredentials) } - @Test("returns nil when web-auth tokens are missing") - internal func returnsNilWhenWebAuthMissing() async throws { + @Test("public without web-auth produces credentials without apiAuth") + internal func publicOmitsAPIAuthWhenWebAuthMissing() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "", database: .public, @@ -200,7 +200,10 @@ extension AuthenticationCredentialsTests { privateKey: MistKitClientFactoryTests.validPrivateKey ) - #expect(config.toUserContextCredentials() == nil) + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer != nil) + #expect(credentials.apiAuth == nil) + #expect(!config.hasUserContextCredentials) } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift index eef44c7d..ff67c5f4 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift @@ -81,74 +81,4 @@ internal enum AuthenticationCredentialsTests { } } - @Suite("CloudKitService(credentials:database:) validation") - internal struct CredentialsValidationTests { - @Test( - "private + serverToServer-only credentials throws missingCredentials", - .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) - ) - internal func privateRejectsS2SOnly() throws { - let credentials = Credentials( - serverToServer: ServerToServerCredentials( - keyID: "k", - privateKey: .raw(MistKitClientFactoryTests.validPrivateKey) - ) - ) - #expect(throws: CloudKitError.self) { - _ = try CloudKitService( - containerIdentifier: "iCloud.test", - credentials: credentials, - environment: .development, - database: .private - ) - } - } - - @Test( - "shared + serverToServer-only credentials throws missingCredentials", - .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) - ) - internal func sharedRejectsS2SOnly() throws { - let credentials = Credentials( - serverToServer: ServerToServerCredentials( - keyID: "k", - privateKey: .raw(MistKitClientFactoryTests.validPrivateKey) - ) - ) - #expect(throws: CloudKitError.self) { - _ = try CloudKitService( - containerIdentifier: "iCloud.test", - credentials: credentials, - environment: .development, - database: .shared - ) - } - } - - @Test("private with apiAuth + webAuthToken constructs successfully") - internal func privateWithWebAuthSucceeds() throws { - let credentials = Credentials( - apiAuth: APICredentials(apiToken: "api", webAuthToken: "web") - ) - _ = try CloudKitService( - containerIdentifier: "iCloud.test", - credentials: credentials, - environment: .development, - database: .private - ) - } - - @Test("public with apiAuth-only constructs successfully") - internal func publicWithAPIOnlySucceeds() throws { - let credentials = Credentials( - apiAuth: APICredentials(apiToken: "api") - ) - _ = try CloudKitService( - containerIdentifier: "iCloud.test", - credentials: credentials, - environment: .development, - database: .public - ) - } - } } diff --git a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift b/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift index ba6d7023..a4d67583 100644 --- a/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+ZoneOperations.swift @@ -42,11 +42,11 @@ import OpenAPIRuntime extension CloudKitService { /// List zones in the target database. /// - /// > Note: Public database only contains the default zone (`_defaultZone`), - /// > so listing zones against `.public` is degenerate. Use `.private` or - /// > `.shared` for meaningful results. + /// > Note: The default is `.private` because the public database only + /// > contains the default zone (`_defaultZone`); listing zones against + /// > `.public` is degenerate. Pass `.shared` for the shared database. public func listZones( - database: Database = .public + database: Database = .private ) async throws(CloudKitError) -> [ZoneInfo] { do { let client = try self.client(for: database) @@ -97,7 +97,7 @@ extension CloudKitService { /// ``` public func lookupZones( zoneIDs: [ZoneID], - database: Database = .public + database: Database = .private ) async throws(CloudKitError) -> [ZoneInfo] { guard !zoneIDs.isEmpty else { throw CloudKitError.httpErrorWithRawResponse( @@ -171,7 +171,7 @@ extension CloudKitService { /// ``` public func fetchZoneChanges( syncToken: String? = nil, - database: Database = .public + database: Database = .private ) async throws(CloudKitError) -> ZoneChangesResult { do { let client = try self.client(for: database) diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift index b2548883..0117e691 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift @@ -46,7 +46,7 @@ extension CloudKitServiceTests.FetchZoneChanges { syncToken: "zone-token-xyz" ) - let result = try await service.fetchZoneChanges() + let result = try await service.fetchZoneChanges(database: .public) #expect(result.zones.count == 2) #expect(result.syncToken == "zone-token-xyz") @@ -62,7 +62,7 @@ extension CloudKitServiceTests.FetchZoneChanges { zoneCount: 1 ) - let result = try await service.fetchZoneChanges() + let result = try await service.fetchZoneChanges(database: .public) #expect(result.zones.first?.zoneName == "test-zone-0") } @@ -77,7 +77,7 @@ extension CloudKitServiceTests.FetchZoneChanges { zoneCount: 0 ) - let result = try await service.fetchZoneChanges() + let result = try await service.fetchZoneChanges(database: .public) #expect(result.zones.isEmpty) #expect(result.syncToken != nil) @@ -94,7 +94,10 @@ extension CloudKitServiceTests.FetchZoneChanges { syncToken: "new-token" ) - let result = try await service.fetchZoneChanges(syncToken: "previous-token") + let result = try await service.fetchZoneChanges( + syncToken: "previous-token", + database: .public + ) #expect(result.zones.count == 1) #expect(result.syncToken == "new-token") @@ -116,7 +119,7 @@ extension CloudKitServiceTests.FetchZoneChanges { transport: transport ) - let result = try await service.fetchZoneChanges() + let result = try await service.fetchZoneChanges(database: .public) #expect(result.zones.count == 1, "Zone with nil zoneID should be filtered out") #expect(result.zones.first?.zoneName == "valid-zone") diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift index 2da5bcdd..a9ecd454 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift @@ -44,7 +44,7 @@ extension CloudKitServiceTests.FetchZoneChanges { let service = try await CloudKitServiceTests.FetchZoneChanges.makeAuthErrorService() await #expect(throws: CloudKitError.self) { - try await service.fetchZoneChanges() + try await service.fetchZoneChanges(database: .public) } } } diff --git a/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+SuccessCases.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+SuccessCases.swift index e7c93160..bd13e5ea 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+SuccessCases.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupZones/CloudKitServiceTests.LookupZones+SuccessCases.swift @@ -44,7 +44,8 @@ extension CloudKitServiceTests.LookupZones { let service = try await CloudKitServiceTests.LookupZones.makeSuccessfulService(zoneCount: 1) let zones = try await service.lookupZones( - zoneIDs: [ZoneID(zoneName: "_defaultZone", ownerName: nil)] + zoneIDs: [ZoneID(zoneName: "_defaultZone", ownerName: nil)], + database: .public ) #expect(zones.count == 1) @@ -64,7 +65,8 @@ extension CloudKitServiceTests.LookupZones { ZoneID(zoneName: "zone1", ownerName: nil), ZoneID(zoneName: "zone2", ownerName: nil), ZoneID(zoneName: "zone3", ownerName: nil), - ] + ], + database: .public ) #expect(zones.count == 3) @@ -82,7 +84,8 @@ extension CloudKitServiceTests.LookupZones { let service = try await CloudKitServiceTests.LookupZones.makeSuccessfulService(zoneCount: 0) let zones = try await service.lookupZones( - zoneIDs: [ZoneID(zoneName: "nonexistent", ownerName: nil)] + zoneIDs: [ZoneID(zoneName: "nonexistent", ownerName: nil)], + database: .public ) #expect(zones.isEmpty) From 7436ce04689e93e2deb381baeda025e3b57e62cc Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 9 May 2026 13:57:30 -0400 Subject: [PATCH 07/11] #312: gate @available(*,unavailable) on processDiscoverAllUserIdentitiesResponse to Swift 6.2+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift 6.1 rejects calls to an unavailable function from within another unavailable function; 6.2 relaxed that rule. The internal helper processDiscoverAllUserIdentitiesResponse is unavailable in lockstep with its only caller — the also-unavailable CloudKitService.discoverAllUserIdentities() — which built fine on 6.2+ but failed on Swift 6.1 with: error: 'processDiscoverAllUserIdentitiesResponse' is unavailable: Pending #28: discoverAllUserIdentities is not yet ready. Wrap just the attribute in `#if swift(>=6.2)` so the body is shared and 6.1 compiles. Inline doc records the intent and the one-line cleanup (delete the #if/#endif) once 6.1 is dropped from the matrix. A `swiftlint:disable:next unavailable_function` is required because swiftlint does not evaluate #if and otherwise sees a fatalError-only function without the attribute. Verified: swift build + swift test pass on Swift 6.1.3 (Linux container) and on macOS Swift 6.2+ (475/475 tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Service/CloudKitResponseProcessor+Changes.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift index 2034991b..816a86c3 100644 --- a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift +++ b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift @@ -81,7 +81,16 @@ extension CloudKitResponseProcessor { /// `@available(*, unavailable)`, so this code is unreachable. When #28 is /// resolved, restore the protocol-generic implementation and re-add the /// `CloudKitResponseType` conformance for `Operations.discoverAllUserIdentities.Output`. - @available(*, unavailable, message: "Pending #28: discoverAllUserIdentities is not yet ready.") + /// + /// The `@available(*, unavailable)` attribute is gated to Swift 6.2+ because + /// Swift 6.1 rejects calls to an unavailable function from within another + /// unavailable function; 6.2 relaxed that rule. Once Swift 6.1 is dropped + /// from the support matrix, delete the `#if swift(>=6.2)`/`#endif` lines so + /// the attribute always applies. + #if swift(>=6.2) + @available(*, unavailable, message: "Pending #28: discoverAllUserIdentities is not yet ready.") + #endif + // swiftlint:disable:next unavailable_function internal func processDiscoverAllUserIdentitiesResponse( _ response: Operations.discoverAllUserIdentities.Output ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { From ab51eaec7b10b6271fecdbee236aaf0b1c95f8cf Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 9 May 2026 14:48:15 -0400 Subject: [PATCH 08/11] #315: split unhandled-response logging into debug (full body) + warning (type/status only) CodeQL's swift/cleartext-logging flagged the existing warning logs because lookupUsersByEmail(_:) propagates email-PII taint through the response object. Move full \(response) interpolation to .debug so the detail stays available for development without flowing into ops logs; keep .warning at type(of:) + HTTP status code only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Service/CloudKitError+OpenAPI.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/MistKit/Service/CloudKitError+OpenAPI.swift b/Sources/MistKit/Service/CloudKitError+OpenAPI.swift index 2280bd4f..6b65897f 100644 --- a/Sources/MistKit/Service/CloudKitError+OpenAPI.swift +++ b/Sources/MistKit/Service/CloudKitError+OpenAPI.swift @@ -205,9 +205,16 @@ extension CloudKitError { // Handle undocumented error if let statusCode = response.undocumentedStatusCode { - // Log warning but don't crash - undocumented status codes can occur + // Full body lives at debug level — may contain server-echoed request data + // (e.g. emails passed to lookupUsersByEmail). Warning stays sanitized so + // it can ship to ops/log aggregators without leaking PII. + MistKitLogger.logDebug( + "Unhandled response (HTTP \(statusCode)): \(response)", + logger: MistKitLogger.api, + shouldRedact: false + ) MistKitLogger.logWarning( - "Unhandled response status code: \(statusCode) - treating as generic HTTP error", + "Unhandled \(type(of: response)) (HTTP \(statusCode)) - treating as generic HTTP error", logger: MistKitLogger.api, shouldRedact: false ) @@ -215,8 +222,13 @@ extension CloudKitError { return } + MistKitLogger.logDebug( + "Unhandled response case: \(response)", + logger: MistKitLogger.api, + shouldRedact: false + ) MistKitLogger.logWarning( - "Unhandled response case: \(response) - treating as invalid response", + "Unhandled \(type(of: response)) - treating as invalid response", logger: MistKitLogger.api, shouldRedact: false ) From 2da4cb86acfe425983426fa701e352b79f3d57aa Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 9 May 2026 14:48:50 -0400 Subject: [PATCH 09/11] #312: add --lookup-email / CLOUDKIT_LOOKUP_EMAIL to exercise users/lookup/email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LookupUsersByEmailPhase previously skipped whenever fetchCaller() didn't return an email (which is the common case). Plumb a configurable lookup email through TestIntegrationConfig / TestPrivateConfig → PhaseContext so the phase can be driven against a known-discoverable iCloud account. Falls back to caller email, then to a clearer skip message naming the flag/env var. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/TestIntegrationCommand.swift | 8 +++++- .../Commands/TestPrivateCommand.swift | 8 +++++- .../Configuration/TestIntegrationConfig.swift | 11 ++++++-- .../Configuration/TestPrivateConfig.swift | 11 ++++++-- .../Integration/IntegrationTestRunner.swift | 5 +++- .../Integration/PhaseContext.swift | 5 ++++ .../Phases/LookupUsersByEmailPhase.swift | 28 ++++++++++++++++--- 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift index c00e876f..8c849d6a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift @@ -55,10 +55,15 @@ public struct TestIntegrationCommand: MistDemoCommand { --asset-size Asset size in KB (default: 100) --skip-cleanup Skip cleanup after test --verbose Run in verbose mode + --lookup-email Email for users/lookup/email phase + (CLOUDKIT_LOOKUP_EMAIL); must belong + to an iCloud account discoverable to + the caller, otherwise the phase skips EXAMPLES: mistdemo test-integration --verbose mistdemo test-integration --skip-cleanup --verbose + mistdemo test-integration --lookup-email me@example.com NOTES: - Requires CLOUDKIT_KEY_ID and CLOUDKIT_PRIVATE_KEY @@ -89,7 +94,8 @@ public struct TestIntegrationCommand: MistDemoCommand { recordCount: config.recordCount, assetSizeKB: config.assetSizeKB, skipCleanup: config.skipCleanup, - verbose: config.verbose + verbose: config.verbose, + lookupEmail: config.lookupEmail ) try await runner.runBasicWorkflow() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index d693c0f6..5d69f7f7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -55,10 +55,15 @@ public struct TestPrivateCommand: MistDemoCommand { --asset-size Asset size in KB (default: 100) --skip-cleanup Skip cleanup after test --verbose Run in verbose mode + --lookup-email Email for users/lookup/email phase + (CLOUDKIT_LOOKUP_EMAIL); must belong + to an iCloud account discoverable to + the caller, otherwise the phase skips EXAMPLES: mistdemo test-private --verbose mistdemo test-private --skip-cleanup --verbose + mistdemo test-private --lookup-email me@example.com NOTES: - Requires CLOUDKIT_API_TOKEN and @@ -88,7 +93,8 @@ public struct TestPrivateCommand: MistDemoCommand { recordCount: config.recordCount, assetSizeKB: config.assetSizeKB, skipCleanup: config.skipCleanup, - verbose: config.verbose + verbose: config.verbose, + lookupEmail: config.lookupEmail ) try await runner.runPrivateWorkflow() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestIntegrationConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestIntegrationConfig.swift index 491bdebf..02c0a227 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestIntegrationConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestIntegrationConfig.swift @@ -46,6 +46,9 @@ public struct TestIntegrationConfig: Sendable, ConfigurationParseable { public let skipCleanup: Bool /// Whether to enable verbose output. public let verbose: Bool + /// Optional email used by the lookup-users-by-email phase. Must belong to + /// an iCloud account discoverable to the caller; otherwise the phase skips. + public let lookupEmail: String? /// Creates a new instance. public init( @@ -53,13 +56,15 @@ public struct TestIntegrationConfig: Sendable, ConfigurationParseable { recordCount: Int = 10, assetSizeKB: Int = 100, skipCleanup: Bool = false, - verbose: Bool = false + verbose: Bool = false, + lookupEmail: String? = nil ) { self.base = base self.recordCount = recordCount self.assetSizeKB = assetSizeKB self.skipCleanup = skipCleanup self.verbose = verbose + self.lookupEmail = lookupEmail } /// Parse configuration from command line arguments. @@ -85,13 +90,15 @@ public struct TestIntegrationConfig: Sendable, ConfigurationParseable { configuration.bool(forKey: "skip.cleanup", default: false) let verbose = configuration.bool(forKey: "verbose", default: false) + let lookupEmail = configuration.string(forKey: "lookup.email") self.init( base: baseConfig, recordCount: recordCount, assetSizeKB: assetSizeKB, skipCleanup: skipCleanup, - verbose: verbose + verbose: verbose, + lookupEmail: lookupEmail ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift index d8fd0beb..d4a70ba6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift @@ -47,6 +47,9 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { public let skipCleanup: Bool /// Whether to enable verbose output. public let verbose: Bool + /// Optional email used by the lookup-users-by-email phase. Must belong to + /// an iCloud account discoverable to the caller; otherwise the phase skips. + public let lookupEmail: String? /// Creates a new instance. public init( @@ -54,13 +57,15 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { recordCount: Int = 10, assetSizeKB: Int = 100, skipCleanup: Bool = false, - verbose: Bool = false + verbose: Bool = false, + lookupEmail: String? = nil ) { self.base = base self.recordCount = recordCount self.assetSizeKB = assetSizeKB self.skipCleanup = skipCleanup self.verbose = verbose + self.lookupEmail = lookupEmail } /// Parse configuration from command line arguments. @@ -100,13 +105,15 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { configuration.bool(forKey: "skip.cleanup", default: false) let verbose = configuration.bool(forKey: "verbose", default: false) + let lookupEmail = configuration.string(forKey: "lookup.email") self.init( base: baseConfig, recordCount: recordCount, assetSizeKB: assetSizeKB, skipCleanup: skipCleanup, - verbose: verbose + verbose: verbose, + lookupEmail: lookupEmail ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift index 15809aa1..0a23d6d6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -45,6 +45,8 @@ internal struct IntegrationTestRunner { internal let assetSizeKB: Int internal let skipCleanup: Bool internal let verbose: Bool + /// Optional email forwarded to `PhaseContext.lookupEmail`. + internal let lookupEmail: String? /// Run the public-database workflow. internal func runBasicWorkflow() async throws { @@ -68,7 +70,8 @@ internal struct IntegrationTestRunner { recordCount: recordCount, assetSizeKB: assetSizeKB, skipCleanup: skipCleanup, - verbose: verbose + verbose: verbose, + lookupEmail: lookupEmail ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift index cb98568e..c5a8148c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift @@ -39,4 +39,9 @@ internal struct PhaseContext: Sendable { internal let assetSizeKB: Int internal let skipCleanup: Bool internal let verbose: Bool + /// Optional email address used by `LookupUsersByEmailPhase` to exercise + /// `users/lookup/email` against a known-discoverable iCloud account. When + /// nil, the phase falls back to the caller's own email (often unavailable) + /// and skips otherwise. + internal let lookupEmail: String? } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift index 4b84e656..3dcca32e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift @@ -30,8 +30,13 @@ import Foundation import MistKit -/// Calls POST `/users/lookup/email` with the caller's own email (when known) to -/// exercise the endpoint without depending on third-party data. +/// Calls POST `/users/lookup/email`. +/// +/// Prefers the email supplied via `PhaseContext.lookupEmail` +/// (`--lookup-email` / `CLOUDKIT_LOOKUP_EMAIL`) since CloudKit only resolves +/// addresses that belong to iCloud accounts discoverable to the caller. Falls +/// back to the caller's own email when the user-context endpoint exposes it, +/// and skips otherwise — `users/caller` doesn't always return an address. internal struct LookupUsersByEmailPhase: IntegrationPhase { internal typealias Input = UserInfo internal typealias Output = NoState @@ -45,13 +50,28 @@ internal struct LookupUsersByEmailPhase: IntegrationPhase { ) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - guard let email = input.emailAddress, !email.isEmpty else { + let email: String + let source: String + if let configured = context.lookupEmail, !configured.isEmpty { + email = configured + source = "configured --lookup-email" + } else if let callerEmail = input.emailAddress, !callerEmail.isEmpty { + email = callerEmail + source = "caller's own address" + } else { print( - "⏭️ Skipping — caller's email address is not available; cannot self-lookup." + """ + ⏭️ Skipping — no email available. Set --lookup-email or \ + CLOUDKIT_LOOKUP_EMAIL to exercise this phase. + """ ) return NoState() } + if context.verbose { + print(" Looking up: \(email) (\(source))") + } + let identities = try await context.service.lookupUsersByEmail([email]) print("✅ Looked up \(identities.count) identit\(identities.count == 1 ? "y" : "ies") by email") From b4671aa346eda092abfd04ff1a64a4c98466d7a6 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 9 May 2026 14:48:57 -0400 Subject: [PATCH 10/11] docs: point CLAUDE.md lint section at mise (and Scripts/lint.sh) swift-format / swiftlint / periphery are pinned in mise.toml; the previous "requires swiftlint installation" wording led to PATH lookups that fail in this repo. Replace with `mise exec --` invocations and flag the full ./Scripts/lint.sh pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 01b0c6d1..c9576735 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,14 +66,15 @@ swift test --parallel # Show test output swift test --verbose -# Format code (requires swift-format installation) -swift-format -i -r Sources/ Tests/ - -# Lint code (requires swiftlint installation) -swiftlint - -# Auto-fix linting issues -swiftlint --fix +# Format + lint +# swift-format, swiftlint, periphery, and swift-openapi-generator are pinned +# in mise.toml — do NOT invoke them from PATH directly. Run them THROUGH mise: +mise exec -- swift-format -i -r Sources/ Tests/ +mise exec -- swiftlint # lint +mise exec -- swiftlint --fix # auto-fix + +# Or run the full lint pipeline (build + swiftlint + header.sh + periphery): +./Scripts/lint.sh ``` ### MistDemo Commands From 2f66115e137385521ed2688e739d5d2bd2fb32a3 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Sat, 9 May 2026 15:17:34 -0400 Subject: [PATCH 11/11] =?UTF-8?q?#315:=20address=20review=20punch=20list?= =?UTF-8?q?=20=E2=80=94=20invalidPrivateKey,=20recoverable=20unavailable?= =?UTF-8?q?=20response,=20supportsUserContextPhases=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CloudKitError: add invalidPrivateKey(path:underlying:) so PEM-load failures carry the file path + original error instead of bare Foundation NSError. Wrap loadPEM() at the single call site in Credentials+TokenManager. Add PrivateKeyMaterial.filePath accessor for the diagnostic. - processDiscoverAllUserIdentitiesResponse: replace fatalError with throw CloudKitError.unsupportedOperationType so a stray Swift 6.1 caller (where the @available cascade does not apply) gets a recoverable error instead of a crash. - TestPrivateCommand: derive supportsUserContextPhases from config.base.hasUserContextCredentials, mirroring TestIntegrationCommand, so user-identity phases skip cleanly when web-auth env vars are absent. - toPrimaryCredentials: replace try? with do/catch + stderr INFO line so operators see when web-auth is missing on a .public setup. - CLAUDE.md: annotate discoverAllUserIdentities() as unavailable pending #28. - CredentialsTokenManagerTests: fill the missing routing-matrix branches (user-context × .private/.shared, .shared + token-only) and cover the new .invalidPrivateKey path. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- .../Commands/TestPrivateCommand.swift | 3 +- ...MistDemoConfig+DatabaseConfiguration.swift | 12 ++- .../AuthenticationCredentialsTests.swift | 1 - .../Credentials+TokenManager.swift | 16 +++- .../Authentication/PrivateKeyMaterial.swift | 13 +++ Sources/MistKit/Service/CloudKitError.swift | 8 +- .../CloudKitResponseProcessor+Changes.swift | 15 ++-- .../CredentialsTokenManagerTests.swift | 79 +++++++++++++++++++ 9 files changed, 135 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c9576735..db29e7b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,7 @@ MistKit/ | `CloudKitService+WriteOperations.swift` | `modifyRecords`, `createRecord`, `updateRecord`, `deleteRecord` | | `CloudKitService+ZoneOperations.swift` | `listZones`, `lookupZones(zoneIDs:)`, `fetchZoneChanges(syncToken:)` | | `CloudKitService+SyncOperations.swift` | `fetchRecordChanges(recordType:syncToken:)`, `fetchAllRecordChanges(recordType:syncToken:)` | -| `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()`, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)`, `fetchCurrentUser()` (deprecated, forwards to `fetchCaller`) | +| `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()` *(unavailable — pending #28)*, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)`, `fetchCurrentUser()` (deprecated, forwards to `fetchCaller`) | | `CloudKitService+AssetOperations.swift` | `uploadAssets`, `requestAssetUploadURL` | | `CloudKitService+AssetUpload.swift` | `uploadAssetData` | | `CloudKitService+RecordManaging.swift` | record-managing convenience surface | diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index 5d69f7f7..44b6ea03 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -84,10 +84,11 @@ public struct TestPrivateCommand: MistDemoCommand { // Private-database flows always carry web-auth credentials, so the same // service can also serve user-identity routes when this command needs // them. Per-call resolution picks the right token manager. + let supportsUserContextPhases = config.base.hasUserContextCredentials let runner = IntegrationTestRunner( service: service, - supportsUserContextPhases: true, + supportsUserContextPhases: supportsUserContextPhases, containerIdentifier: config.base.containerIdentifier, database: .private, recordCount: config.recordCount, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift index d2a7ff6c..b863680f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift @@ -49,7 +49,17 @@ extension MistDemoConfig { // Optional: also include web-auth so a single service can serve // user-identity routes (`fetchCaller`, `lookupUsers*`) alongside // S2S-signed record operations on `.public`. - let webAuth = try? resolveAPICredentials() + let webAuth: APICredentials? + do { + webAuth = try resolveAPICredentials() + } catch { + webAuth = nil + let line = + "INFO: Public-DB credentials resolved without web-auth — " + + "user-identity routes (fetchCaller, lookupUsers*) will be unavailable. " + + "Underlying: \(error.localizedDescription)\n" + FileHandle.standardError.write(Data(line.utf8)) + } return try Credentials(serverToServer: s2s, apiAuth: webAuth) case .private, .shared: let apiAuth = try resolveAPICredentials() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift index ff67c5f4..bfacb43c 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift @@ -80,5 +80,4 @@ internal enum AuthenticationCredentialsTests { } } } - } diff --git a/Sources/MistKit/Authentication/Credentials+TokenManager.swift b/Sources/MistKit/Authentication/Credentials+TokenManager.swift index 3ae4d134..d950b8f0 100644 --- a/Sources/MistKit/Authentication/Credentials+TokenManager.swift +++ b/Sources/MistKit/Authentication/Credentials+TokenManager.swift @@ -47,8 +47,10 @@ extension Credentials { /// `serverToServer` material is ignored on this path. /// /// - Throws: `CloudKitError.missingCredentials` when no populated credential - /// set can satisfy the requested combination, or any error from - /// `ServerToServerAuthManager.init` when the PEM is malformed. + /// set can satisfy the requested combination, + /// `CloudKitError.invalidPrivateKey` when a `.file(path:)` PEM cannot be + /// read, or any error from `ServerToServerAuthManager.init` when the PEM + /// is malformed. internal func makeTokenManager( for database: Database, requiresUserContext: Bool = false @@ -69,7 +71,15 @@ extension Credentials { switch database { case .public: if let s2s = serverToServer { - let pem = try s2s.privateKey.loadPEM() + let pem: String + do { + pem = try s2s.privateKey.loadPEM() + } catch { + throw CloudKitError.invalidPrivateKey( + path: s2s.privateKey.filePath, + underlying: error + ) + } return try ServerToServerAuthManager( keyID: s2s.keyID, pemString: pem diff --git a/Sources/MistKit/Authentication/PrivateKeyMaterial.swift b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift index 6cdd02b9..21b688bf 100644 --- a/Sources/MistKit/Authentication/PrivateKeyMaterial.swift +++ b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift @@ -40,6 +40,19 @@ public enum PrivateKeyMaterial: Sendable { case raw(String) case file(path: String) + /// The on-disk path when this material is `.file(path:)`, otherwise `nil`. + /// + /// Used by `CloudKitError.invalidPrivateKey` to attach a useful diagnostic + /// when `loadPEM()` fails on a missing or unreadable file. + public var filePath: String? { + switch self { + case .raw: + return nil + case .file(let path): + return path + } + } + /// Resolve the PEM text for this material. /// /// - Throws: Any error from the underlying file read when `.file(path:)` is diff --git a/Sources/MistKit/Service/CloudKitError.swift b/Sources/MistKit/Service/CloudKitError.swift index 3eae5d09..c05c7c9e 100644 --- a/Sources/MistKit/Service/CloudKitError.swift +++ b/Sources/MistKit/Service/CloudKitError.swift @@ -46,6 +46,7 @@ public enum CloudKitError: LocalizedError, Sendable { case unsupportedOperationType(String) case paginationLimitExceeded(maxPages: Int, recordsCollected: Int) case missingCredentials(database: Database, reason: String) + case invalidPrivateKey(path: String?, underlying: any Error) /// HTTP status code if this error originated from an HTTP response, otherwise nil. public var httpStatusCode: Int? { @@ -55,7 +56,8 @@ public enum CloudKitError: LocalizedError, Sendable { .httpErrorWithRawResponse(let statusCode, _): return statusCode case .invalidResponse, .underlyingError, .decodingError, .networkError, - .unsupportedOperationType, .paginationLimitExceeded, .missingCredentials: + .unsupportedOperationType, .paginationLimitExceeded, .missingCredentials, + .invalidPrivateKey: return nil } } @@ -128,6 +130,10 @@ public enum CloudKitError: LocalizedError, Sendable { case .missingCredentials(let database, let reason): return "Missing credentials for database '\(database.rawValue)': \(reason)" + case .invalidPrivateKey(let path, let underlying): + let location = path.map { "from '\($0)'" } ?? "from inline material" + return + "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" } } } diff --git a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift index 816a86c3..745ed98b 100644 --- a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift +++ b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift @@ -77,10 +77,12 @@ extension CloudKitResponseProcessor { /// Process discoverAllUserIdentities response. /// /// Marked unavailable in lockstep with `CloudKitService.discoverAllUserIdentities()`. - /// The body is intentionally a `fatalError`: the only caller is itself - /// `@available(*, unavailable)`, so this code is unreachable. When #28 is - /// resolved, restore the protocol-generic implementation and re-add the - /// `CloudKitResponseType` conformance for `Operations.discoverAllUserIdentities.Output`. + /// The body throws `CloudKitError.unsupportedOperationType` so any stray + /// caller (for example via `@testable import` under Swift 6.1, where the + /// `@available(*, unavailable)` cascade does not apply) gets a recoverable + /// error rather than a crash. When #28 is resolved, restore the + /// protocol-generic implementation and re-add the `CloudKitResponseType` + /// conformance for `Operations.discoverAllUserIdentities.Output`. /// /// The `@available(*, unavailable)` attribute is gated to Swift 6.2+ because /// Swift 6.1 rejects calls to an unavailable function from within another @@ -90,11 +92,12 @@ extension CloudKitResponseProcessor { #if swift(>=6.2) @available(*, unavailable, message: "Pending #28: discoverAllUserIdentities is not yet ready.") #endif - // swiftlint:disable:next unavailable_function internal func processDiscoverAllUserIdentitiesResponse( _ response: Operations.discoverAllUserIdentities.Output ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { - fatalError("discoverAllUserIdentities is not yet ready (pending #28)") + throw CloudKitError.unsupportedOperationType( + "discoverAllUserIdentities is not yet ready (pending #28)" + ) } /// Process lookupUsersByEmail response diff --git a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift index e6ff6100..90444a1f 100644 --- a/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift @@ -153,6 +153,15 @@ internal struct CredentialsTokenManagerTests { } } + @Test(".shared + apiAuth without webAuthToken → throws missingCredentials") + internal func sharedRejectsAPITokenOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsTokenOnly()) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager(for: .shared) + } + } + // MARK: - User-context branch @Test("requiresUserContext on .public → WebAuthTokenManager") @@ -192,4 +201,74 @@ internal struct CredentialsTokenManagerTests { ) } } + + @Test("requiresUserContext on .private + web-auth → WebAuthTokenManager") + internal func userContextOnPrivatePicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsWithWebAuth()) + let manager = try credentials.makeTokenManager( + for: .private, requiresUserContext: true + ) + #expect(manager is WebAuthTokenManager) + } + + @Test("requiresUserContext on .shared + web-auth → WebAuthTokenManager") + internal func userContextOnSharedPicksWebAuth() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials(apiAuth: Self.makeAPICredentialsWithWebAuth()) + let manager = try credentials.makeTokenManager( + for: .shared, requiresUserContext: true + ) + #expect(manager is WebAuthTokenManager) + } + + @Test("requiresUserContext on .private + S2S only → throws missingCredentials") + internal func userContextOnPrivateRejectsServerToServerOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager( + for: .private, requiresUserContext: true + ) + } + } + + @Test("requiresUserContext on .shared + S2S only → throws missingCredentials") + internal func userContextOnSharedRejectsServerToServerOnly() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let credentials = try Credentials( + serverToServer: Self.makeServerToServerCredentials() + ) + #expect(throws: CloudKitError.self) { + _ = try credentials.makeTokenManager( + for: .shared, requiresUserContext: true + ) + } + } + + // MARK: - Private-key load failure + + @Test(".public + S2S with unreadable PEM file → throws invalidPrivateKey") + internal func publicWithUnreadablePEMFileThrowsInvalidPrivateKey() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { return } + let missingPath = "/nonexistent/path/to/private-key-\(UUID().uuidString).pem" + let credentials = try Credentials( + serverToServer: ServerToServerCredentials( + keyID: "test-key-id-12345678", + privateKey: .file(path: missingPath) + ) + ) + do { + _ = try credentials.makeTokenManager(for: .public) + Issue.record("expected makeTokenManager to throw .invalidPrivateKey") + } catch let error as CloudKitError { + guard case .invalidPrivateKey(let path, _) = error else { + Issue.record("expected .invalidPrivateKey, got \(error)") + return + } + #expect(path == missingPath) + } + } }