diff --git a/CLAUDE.md b/CLAUDE.md index 3afcc1a5..db29e7b3 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 @@ -167,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` | `fetchCurrentUser()`, `discoverUserIdentities(lookupInfos:)` | +| `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 | @@ -179,7 +180,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 +353,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/CloudKit/MistKitClientFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift index 5a605322..2eb9c63d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/CloudKit/MistKitClientFactory.swift @@ -27,27 +27,36 @@ // 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` configured for `config.database`, choosing + /// auth material automatically based on the populated environment. /// - /// - `.public`: requires `CLOUDKIT_KEY_ID` + `CLOUDKIT_PRIVATE_KEY[_FILE]` - /// - `.private` / `.shared`: requires `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` + /// - `.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`. The resulting web-auth credentials cover + /// user-identity routes too (which CloudKit pins to `.public`). /// - /// 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 + /// 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 + /// 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,24 +71,19 @@ public struct MistKitClientFactory: Sendable { } return try create(from: config, tokenManager: makeBadCredentialsTokenManager()) } - let credentials = try config.toDatabaseCredentials() - let tokenManager = try credentials.makeTokenManager() + let credentials = try config.toPrimaryCredentials() return try CloudKitService( containerIdentifier: config.containerIdentifier, - tokenManager: tokenManager, - environment: config.environment, - database: credentials.database + credentials: credentials, + environment: config.environment ) #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), @@ -87,8 +91,8 @@ public struct MistKitClientFactory: Sendable { ) } - /// Create a CloudKitService with a caller-supplied TokenManager, targeting - /// `config.database`. + /// 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 @@ -101,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/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/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/Commands/TestIntegrationCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestIntegrationCommand.swift index 225386f8..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 @@ -75,15 +80,22 @@ public struct TestIntegrationCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { let service = try MistKitClientFactory.create(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, + supportsUserContextPhases: supportsUserContextPhases, containerIdentifier: config.base.containerIdentifier, database: config.base.database, 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 afcd5195..44b6ea03 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 @@ -76,15 +81,21 @@ public struct TestPrivateCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { let service = try MistKitClientFactory.create(for: config.base) + // 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: supportsUserContextPhases, containerIdentifier: config.base.containerIdentifier, database: .private, 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/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/DatabaseCredentials.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseCredentials.swift deleted file mode 100644 index bfc001d1..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DatabaseCredentials.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// DatabaseCredentials.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 database choice paired with the credentials required to access it. -/// -/// 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 - } - } - - /// 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 .publicDatabase(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+" - ) - } - let pem = try privateKey.loadPEM() - return try ServerToServerAuthManager(keyID: keyID, pemString: pem) - case .privateDatabase(let apiToken, let webAuthToken), - .sharedDatabase(let apiToken, let webAuthToken): - return WebAuthTokenManager(apiToken: apiToken, webAuthToken: webAuthToken) - } - } -} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseCredentials.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift similarity index 55% rename from Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseCredentials.swift rename to Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift index d496c088..b863680f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseCredentials.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. @@ -27,26 +27,58 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal 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 `Credentials` for the primary `CloudKitService` targeting + /// `self.database`. + /// + /// - `.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 toDatabaseCredentials() throws -> DatabaseCredentials { + internal func toPrimaryCredentials() throws -> Credentials { switch database { case .public: - return try toPublicCredentials() + let s2s = try resolveServerToServerCredentials() + // 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: 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: - return try toUserCredentials() + let apiAuth = try resolveAPICredentials() + return try Credentials(apiAuth: apiAuth) } } - private func toPublicCredentials() throws -> DatabaseCredentials { + /// Indicates whether `toPrimaryCredentials()` will produce credentials that + /// can satisfy user-identity endpoints (`fetchCaller`, `lookupUsers*`). + /// + /// 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 + + private func resolveServerToServerCredentials() throws -> ServerToServerCredentials { guard let keyID, !keyID.isEmpty else { throw ConfigurationError.missingRequired( "key.id", @@ -54,7 +86,7 @@ extension MistDemoConfig { ) } let material = try resolvePrivateKeyMaterial() - return .publicDatabase(keyID: keyID, privateKey: material) + return ServerToServerCredentials(keyID: keyID, privateKey: material) } private func resolvePrivateKeyMaterial() throws -> PrivateKeyMaterial { @@ -69,7 +101,7 @@ extension MistDemoConfig { ) } - private func toUserCredentials() throws -> DatabaseCredentials { + private func resolveAPICredentials() throws -> APICredentials { let resolvedAPIToken = AuthenticationHelper.resolveAPIToken(apiToken) guard !resolvedAPIToken.isEmpty else { throw ConfigurationError.missingRequired( @@ -86,14 +118,9 @@ 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 APICredentials( + apiToken: resolvedAPIToken, + webAuthToken: resolvedWebAuth + ) } } 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 14844b7c..0a23d6d6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -34,16 +34,27 @@ import MistKit /// dispatches to the appropriate `PhasedIntegrationTest` implementation. internal struct IntegrationTestRunner { internal let service: 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 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 { - try await PublicDatabaseTest(database: database).run(context: makeContext()) + let test = PublicDatabaseTest( + database: database, + includeUserContextPhases: supportsUserContextPhases + ) + try await test.run(context: makeContext()) } /// Run the private-database workflow. @@ -59,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/DiscoverUserIdentitiesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift index 5c3f74cb..f6fb5b4e 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. 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 @@ -44,7 +49,9 @@ internal struct DiscoverUserIdentitiesPhase: IntegrationPhase { print("\n\(Self.emoji) \(Self.title)") let lookupInfos = [UserIdentityLookupInfo(userRecordName: input.userRecordName)] - let identities = try await context.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/FetchCurrentUserPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift similarity index 71% rename from Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCurrentUserPhase.swift rename to Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift index a14c3975..327984e6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCurrentUserPhase.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,29 @@ 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**. 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 - 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() + let userInfo = try await context.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/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/LookupUsersByEmailPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift new file mode 100644 index 00000000..3dcca32e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift @@ -0,0 +1,87 @@ +// +// 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`. +/// +/// 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 + + 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)") + + 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 — 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") + + 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..3d3465c5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift @@ -0,0 +1,64 @@ +// +// 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)") + + let identities = try await context.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/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/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index b987f36e..3fbaac55 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 the public-database + // pipeline; the service resolves web-auth credentials per call when needed. 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..5b23f7db 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/*`). + /// 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 + ) { 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(LookupUsersByEmailPhase()) + phases.append(LookupUsersByRecordNamePhase()) + } + self.phases = phases } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests+ToDatabaseCredentials.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift similarity index 56% rename from Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests+ToDatabaseCredentials.swift rename to Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests+ToConfiguration.swift index f4c0cc7d..1ec9d3af 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests+ToDatabaseCredentials.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.toPrimaryCredentials", .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 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,20 +50,20 @@ 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 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 .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,15 +85,15 @@ extension DatabaseCredentialsTests { badCredentials: false ) - let creds = try config.toDatabaseCredentials() - guard case .publicDatabase(_, let material) = creds else { - Issue.record("Expected .publicDatabase, got \(creds)") + 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)") } } @@ -106,7 +106,7 @@ extension DatabaseCredentialsTests { ) do { - _ = try config.toDatabaseCredentials() + _ = try config.toPrimaryCredentials() Issue.record("Expected ConfigurationError.missingRequired") } catch let error as ConfigurationError { if case .missingRequired(let key, _) = error { @@ -125,7 +125,7 @@ extension DatabaseCredentialsTests { ) do { - _ = try config.toDatabaseCredentials() + _ = try config.toPrimaryCredentials() Issue.record("Expected ConfigurationError.missingRequired") } catch let error as ConfigurationError { if case .missingRequired(let key, _) = error { @@ -136,7 +136,7 @@ extension DatabaseCredentialsTests { } } - @Test("private database resolves into .privateDatabase") + @Test("private database resolves to apiAuth credentials with web-auth token") internal func privateHappyPath() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", @@ -144,16 +144,13 @@ extension DatabaseCredentialsTests { webAuthToken: "web" ) - let creds = try config.toDatabaseCredentials() - if case .privateDatabase(let api, let web) = creds { - #expect(api == "api") - #expect(web == "web") - } else { - Issue.record("Expected .privateDatabase, got \(creds)") - } + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer == nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") } - @Test("shared database resolves into .sharedDatabase") + @Test("shared database resolves to apiAuth credentials with web-auth token") internal func sharedHappyPath() async throws { let config = try await MistKitClientFactoryTests.makeConfig( apiToken: "api", @@ -161,13 +158,52 @@ extension DatabaseCredentialsTests { webAuthToken: "web" ) - let creds = try config.toDatabaseCredentials() - if case .sharedDatabase(let api, let web) = creds { - #expect(api == "api") - #expect(web == "web") - } else { - Issue.record("Expected .sharedDatabase, got \(creds)") - } + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer == nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") + } + } + + @Suite( + "MistDemoConfig user-context credentials", + .disabled( + if: TestPlatform.isWasm32, + "MistDemoConfig construction relies on Foundation IO unavailable on WASI" + ) + ) + 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, + webAuthToken: "web", + keyID: "k", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer != nil) + #expect(credentials.apiAuth?.apiToken == "api") + #expect(credentials.apiAuth?.webAuthToken == "web") + #expect(config.hasUserContextCredentials) + } + + @Test("public without web-auth produces credentials without apiAuth") + internal func publicOmitsAPIAuthWhenWebAuthMissing() async throws { + let config = try await MistKitClientFactoryTests.makeConfig( + apiToken: "", + database: .public, + webAuthToken: nil, + keyID: "k", + privateKey: MistKitClientFactoryTests.validPrivateKey + ) + + let credentials = try config.toPrimaryCredentials() + #expect(credentials.serverToServer != nil) + #expect(credentials.apiAuth == nil) + #expect(!config.hasUserContextCredentials) } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift similarity index 54% rename from Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests.swift rename to Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthenticationCredentialsTests.swift index 3eaf2f04..bfacb43c 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/DatabaseCredentialsTests.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("Credentials helpers") +internal enum AuthenticationCredentialsTests { @Suite("PrivateKeyMaterial") internal struct PrivateKeyMaterialTests { @Test("loadPEM raw returns content unchanged when no escapes present") @@ -71,89 +71,12 @@ internal enum DatabaseCredentialsTests { #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 { - _ = 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("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( - 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() - #expect(manager is WebAuthTokenManager) - } - - @Test( - "publicDatabase with malformed PEM surfaces the auth manager error", - .enabled(if: MistKitClientFactoryTests.isServerToServerSupported()) - ) - internal func publicWithBadPEMThrows() throws { - let creds = DatabaseCredentials.publicDatabase( - keyID: "test-key-id", - privateKey: .raw("not-a-real-pem") - ) - #expect(throws: (any Error).self) { - _ = try creds.makeTokenManager() + _ = try material.loadPEM() } } } 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..d950b8f0 --- /dev/null +++ b/Sources/MistKit/Authentication/Credentials+TokenManager.swift @@ -0,0 +1,115 @@ +// +// 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, + /// `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 + ) 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: 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 + ) + } + 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 new file mode 100644 index 00000000..906c1df4 --- /dev/null +++ b/Sources/MistKit/Authentication/Credentials.swift @@ -0,0 +1,65 @@ +// +// 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. +// + +/// 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. + /// + /// 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 + ) 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/Examples/MistDemo/Sources/MistDemoKit/Configuration/PrivateKeyMaterial.swift b/Sources/MistKit/Authentication/CredentialsValidationError.swift similarity index 63% rename from Examples/MistDemo/Sources/MistDemoKit/Configuration/PrivateKeyMaterial.swift rename to Sources/MistKit/Authentication/CredentialsValidationError.swift index c4eb20b9..494de0e3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/PrivateKeyMaterial.swift +++ b/Sources/MistKit/Authentication/CredentialsValidationError.swift @@ -1,6 +1,6 @@ // -// PrivateKeyMaterial.swift -// MistDemo +// CredentialsValidationError.swift +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -27,27 +27,17 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +public import Foundation -/// Source of a server-to-server private key — either inline PEM or a path to a `.pem` file. -internal enum PrivateKeyMaterial: Sendable { - case raw(String) - case file(path: String) +/// Construction-time validation errors for `Credentials`. +public enum CredentialsValidationError: LocalizedError, Sendable { + /// `Credentials` was constructed without any populated credential set. + case empty - internal func loadPEM() throws -> String { + public var errorDescription: 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)" - ) - } + 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/PrivateKeyMaterial.swift b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift new file mode 100644 index 00000000..21b688bf --- /dev/null +++ b/Sources/MistKit/Authentication/PrivateKeyMaterial.swift @@ -0,0 +1,68 @@ +// +// PrivateKeyMaterial.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 + +/// 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) + + /// 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 + /// 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): + return try String(contentsOfFile: path, encoding: .utf8) + } + } +} 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/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/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/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 ) diff --git a/Sources/MistKit/Service/CloudKitError.swift b/Sources/MistKit/Service/CloudKitError.swift index 713ab834..c05c7c9e 100644 --- a/Sources/MistKit/Service/CloudKitError.swift +++ b/Sources/MistKit/Service/CloudKitError.swift @@ -45,6 +45,8 @@ public enum CloudKitError: LocalizedError, Sendable { case networkError(URLError) 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? { @@ -54,7 +56,8 @@ public enum CloudKitError: LocalizedError, Sendable { .httpErrorWithRawResponse(let statusCode, _): return statusCode case .invalidResponse, .underlyingError, .decodingError, .networkError, - .unsupportedOperationType, .paginationLimitExceeded: + .unsupportedOperationType, .paginationLimitExceeded, .missingCredentials, + .invalidPrivateKey: return nil } } @@ -124,6 +127,13 @@ 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)" + 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 a992db88..745ed98b 100644 --- a/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift +++ b/Sources/MistKit/Service/CloudKitResponseProcessor+Changes.swift @@ -74,6 +74,70 @@ extension CloudKitResponseProcessor { } } + /// Process discoverAllUserIdentities response. + /// + /// Marked unavailable in lockstep with `CloudKitService.discoverAllUserIdentities()`. + /// 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 + /// 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 + internal func processDiscoverAllUserIdentitiesResponse( + _ response: Operations.discoverAllUserIdentities.Output + ) async throws(CloudKitError) -> Components.Schemas.DiscoverResponse { + throw CloudKitError.unsupportedOperationType( + "discoverAllUserIdentities is not yet ready (pending #28)" + ) + } + + /// 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+AssetOperations.swift b/Sources/MistKit/Service/CloudKitService+AssetOperations.swift index c1bef11f..27460391 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 = .public ) 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,7 +137,8 @@ extension CloudKitService { recordType: String, fieldName: String, recordName: String? = nil, - zoneID: ZoneID? = nil + zoneID: ZoneID? = nil, + database: Database = .public ) async throws(CloudKitError) -> AssetUploadToken { do { let tokenRequest = @@ -151,9 +154,11 @@ extension CloudKitService { tokens: [tokenRequest] ) + let client = try self.client(for: database) let response = try await client.uploadAssets( path: createUploadAssetsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + 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+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..c08954cc 100644 --- a/Sources/MistKit/Service/CloudKitService+Initialization.swift +++ b/Sources/MistKit/Service/CloudKitService+Initialization.swift @@ -27,148 +27,104 @@ // 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 + /// 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. + /// + /// 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. + /// + /// 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, - apiToken: String, - webAuthToken: String, + credentials: Credentials, + environment: Environment = .development, transport: any ClientTransport - ) throws { + ) { self.containerIdentifier = containerIdentifier - self.apiToken = apiToken - self.environment = .development - self.database = .private - - let config = MistKitConfiguration( - container: containerIdentifier, - environment: .development, - database: .private, - apiToken: apiToken, - webAuthToken: webAuthToken - ) - self.mistKitClient = try MistKitClient( - configuration: config, - transport: transport - ) + self.environment = environment + self.credentials = credentials + self.fixedTokenManager = nil + self.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, - transport: any ClientTransport - ) throws { - self.containerIdentifier = containerIdentifier - self.apiToken = apiToken - self.environment = .development - self.database = .public // API-only supports public database - - let config = MistKitConfiguration( - container: containerIdentifier, - environment: .development, - database: .public, // API-only supports public database - apiToken: apiToken, - webAuthToken: nil, - keyID: nil, - privateKeyData: nil - ) - self.mistKitClient = try MistKitClient( - configuration: config, - 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`. + /// + /// 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 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.apiToken = "" // Not used when providing TokenManager directly self.environment = environment - self.database = database - - self.mistKitClient = try MistKitClient( - container: containerIdentifier, - environment: environment, - database: database, - tokenManager: tokenManager, - transport: transport - ) + self.credentials = nil + self.fixedTokenManager = tokenManager + self.transport = transport } } // 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 + /// 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, - webAuthToken: String - ) throws { - try self.init( + credentials: Credentials, + environment: Environment = .development + ) { + self.init( containerIdentifier: containerIdentifier, - apiToken: apiToken, - webAuthToken: webAuthToken, - transport: URLSessionTransport() - ) - } - - /// Initialize CloudKit service with API-only 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 - ) throws { - try self.init( - containerIdentifier: containerIdentifier, - apiToken: apiToken, + credentials: credentials, + environment: environment, 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, - 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 69631db3..427b4b39 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 = .public ) async throws(CloudKitError) -> [RecordInfo] { do { + let client = try self.client(for: database) let response = try await client.modifyRecords( .init( path: createModifyRecordsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: database ), 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 = .public ) async throws(CloudKitError) -> [RecordInfo] { do { + let client = try self.client(for: database) let response = try await client.lookupRecords( .init( path: createLookupRecordsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: database ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService+Operations.swift b/Sources/MistKit/Service/CloudKitService+Operations.swift index ff66efc2..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 } @@ -146,7 +148,8 @@ extension CloudKitService { sortBy: [QuerySort]? = nil, limit: Int? = nil, desiredKeys: [String]? = nil, - continuationMarker: String? = nil + continuationMarker: String? = nil, + database: Database = .public ) async throws(CloudKitError) -> QueryResult { let effectiveLimit = limit ?? defaultQueryLimit @@ -173,10 +176,12 @@ extension CloudKitService { } do { + let client = try self.client(for: database) let response = try await client.queryRecords( .init( path: createQueryRecordsPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: database ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService+QueryPagination.swift b/Sources/MistKit/Service/CloudKitService+QueryPagination.swift index 6926b8da..d4c11b41 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 = .public ) 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..14332655 100644 --- a/Sources/MistKit/Service/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+SyncOperations.swift @@ -80,7 +80,8 @@ extension CloudKitService { public func fetchRecordChanges( zoneID: ZoneID? = nil, syncToken: String? = nil, - resultsLimit: Int? = nil + resultsLimit: Int? = nil, + database: Database = .public ) async throws(CloudKitError) -> RecordChangesResult { if let limit = resultsLimit { guard limit > 0 && limit <= 200 else { @@ -95,10 +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 + containerIdentifier: containerIdentifier, + database: database ), 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 = .public ) 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 f580bef2..a702bb3c 100644 --- a/Sources/MistKit/Service/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+UserOperations.swift @@ -40,32 +40,149 @@ 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`. 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 response = try await client.getCurrentUser( + let client = try self.client(for: .public, requiresUserContext: true) + let response = try await client.getCaller( .init( - path: createGetCurrentUserPath(containerIdentifier: containerIdentifier) + path: createGetCallerPath( + containerIdentifier: containerIdentifier, + database: .public + ) ) ) 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. Routed against the public + /// database with web-auth credentials. + /// + /// > Important: Marked `unavailable` until #28 is resolved — see issue for + /// > the live-testing investigation log. + @available( + *, unavailable, + 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( + containerIdentifier: containerIdentifier, + database: .public + ) + ) + ) + + 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. 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( + containerIdentifier: containerIdentifier, + database: .public + ), + 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. 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( + containerIdentifier: containerIdentifier, + database: .public + ), + 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") } } - /// 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( - 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..2cf7874c 100644 --- a/Sources/MistKit/Service/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/Service/CloudKitService+WriteOperations.swift @@ -48,13 +48,15 @@ extension CloudKitService { /// - Throws: CloudKitError if the operation fails public func modifyRecords( _ operations: [RecordOperation], - atomic: Bool = false + atomic: Bool = false, + database: Database = .public ) async throws(CloudKitError) -> [RecordInfo] { 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( @@ -94,7 +96,8 @@ extension CloudKitService { public func createRecord( recordType: String, recordName: String? = nil, - fields: [String: FieldValue] + fields: [String: FieldValue], + database: Database = .public ) 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 = .public ) 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 = .public ) 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..a4d67583 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: 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 = .private + ) async throws(CloudKitError) -> [ZoneInfo] { do { + let client = try self.client(for: database) let response = try await client.listZones( .init( - path: createListZonesPath(containerIdentifier: containerIdentifier) + path: createListZonesPath( + containerIdentifier: containerIdentifier, + database: database + ) ) ) @@ -86,7 +96,8 @@ extension CloudKitService { /// ) /// ``` public func lookupZones( - zoneIDs: [ZoneID] + zoneIDs: [ZoneID], + database: Database = .private ) async throws(CloudKitError) -> [ZoneInfo] { guard !zoneIDs.isEmpty else { throw CloudKitError.httpErrorWithRawResponse( @@ -102,10 +113,12 @@ extension CloudKitService { } do { + let client = try self.client(for: database) let response = try await client.lookupZones( .init( path: createLookupZonesPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: database ), body: .json( .init( @@ -157,13 +170,16 @@ extension CloudKitService { /// ) /// ``` public func fetchZoneChanges( - syncToken: String? = nil + syncToken: String? = nil, + database: Database = .private ) async throws(CloudKitError) -> ZoneChangesResult { do { + let client = try self.client(for: database) let response = try await client.fetchZoneChanges( .init( path: createFetchZoneChangesPath( - containerIdentifier: containerIdentifier + containerIdentifier: containerIdentifier, + database: database ), body: .json( .init( diff --git a/Sources/MistKit/Service/CloudKitService.swift b/Sources/MistKit/Service/CloudKitService.swift index af992603..8031f18d 100644 --- a/Sources/MistKit/Service/CloudKitService.swift +++ b/Sources/MistKit/Service/CloudKitService.swift @@ -27,18 +27,31 @@ // 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. +/// +/// 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. @@ -46,33 +59,37 @@ 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 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: - 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 getCurrentUser 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, + database: Database + ) -> Operations.getCaller.Input.Path { .init( version: "1", container: containerIdentifier, @@ -81,12 +98,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 +110,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 +122,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 +134,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 +146,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 +158,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 +170,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 +182,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 +194,45 @@ extension CloudKitService { ) } - /// Create a standard path for fetchZoneChanges requests - /// - Parameter containerIdentifier: The container identifier - /// - Returns: A configured path for the request + internal func createDiscoverAllUserIdentitiesPath( + containerIdentifier: String, + database: Database + ) -> Operations.discoverAllUserIdentities.Input.Path { + .init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ) + } + + internal func createLookupUsersByEmailPath( + containerIdentifier: String, + database: Database + ) -> Operations.lookupUsersByEmail.Input.Path { + .init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ) + } + + internal func createLookupUsersByRecordNamePath( + containerIdentifier: String, + database: Database + ) -> Operations.lookupUsersByRecordName.Input.Path { + .init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ) + } + internal func createFetchZoneChangesPath( - containerIdentifier: String + containerIdentifier: String, + database: Database ) -> Operations.fetchZoneChanges.Input.Path { .init( version: "1", 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/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..90444a1f --- /dev/null +++ b/Tests/MistKitTests/Authentication/Credentials/CredentialsTokenManagerTests.swift @@ -0,0 +1,274 @@ +// +// 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) + } + } + + @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") + 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 + ) + } + } + + @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) + } + } +} 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 14d8b46a..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, - 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, - 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/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..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") @@ -112,11 +115,11 @@ 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 ) - 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/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift new file mode 100644 index 00000000..af7babcf --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByEmail/CloudKitServiceTests.LookupUsersByEmail+Helpers.swift @@ -0,0 +1,75 @@ +// +// 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, + 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 + ) + } +} 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..50de841b --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceLookupUsersByRecordName/CloudKitServiceTests.LookupUsersByRecordName+Helpers.swift @@ -0,0 +1,73 @@ +// +// 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, + 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 + ) + } +} 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/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/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) 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..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, - apiToken: apiToken, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), 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 ) diff --git a/openapi.yaml b/openapi.yaml index 23efc408..2a70fed2 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,114 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + # GET /users/discover — see #28 + 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: