feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
Pod::Spec.new do |s|
s.name = 'ConsentOS'
s.version = '0.1.0'
s.summary = 'iOS consent management SDK for ConsentOS.'
s.description = <<~DESC
ConsentOS provides cookie and tracking consent management for iOS apps.
It handles consent collection, persistence, server synchronisation,
IAB TCF v2.2 string generation, and Google Consent Mode v2 signalling.
DESC
s.homepage = 'https://consentos.dev'
s.license = { :type => 'Elastic-2.0', :file => 'LICENSE' }
s.author = { 'ConsentOS' => 'hello@consentos.dev' }
s.ios.deployment_target = '15.0'
s.swift_version = '5.9'
s.source = {
:git => 'https://github.com/consentos/consentos.git',
:tag => "ios-sdk/#{s.version}"
}
# Core module — no external dependencies
s.subspec 'Core' do |core|
core.source_files = 'sdks/ios/ConsentOS/Sources/ConsentOSCore/**/*.swift'
end
# UI module — depends on Core, SwiftUI built-in
s.subspec 'UI' do |ui|
ui.source_files = 'sdks/ios/ConsentOS/Sources/ConsentOSUI/**/*.swift'
ui.dependency 'ConsentOS/Core'
ui.frameworks = 'SwiftUI', 'UIKit'
end
# Default subspecs
s.default_subspec = 'UI'
s.frameworks = 'Foundation'
end

View File

@@ -0,0 +1,38 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "ConsentOS",
platforms: [
.iOS(.v15),
.macOS(.v12) // macOS target for running tests via `swift test`
],
products: [
.library(
name: "ConsentOSCore",
targets: ["ConsentOSCore"]
),
.library(
name: "ConsentOSUI",
targets: ["ConsentOSUI"]
)
],
targets: [
.target(
name: "ConsentOSCore",
path: "Sources/ConsentOSCore"
),
.target(
name: "ConsentOSUI",
dependencies: ["ConsentOSCore"],
path: "Sources/ConsentOSUI"
),
.testTarget(
name: "ConsentOSCoreTests",
dependencies: ["ConsentOSCore"],
path: "Tests/ConsentOSCoreTests"
)
]
)

View File

@@ -0,0 +1,165 @@
import Foundation
// MARK: - Protocol
/// Abstracts the network layer for testability.
public protocol ConsentAPIProtocol: Sendable {
/// Fetches the effective site configuration from the API.
func fetchConfig(siteId: String) async throws -> ConsentConfig
/// Posts a consent record to the server.
func postConsent(_ payload: ConsentPayload) async throws
}
// MARK: - Errors
/// Errors that can be thrown by the CMP API client.
public enum ConsentAPIError: Error, LocalizedError {
case invalidURL
case unexpectedStatusCode(Int)
case decodingFailure(Error)
case networkFailure(Error)
public var errorDescription: String? {
switch self {
case .invalidURL:
return "The constructed API URL is invalid."
case .unexpectedStatusCode(let code):
return "The server returned an unexpected HTTP status code: \(code)."
case .decodingFailure(let underlying):
return "Failed to decode the server response: \(underlying.localizedDescription)"
case .networkFailure(let underlying):
return "A network error occurred: \(underlying.localizedDescription)"
}
}
}
// MARK: - Consent Payload
/// The request body sent when recording a consent event.
public struct ConsentPayload: Codable, Sendable {
public let siteId: String
public let visitorId: String
public let platform: String // Always "ios"
public let accepted: [String] // ConsentCategory raw values
public let rejected: [String]
public let consentedAt: Date
public let bannerVersion: String?
public let userAgent: String?
public let tcString: String?
public init(
siteId: String,
visitorId: String,
accepted: [ConsentCategory],
rejected: [ConsentCategory],
consentedAt: Date,
bannerVersion: String?,
userAgent: String? = nil,
tcString: String? = nil
) {
self.siteId = siteId
self.visitorId = visitorId
self.platform = "ios"
self.accepted = accepted.map(\.rawValue)
self.rejected = rejected.map(\.rawValue)
self.consentedAt = consentedAt
self.bannerVersion = bannerVersion
self.userAgent = userAgent
self.tcString = tcString
}
}
// MARK: - Live Implementation
/// URLSession-backed API client that communicates with the CMP API.
public final class ConsentAPI: ConsentAPIProtocol, @unchecked Sendable {
private let apiBase: URL
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
// MARK: - Initialiser
/// - Parameters:
/// - apiBase: The base URL of the CMP API (e.g. `https://api.example.com`).
/// - session: Defaults to `URLSession.shared`; inject a custom session in tests.
public init(apiBase: URL, session: URLSession = .shared) {
self.apiBase = apiBase
self.session = session
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
dec.keyDecodingStrategy = .convertFromSnakeCase
self.decoder = dec
let enc = JSONEncoder()
enc.dateEncodingStrategy = .iso8601
enc.keyEncodingStrategy = .convertToSnakeCase
self.encoder = enc
}
// MARK: - ConsentAPIProtocol
public func fetchConfig(siteId: String) async throws -> ConsentConfig {
let url = apiBase
.appendingPathComponent("api/v1/config/sites")
.appendingPathComponent(siteId)
.appendingPathComponent("effective")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent")
let (data, response) = try await performRequest(request)
try validateResponse(response)
do {
return try decoder.decode(ConsentConfig.self, from: data)
} catch {
throw ConsentAPIError.decodingFailure(error)
}
}
public func postConsent(_ payload: ConsentPayload) async throws {
let url = apiBase.appendingPathComponent("api/v1/consent")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(sdkUserAgent, forHTTPHeaderField: "User-Agent")
do {
request.httpBody = try encoder.encode(payload)
} catch {
throw ConsentAPIError.decodingFailure(error)
}
let (_, response) = try await performRequest(request)
try validateResponse(response)
}
// MARK: - Private Helpers
private func performRequest(_ request: URLRequest) async throws -> (Data, URLResponse) {
do {
return try await session.data(for: request)
} catch {
throw ConsentAPIError.networkFailure(error)
}
}
private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else { return }
guard (200..<300).contains(httpResponse.statusCode) else {
throw ConsentAPIError.unexpectedStatusCode(httpResponse.statusCode)
}
}
private var sdkUserAgent: String {
"ConsentOS-iOS/1.0.0 (Swift)"
}
}

View File

@@ -0,0 +1,101 @@
/// Consent categories matching the platform's taxonomy.
///
/// Each category maps to IAB TCF v2.2 purposes and Google Consent Mode consent types.
/// The `necessary` category is always granted and cannot be revoked by the user.
public enum ConsentCategory: String, CaseIterable, Codable, Sendable {
/// Strictly necessary cookies always allowed, no consent required.
case necessary
/// Functional / preference cookies (e.g. language, saved settings).
case functional
/// Analytics / statistics cookies (e.g. page views, session data).
case analytics
/// Marketing / advertising cookies (e.g. retargeting, personalised ads).
case marketing
/// Personalisation cookies (e.g. content recommendations).
case personalisation
// MARK: - TCF Mappings
/// IAB TCF v2.2 purpose IDs associated with this category.
///
/// Returns an empty array for `necessary`, which does not require consent purposes.
public var tcfPurposeIds: [Int] {
switch self {
case .necessary:
return []
case .functional:
return [1] // Store and/or access information on a device
case .analytics:
return [7, 8, 9, 10] // Measurement, market research, product development
case .marketing:
return [2, 3, 4] // Select basic/personalised ads, create ad profile
case .personalisation:
return [5, 6] // Create content profile, select personalised content
}
}
// MARK: - Google Consent Mode Mappings
/// Google Consent Mode v2 consent type string for this category.
///
/// Returns `nil` for categories that do not have a direct GCM mapping.
public var gcmConsentType: String? {
switch self {
case .necessary:
return nil
case .functional:
return "functionality_storage"
case .analytics:
return "analytics_storage"
case .marketing:
return "ad_storage"
case .personalisation:
return "personalization_storage"
}
}
// MARK: - Display
/// Human-readable display name for the category (British English).
public var displayName: String {
switch self {
case .necessary:
return "Strictly Necessary"
case .functional:
return "Functional"
case .analytics:
return "Analytics"
case .marketing:
return "Marketing"
case .personalisation:
return "Personalisation"
}
}
/// Brief description of the category for banner display.
public var displayDescription: String {
switch self {
case .necessary:
return "Essential for the website to function. These cannot be disabled."
case .functional:
return "Enable enhanced functionality such as remembering your preferences."
case .analytics:
return "Help us understand how visitors interact with the website."
case .marketing:
return "Used to deliver relevant advertisements and track ad campaign performance."
case .personalisation:
return "Allow us to personalise content based on your interests."
}
}
/// Whether consent for this category is required before storing data.
///
/// `necessary` is exempt from consent requirements under ePrivacy regulations.
public var requiresConsent: Bool {
self != .necessary
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
/// The effective site configuration loaded from the CMP API.
///
/// Maps to the response of `GET {apiBase}/api/v1/config/sites/{siteId}/effective`.
/// The configuration drives banner display, theming, blocking mode, and consent expiry.
public struct ConsentConfig: Codable, Sendable {
// MARK: - Top-level Fields
/// Unique identifier for the site.
public let siteId: String
/// Human-readable name for the site.
public let siteName: String
/// The blocking mode determining the default consent model.
public let blockingMode: BlockingMode
/// Consent validity in days. After expiry the banner is shown again.
public let consentExpiryDays: Int
/// The current version of this configuration.
/// Stored alongside consent records to detect when re-consent is needed.
public let bannerVersion: String
/// Banner display and theming configuration.
public let bannerConfig: BannerConfig
/// Available categories for this site.
public let categories: [CategoryConfig]
// MARK: - Initialiser
public init(
siteId: String,
siteName: String,
blockingMode: BlockingMode,
consentExpiryDays: Int,
bannerVersion: String,
bannerConfig: BannerConfig,
categories: [CategoryConfig]
) {
self.siteId = siteId
self.siteName = siteName
self.blockingMode = blockingMode
self.consentExpiryDays = consentExpiryDays
self.bannerVersion = bannerVersion
self.bannerConfig = bannerConfig
self.categories = categories
}
// MARK: - Blocking Mode
/// The consent model applied to visitors.
public enum BlockingMode: String, Codable, Sendable {
/// User must opt in before non-essential scripts run (GDPR default).
case optIn = "opt_in"
/// Non-essential scripts run by default; user may opt out (CCPA default).
case optOut = "opt_out"
/// Informational notice only; no blocking.
case informational
}
// MARK: - Banner Configuration
/// Visual and behavioural configuration for the consent banner.
public struct BannerConfig: Codable, Sendable {
/// Display mode controlling the banner layout.
public let displayMode: DisplayMode
/// Primary background colour as a hex string (e.g. `"#FFFFFF"`).
public let backgroundColor: String?
/// Primary text colour as a hex string.
public let textColor: String?
/// Accent colour used for buttons and highlights.
public let accentColor: String?
/// Text for the "Accept all" button.
public let acceptButtonText: String?
/// Text for the "Reject all" button.
public let rejectButtonText: String?
/// Text for the "Manage preferences" button.
public let manageButtonText: String?
/// The banner title text.
public let title: String?
/// The banner body copy.
public let description: String?
/// URL for the site's privacy policy.
public let privacyPolicyUrl: String?
public enum DisplayMode: String, Codable, Sendable {
case overlay
case bottomBanner = "bottom_banner"
case topBanner = "top_banner"
case cornerPopup = "corner_popup"
case inline
}
// Default-value initialiser used in tests and previews
public init(
displayMode: DisplayMode = .bottomBanner,
backgroundColor: String? = nil,
textColor: String? = nil,
accentColor: String? = nil,
acceptButtonText: String? = nil,
rejectButtonText: String? = nil,
manageButtonText: String? = nil,
title: String? = nil,
description: String? = nil,
privacyPolicyUrl: String? = nil
) {
self.displayMode = displayMode
self.backgroundColor = backgroundColor
self.textColor = textColor
self.accentColor = accentColor
self.acceptButtonText = acceptButtonText
self.rejectButtonText = rejectButtonText
self.manageButtonText = manageButtonText
self.title = title
self.description = description
self.privacyPolicyUrl = privacyPolicyUrl
}
}
// MARK: - Category Configuration
/// Per-category configuration as returned by the API.
public struct CategoryConfig: Codable, Sendable {
/// Machine-readable category key (matches ``ConsentCategory`` raw values).
public let key: String
/// Whether this category is enabled for this site.
public let enabled: Bool
/// Overridden display name (falls back to ``ConsentCategory/displayName`` if absent).
public let displayName: String?
/// Overridden description text.
public let description: String?
public init(key: String, enabled: Bool, displayName: String?, description: String?) {
self.key = key
self.enabled = enabled
self.displayName = displayName
self.description = description
}
/// Resolves to the matching ``ConsentCategory``, if the key is recognised.
public var category: ConsentCategory? {
ConsentCategory(rawValue: key)
}
}
// MARK: - Convenience
/// Returns only the enabled ``ConsentCategory`` values for this config.
public var enabledCategories: [ConsentCategory] {
categories.compactMap { cfg in
guard cfg.enabled else { return nil }
return cfg.category
}
}
}
// MARK: - Cached Config Wrapper
/// Wraps a ``ConsentConfig`` with metadata needed for cache invalidation.
public struct CachedConfig: Codable {
public let config: ConsentConfig
public let fetchedAt: Date
/// The cache TTL in seconds (10 minutes by default).
public static let ttl: TimeInterval = 600
public var isExpired: Bool {
Date().timeIntervalSince(fetchedAt) > CachedConfig.ttl
}
public init(config: ConsentConfig, fetchedAt: Date) {
self.config = config
self.fetchedAt = fetchedAt
}
}

View File

@@ -0,0 +1,306 @@
import Foundation
// MARK: - Delegate Protocol
/// Receive notifications when the user's consent choices change.
public protocol ConsentOSDelegate: AnyObject {
/// Called on the main thread after consent has been updated.
///
/// - Parameter state: The new consent state.
func consentDidChange(_ state: ConsentState)
}
// MARK: - ConsentOS
/// The main entry point for the CMP iOS SDK.
///
/// Use the shared singleton to configure the SDK, display the consent banner,
/// and query consent status. All public methods are safe to call from any thread.
///
/// ```swift
/// // In AppDelegate or @main App
/// ConsentOS.shared.configure(siteId: "my-site-id", apiBase: apiURL)
///
/// // Optionally register a delegate
/// ConsentOS.shared.delegate = self
///
/// // Show banner if needed
/// if await ConsentOS.shared.shouldShowBanner() {
/// await ConsentOS.shared.showBanner(on: rootViewController)
/// }
/// ```
public final class ConsentOS: @unchecked Sendable {
// MARK: - Singleton
/// The shared SDK instance. Configure this before use.
public static let shared = ConsentOS()
// MARK: - State
/// The site ID set during ``configure(siteId:apiBase:)``.
private(set) public var siteId: String?
/// Whether the SDK has been configured.
public var isConfigured: Bool { siteId != nil }
/// The current consent state. `nil` until storage has been read on first access.
private(set) public var consentState: ConsentState?
/// The currently loaded site configuration. `nil` until fetched.
private(set) public var siteConfig: ConsentConfig?
/// Delegate notified on consent changes. Weakly held.
public weak var delegate: (any ConsentOSDelegate)?
// MARK: - Dependencies
private var api: (any ConsentAPIProtocol)?
private var storage: any ConsentStorageProtocol
private var gcmBridge: GCMBridge
private let stateLock = NSLock()
// MARK: - Initialiser
/// Creates a new instance with the default storage and no-op GCM provider.
/// Inject custom dependencies for testing via ``init(storage:api:gcmBridge:)``.
public init(
storage: any ConsentStorageProtocol = ConsentStorage(),
api: (any ConsentAPIProtocol)? = nil,
gcmBridge: GCMBridge = GCMBridge()
) {
self.storage = storage
self.api = api
self.gcmBridge = gcmBridge
}
// MARK: - Configuration
/// Configures the SDK with a site ID and API base URL.
///
/// Must be called before any other method. Loads the persisted consent state
/// and fetches the site configuration in the background.
///
/// - Parameters:
/// - siteId: The unique identifier for the site (from the CMP dashboard).
/// - apiBase: Base URL of the CMP API (e.g. `https://api.example.com`).
/// - gcmProvider: Optional GCM analytics provider. Defaults to no-op.
public func configure(
siteId: String,
apiBase: URL,
gcmProvider: (any GCMAnalyticsProvider)? = nil
) {
stateLock.lock()
self.siteId = siteId
if self.api == nil {
self.api = ConsentAPI(apiBase: apiBase)
}
if let provider = gcmProvider {
self.gcmBridge = GCMBridge(provider: provider)
}
// Restore persisted state
let visitorId = storage.visitorId()
self.consentState = storage.loadState() ?? ConsentState(visitorId: visitorId)
stateLock.unlock()
// Fetch config in the background; apply GCM defaults once available
Task {
await refreshConfigIfNeeded()
}
}
/// Attaches a custom API client (useful for testing or custom transports).
public func setAPI(_ api: any ConsentAPIProtocol) {
stateLock.lock()
self.api = api
stateLock.unlock()
}
// MARK: - Banner Display
/// Returns `true` if the consent banner should be shown to this visitor.
///
/// The banner is required when:
/// - The user has not yet interacted (no `consentedAt`), or
/// - The stored banner version differs from the current config version (re-consent needed), or
/// - The stored consent is older than the site's configured expiry.
public func shouldShowBanner() async -> Bool {
await refreshConfigIfNeeded()
stateLock.lock()
let state = consentState
let config = siteConfig
stateLock.unlock()
guard let state else { return true }
guard state.hasInteracted else { return true }
if let config, let consentedAt = state.consentedAt {
// Check banner version mismatch
if state.bannerVersion != config.bannerVersion {
return true
}
// Check consent expiry
let expiryInterval = TimeInterval(config.consentExpiryDays * 86_400)
if Date().timeIntervalSince(consentedAt) > expiryInterval {
return true
}
}
return false
}
// MARK: - Consent Actions
/// Accepts all non-necessary consent categories.
///
/// Updates local state, syncs to the server, and fires GCM signals.
public func acceptAll() async {
await applyConsent { $0.acceptingAll() }
}
/// Rejects all non-necessary consent categories.
public func rejectAll() async {
await applyConsent { $0.rejectingAll() }
}
/// Accepts only the specified categories (and rejects all others).
///
/// - Parameter categories: The set of categories to accept.
public func acceptCategories(_ categories: Set<ConsentCategory>) async {
await applyConsent { $0.accepting(categories: categories) }
}
// MARK: - Query
/// Returns the consent status for a specific category.
///
/// - Returns: `true` if consent has been granted, `false` if denied or not yet given.
public func getConsentStatus(for category: ConsentCategory) -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
return consentState?.isGranted(category) ?? (category == .necessary)
}
// MARK: - User Identity
/// Associates the current visitor with a verified user identity.
///
/// The JWT is sent alongside subsequent consent records for server-side
/// correlation with authenticated users.
///
/// - Parameter jwt: A signed JWT issued by the host application's auth system.
public func identifyUser(jwt: String) {
// Store JWT for inclusion in future consent payloads.
// In a production implementation this would also re-sync the consent record.
UserDefaults.standard.set(jwt, forKey: "com.cmp.consent.userJwt")
}
// MARK: - Internal: Config Refresh
@discardableResult
func refreshConfigIfNeeded() async -> ConsentConfig? {
// Check cached config first
if let cached = storage.loadCachedConfig(), !cached.isExpired {
stateLock.lock()
self.siteConfig = cached.config
stateLock.unlock()
return cached.config
}
guard let siteId, let api else { return nil }
do {
let config = try await api.fetchConfig(siteId: siteId)
let cached = CachedConfig(config: config, fetchedAt: Date())
storage.saveCachedConfig(cached)
stateLock.lock()
self.siteConfig = config
// Stamp the banner version onto the current state
if var state = self.consentState {
state.bannerVersion = config.bannerVersion
self.consentState = state
}
stateLock.unlock()
// Apply GCM defaults for new visitors
gcmBridge.applyDefaults(config: config)
return config
} catch {
// Non-fatal the SDK can operate with cached or default config
return nil
}
}
// MARK: - Internal: Apply Consent
private func applyConsent(transform: (ConsentState) -> ConsentState) async {
stateLock.lock()
guard let current = consentState else {
stateLock.unlock()
return
}
var newState = transform(current)
// Stamp banner version
newState = ConsentState(
visitorId: newState.visitorId,
accepted: newState.accepted,
rejected: newState.rejected,
consentedAt: newState.consentedAt,
bannerVersion: siteConfig?.bannerVersion ?? current.bannerVersion
)
self.consentState = newState
stateLock.unlock()
// Persist locally
storage.saveState(newState)
// Signal GCM
gcmBridge.applyConsent(state: newState)
// Generate TC string
let tcString: String?
if let config = siteConfig {
tcString = TCFStringEncoder.encode(state: newState, config: config)
} else {
tcString = nil
}
// Sync to server (best-effort; failures are non-fatal)
await syncConsent(state: newState, tcString: tcString)
// Notify delegate on main thread
let delegateState = newState
await MainActor.run {
delegate?.consentDidChange(delegateState)
}
}
private func syncConsent(state: ConsentState, tcString: String?) async {
guard let siteId, let api, let consentedAt = state.consentedAt else { return }
let payload = ConsentPayload(
siteId: siteId,
visitorId: state.visitorId,
accepted: Array(state.accepted),
rejected: Array(state.rejected),
consentedAt: consentedAt,
bannerVersion: state.bannerVersion,
tcString: tcString
)
do {
try await api.postConsent(payload)
} catch {
// Consent is stored locally; server sync failure is non-fatal.
// In production, implement a retry queue here.
}
}
}
// Note: `showBanner(on:)` for UIKit is provided by the ConsentOSUI module.
// Import ConsentOSUI and call `ConsentOS.shared.showBanner(on:)` after adding
// that module as a dependency.

View File

@@ -0,0 +1,118 @@
import Foundation
/// Represents the complete consent state for a visitor.
///
/// This model mirrors the web consent cookie structure for cross-platform consistency.
/// It is persisted locally via ``ConsentStorage`` and synced to the server.
public struct ConsentState: Codable, Equatable, Sendable {
// MARK: - Properties
/// A stable, anonymous identifier for this device/visitor.
/// Generated once and persisted across sessions.
public let visitorId: String
/// The set of categories the visitor has explicitly accepted.
public var accepted: Set<ConsentCategory>
/// The set of categories the visitor has explicitly rejected.
public var rejected: Set<ConsentCategory>
/// The timestamp at which consent was last recorded.
public var consentedAt: Date?
/// The banner configuration version active when consent was collected.
/// Used to detect when consent must be re-collected after a config change.
public var bannerVersion: String?
/// Whether the user has interacted with the banner (accepted or rejected).
///
/// Returns `false` when the state represents the pre-consent default.
public var hasInteracted: Bool {
consentedAt != nil
}
// MARK: - Derived State
/// Returns `true` if the user has granted consent for the given category.
///
/// `necessary` is always considered granted regardless of the stored state.
public func isGranted(_ category: ConsentCategory) -> Bool {
guard category != .necessary else { return true }
return accepted.contains(category)
}
/// Returns `true` if the user has explicitly denied consent for the given category.
public func isDenied(_ category: ConsentCategory) -> Bool {
guard category != .necessary else { return false }
return rejected.contains(category)
}
// MARK: - Initialisers
/// Creates a new, blank consent state for the given visitor.
///
/// No categories are accepted or rejected; `consentedAt` is `nil`.
public init(visitorId: String) {
self.visitorId = visitorId
self.accepted = []
self.rejected = []
self.consentedAt = nil
self.bannerVersion = nil
}
/// Creates a fully populated consent state.
public init(
visitorId: String,
accepted: Set<ConsentCategory>,
rejected: Set<ConsentCategory>,
consentedAt: Date?,
bannerVersion: String?
) {
self.visitorId = visitorId
self.accepted = accepted
self.rejected = rejected
self.consentedAt = consentedAt
self.bannerVersion = bannerVersion
}
// MARK: - Mutations
/// Returns a new state with all non-necessary categories accepted.
public func acceptingAll() -> ConsentState {
let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent }
return ConsentState(
visitorId: visitorId,
accepted: Set(allOptional),
rejected: [],
consentedAt: Date(),
bannerVersion: bannerVersion
)
}
/// Returns a new state with all non-necessary categories rejected.
public func rejectingAll() -> ConsentState {
let allOptional = ConsentCategory.allCases.filter { $0.requiresConsent }
return ConsentState(
visitorId: visitorId,
accepted: [],
rejected: Set(allOptional),
consentedAt: Date(),
bannerVersion: bannerVersion
)
}
/// Returns a new state accepting only the specified categories (and rejecting the rest).
public func accepting(categories: Set<ConsentCategory>) -> ConsentState {
let allOptional = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
let toAccept = categories.filter { $0.requiresConsent }
let toReject = allOptional.subtracting(toAccept)
return ConsentState(
visitorId: visitorId,
accepted: toAccept,
rejected: toReject,
consentedAt: Date(),
bannerVersion: bannerVersion
)
}
}

View File

@@ -0,0 +1,101 @@
import Foundation
// MARK: - Protocol
/// Abstracts local persistence so the storage layer can be swapped in tests.
public protocol ConsentStorageProtocol: Sendable {
func loadState() -> ConsentState?
func saveState(_ state: ConsentState)
func clearState()
func loadCachedConfig() -> CachedConfig?
func saveCachedConfig(_ cached: CachedConfig)
func clearCachedConfig()
/// Loads or generates a stable visitor ID.
func visitorId() -> String
}
// MARK: - UserDefaults Implementation
/// Persists consent state and site configuration in `UserDefaults`.
///
/// All keys are namespaced under `com.cmp.consent` to avoid collisions.
public final class ConsentStorage: ConsentStorageProtocol, @unchecked Sendable {
// MARK: - Keys
private enum Keys {
static let consentState = "com.cmp.consent.state"
static let cachedConfig = "com.cmp.consent.config"
static let visitorId = "com.cmp.consent.visitorId"
}
// MARK: - Dependencies
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
// MARK: - Initialiser
/// Creates a storage instance backed by the given `UserDefaults` suite.
///
/// - Parameter suiteName: Pass a custom suite name to isolate storage per app group.
/// Defaults to `nil`, which uses `UserDefaults.standard`.
public init(suiteName: String? = nil) {
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
let enc = JSONEncoder()
enc.dateEncodingStrategy = .iso8601
self.encoder = enc
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
self.decoder = dec
}
// MARK: - ConsentState
public func loadState() -> ConsentState? {
guard let data = defaults.data(forKey: Keys.consentState) else { return nil }
return try? decoder.decode(ConsentState.self, from: data)
}
public func saveState(_ state: ConsentState) {
guard let data = try? encoder.encode(state) else { return }
defaults.set(data, forKey: Keys.consentState)
}
public func clearState() {
defaults.removeObject(forKey: Keys.consentState)
}
// MARK: - Cached Config
public func loadCachedConfig() -> CachedConfig? {
guard let data = defaults.data(forKey: Keys.cachedConfig) else { return nil }
return try? decoder.decode(CachedConfig.self, from: data)
}
public func saveCachedConfig(_ cached: CachedConfig) {
guard let data = try? encoder.encode(cached) else { return }
defaults.set(data, forKey: Keys.cachedConfig)
}
public func clearCachedConfig() {
defaults.removeObject(forKey: Keys.cachedConfig)
}
// MARK: - Visitor ID
/// Returns the persisted visitor ID, generating and saving a new UUID if absent.
public func visitorId() -> String {
if let existing = defaults.string(forKey: Keys.visitorId) {
return existing
}
let newId = UUID().uuidString
defaults.set(newId, forKey: Keys.visitorId)
return newId
}
}

View File

@@ -0,0 +1,128 @@
import Foundation
// MARK: - GCM Consent Types
/// The Google Consent Mode v2 consent type strings.
public enum GCMConsentType: String, CaseIterable, Sendable {
case analyticsStorage = "analytics_storage"
case adStorage = "ad_storage"
case adUserData = "ad_user_data"
case adPersonalisation = "ad_personalization"
case functionalityStorage = "functionality_storage"
case personalisationStorage = "personalization_storage"
case securityStorage = "security_storage"
}
/// The granted/denied status for a GCM consent type.
public enum GCMConsentStatus: String, Sendable {
case granted = "granted"
case denied = "denied"
}
// MARK: - GCM Analytics Provider Protocol
/// Abstracts the Firebase Analytics / Google Tag Manager call surface.
///
/// Conforming types should call the underlying GCM API. The default no-op
/// implementation is used when Firebase is not present.
public protocol GCMAnalyticsProvider: AnyObject, Sendable {
/// Sets the default consent state before any interaction.
func setConsentDefaults(_ defaults: [String: String])
/// Updates consent state after the user interacts with the banner.
func updateConsent(_ updates: [String: String])
}
// MARK: - No-Op Provider
/// A no-op ``GCMAnalyticsProvider`` used when Firebase Analytics is not linked.
public final class NoOpGCMAnalyticsProvider: GCMAnalyticsProvider, @unchecked Sendable {
public init() {}
public func setConsentDefaults(_ defaults: [String: String]) {}
public func updateConsent(_ updates: [String: String]) {}
}
// MARK: - GCM Bridge
/// Maps CMP consent state to Google Consent Mode v2 signals.
///
/// On app launch, call ``applyDefaults(config:)`` before the user interacts.
/// After consent is collected, call ``applyConsent(state:)`` to update GCM.
///
/// To integrate with Firebase Analytics, implement ``GCMAnalyticsProvider`` and
/// call the `Firebase.Analytics.setConsent(_:)` API inside it.
public final class GCMBridge: @unchecked Sendable {
// MARK: - Dependencies
private let provider: any GCMAnalyticsProvider
// MARK: - Initialiser
/// - Parameter provider: The analytics provider to forward consent signals to.
/// Defaults to a no-op implementation.
public init(provider: any GCMAnalyticsProvider = NoOpGCMAnalyticsProvider()) {
self.provider = provider
}
// MARK: - Public API
/// Sends default (pre-consent) consent signals to GCM based on the site's blocking mode.
///
/// Call this as early as possible ideally before any analytics events are sent.
///
/// - Parameter config: The effective site configuration.
public func applyDefaults(config: ConsentConfig) {
let defaults = buildDefaults(for: config.blockingMode)
provider.setConsentDefaults(defaults)
}
/// Updates GCM consent signals to reflect the user's explicit choices.
///
/// - Parameter state: The resolved consent state after user interaction.
public func applyConsent(state: ConsentState) {
let updates = buildConsentMap(from: state)
provider.updateConsent(updates)
}
// MARK: - Private Helpers
/// Builds the default GCM consent map based on blocking mode.
///
/// - `opt_in`: all types denied by default (GDPR).
/// - `opt_out`: all types granted by default (CCPA).
/// - `informational`: all types granted.
private func buildDefaults(for mode: ConsentConfig.BlockingMode) -> [String: String] {
let status: GCMConsentStatus = mode == .optIn ? .denied : .granted
return Dictionary(
uniqueKeysWithValues: GCMConsentType.allCases.map {
($0.rawValue, status.rawValue)
}
)
}
/// Maps the consent state's accepted/rejected categories to GCM consent type values.
private func buildConsentMap(from state: ConsentState) -> [String: String] {
var map: [String: String] = [:]
// security_storage is always granted it is necessary for security.
map[GCMConsentType.securityStorage.rawValue] = GCMConsentStatus.granted.rawValue
for category in ConsentCategory.allCases {
guard let gcmType = category.gcmConsentType else { continue }
let status: GCMConsentStatus = state.isGranted(category) ? .granted : .denied
map[gcmType] = status.rawValue
}
// ad_user_data and ad_personalization follow the marketing category.
let marketingGranted = state.isGranted(.marketing)
map[GCMConsentType.adUserData.rawValue] = marketingGranted
? GCMConsentStatus.granted.rawValue
: GCMConsentStatus.denied.rawValue
map[GCMConsentType.adPersonalisation.rawValue] = marketingGranted
? GCMConsentStatus.granted.rawValue
: GCMConsentStatus.denied.rawValue
return map
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
// MARK: - TCF String Encoder
/// Encodes a TC string (Transparency & Consent Framework v2.2) from consent state.
///
/// The TC string is a Base64url-encoded bit field described in the IAB TCF v2.2 specification.
/// This implementation encodes the core consent section (segment type 0) sufficient for
/// signalling purpose consent to downstream vendors.
///
/// Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
public final class TCFStringEncoder: Sendable {
// MARK: - Constants
/// TCF specification version.
private static let specVersion: Int = 2
/// A fixed CMP ID replace with your registered IAB CMP ID in production.
private static let cmpId: Int = 0
/// CMP SDK version number.
private static let cmpVersion: Int = 1
/// IAB consent language (en).
private static let consentLanguage: String = "EN"
/// Vendor list version. In production, this should be fetched from the GVL.
private static let vendorListVersion: Int = 1
/// Number of TCF purposes defined in the specification.
private static let tcfPurposeCount: Int = 24
// MARK: - Public API
/// Encodes a TC string for the given consent state and site configuration.
///
/// - Parameters:
/// - state: The resolved consent state containing accepted/rejected categories.
/// - config: The site configuration (used for CMP metadata).
/// - Returns: A Base64url-encoded TC string, or `nil` if encoding fails.
public static func encode(state: ConsentState, config: ConsentConfig) -> String? {
guard state.hasInteracted, let consentedAt = state.consentedAt else {
return nil
}
// Derive the set of consented TCF purpose IDs from accepted categories.
let consentedPurposeIds: Set<Int> = state.accepted.reduce(into: []) { result, category in
category.tcfPurposeIds.forEach { result.insert($0) }
}
return buildCoreString(
consentedAt: consentedAt,
consentedPurposeIds: consentedPurposeIds
)
}
// MARK: - Core String Construction
private static func buildCoreString(
consentedAt: Date,
consentedPurposeIds: Set<Int>
) -> String? {
var bits = BitWriter()
// --- Core segment fields (IAB TCF v2.2 spec, Table 1) ---
// Version (6 bits)
bits.write(specVersion, bitCount: 6)
// Created deciseconds since epoch (36 bits)
let deciseconds = Int(consentedAt.timeIntervalSince1970 * 10)
bits.write(deciseconds, bitCount: 36)
// LastUpdated deciseconds since epoch (36 bits)
bits.write(deciseconds, bitCount: 36)
// CmpId (12 bits)
bits.write(cmpId, bitCount: 12)
// CmpVersion (12 bits)
bits.write(cmpVersion, bitCount: 12)
// ConsentScreen (6 bits) screen number within the CMP UI
bits.write(1, bitCount: 6)
// ConsentLanguage (12 bits) two 6-bit characters, A=0 Z=25
let langBits = encodeTwoLetterLanguage(consentLanguage)
bits.write(langBits.0, bitCount: 6)
bits.write(langBits.1, bitCount: 6)
// VendorListVersion (12 bits)
bits.write(vendorListVersion, bitCount: 12)
// TcfPolicyVersion (6 bits) must be 4 for TCF v2.2
bits.write(4, bitCount: 6)
// IsServiceSpecific (1 bit)
bits.write(0, bitCount: 1)
// UseNonStandardTexts (1 bit)
bits.write(0, bitCount: 1)
// SpecialFeatureOptIns (12 bits) none opted in
bits.write(0, bitCount: 12)
// PurposesConsent (24 bits) one bit per purpose, LSB = purpose 1
for purposeId in 1 ... tcfPurposeCount {
bits.write(consentedPurposeIds.contains(purposeId) ? 1 : 0, bitCount: 1)
}
// PurposesLITransparency (24 bits) legitimate interest; none asserted
bits.write(0, bitCount: 24)
// PurposeOneTreatment (1 bit)
bits.write(0, bitCount: 1)
// PublisherCC (12 bits) "GB"
let ccBits = encodeTwoLetterLanguage("GB")
bits.write(ccBits.0, bitCount: 6)
bits.write(ccBits.1, bitCount: 6)
// Vendor Consents using BitRange encoding with MaxVendorId = 0 (no vendors)
bits.write(0, bitCount: 16) // MaxVendorId
bits.write(0, bitCount: 1) // IsRangeEncoding = false
// (no bits to write for an empty vendor list)
// Vendor Legitimate Interests MaxVendorId = 0
bits.write(0, bitCount: 16)
bits.write(0, bitCount: 1)
// Publisher Restrictions count = 0
bits.write(0, bitCount: 12)
// Serialise and Base64url-encode
let data = bits.toData()
return base64UrlEncode(data)
}
// MARK: - Helpers
/// Encodes a two-letter language/country code into two 6-bit integers (A=0, Z=25).
private static func encodeTwoLetterLanguage(_ code: String) -> (Int, Int) {
// ASCII value of 'A' is 65. Subtracting this gives 0-based index (A=0 Z=25).
let asciiA: Int = 65
let upper = code.uppercased()
let chars = Array(upper)
guard chars.count == 2,
let first = chars[0].asciiValue,
let second = chars[1].asciiValue else {
return (4, 13) // "EN" fallback (E=4, N=13)
}
return (Int(first) - asciiA, Int(second) - asciiA)
}
/// Converts a `Data` value to a Base64url string (RFC 4648, no padding).
private static func base64UrlEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
}
}
// MARK: - Bit Writer
/// A utility type for packing integers into a bit-level byte buffer.
private struct BitWriter {
private var bytes: [UInt8] = []
private var currentByte: UInt8 = 0
private var bitPosition: Int = 0 // 0 = MSB of current byte
/// Writes `bitCount` bits from the MSB of `value`.
mutating func write(_ value: Int, bitCount: Int) {
for i in stride(from: bitCount - 1, through: 0, by: -1) {
let bit: UInt8 = (value >> i) & 1 == 1 ? 1 : 0
currentByte |= bit << (7 - bitPosition)
bitPosition += 1
if bitPosition == 8 {
bytes.append(currentByte)
currentByte = 0
bitPosition = 0
}
}
}
/// Flushes any remaining partial byte and returns the accumulated data.
func toData() -> Data {
var result = bytes
if bitPosition > 0 {
result.append(currentByte)
}
return Data(result)
}
}

View File

@@ -0,0 +1,111 @@
#if canImport(SwiftUI)
import SwiftUI
import ConsentOSCore
// MARK: - Banner Theme
/// Resolved colour and typography theme for the consent banner.
///
/// Derived from ``ConsentConfig/BannerConfig`` values, falling back to sensible defaults.
public struct BannerTheme: Sendable {
// MARK: - Colours
public let backgroundColor: Color
public let textColor: Color
public let accentColor: Color
public let secondaryTextColor: Color
public let dividerColor: Color
public let acceptButtonBackground: Color
public let acceptButtonTextColor: Color
public let rejectButtonBackground: Color
public let rejectButtonTextColor: Color
// MARK: - Typography
public let titleFont: Font
public let bodyFont: Font
public let buttonFont: Font
public let captionFont: Font
// MARK: - Layout
public let cornerRadius: CGFloat
public let horizontalPadding: CGFloat
public let verticalPadding: CGFloat
public let buttonHeight: CGFloat
// MARK: - Button Labels
public let acceptButtonText: String
public let rejectButtonText: String
public let manageButtonText: String
public let title: String
public let description: String
// MARK: - Defaults
static let defaultAccentHex = "#1A73E8" // Google-blue overridden by brand config
static let defaultBackgroundHex = "#FFFFFF"
static let defaultTextHex = "#1A1A1A"
// MARK: - Factory
/// Creates a ``BannerTheme`` from the banner configuration returned by the API.
///
/// Hex values not present in the config fall back to neutral defaults.
public static func from(config: ConsentConfig.BannerConfig) -> BannerTheme {
let background = Color(hex: config.backgroundColor) ?? Color(.systemBackground)
let text = Color(hex: config.textColor) ?? Color(.label)
let accent = Color(hex: config.accentColor) ?? Color(hex: defaultAccentHex)!
return BannerTheme(
backgroundColor: background,
textColor: text,
accentColor: accent,
secondaryTextColor: text.opacity(0.6),
dividerColor: text.opacity(0.12),
acceptButtonBackground: accent,
acceptButtonTextColor: .white,
rejectButtonBackground: Color(.secondarySystemBackground),
rejectButtonTextColor: text,
titleFont: .headline,
bodyFont: .subheadline,
buttonFont: .subheadline.weight(.semibold),
captionFont: .caption,
cornerRadius: 12,
horizontalPadding: 16,
verticalPadding: 16,
buttonHeight: 44,
acceptButtonText: config.acceptButtonText ?? "Accept All",
rejectButtonText: config.rejectButtonText ?? "Reject All",
manageButtonText: config.manageButtonText ?? "Manage Preferences",
title: config.title ?? "We value your privacy",
description: config.description ?? "We use cookies to improve your experience and for analytics."
)
}
/// Default theme used when no configuration has been loaded yet.
public static var defaultTheme: BannerTheme {
from(config: ConsentConfig.BannerConfig())
}
}
// MARK: - Colour Hex Extension
extension Color {
/// Initialises a `Color` from a hex string such as `"#1A73E8"` or `"1A73E8"`.
init?(hex: String?) {
guard let hex else { return nil }
let sanitised = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard sanitised.count == 6,
let value = UInt64(sanitised, radix: 16) else {
return nil
}
let r = Double((value >> 16) & 0xFF) / 255
let g = Double((value >> 8) & 0xFF) / 255
let b = Double((value ) & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
}
#endif

View File

@@ -0,0 +1,275 @@
#if canImport(SwiftUI)
import SwiftUI
import ConsentOSCore
// MARK: - Consent Banner View
/// A SwiftUI consent banner that respects the site's ``BannerTheme``.
///
/// The banner displays in a bottom-sheet style by default. It presents three
/// actions: accept all, reject all, and manage preferences (category-level toggles).
///
/// Usage:
/// ```swift
/// ConsentBannerView(theme: theme) {
/// await ConsentOS.shared.acceptAll()
/// } onRejectAll: {
/// await ConsentOS.shared.rejectAll()
/// } onSave: { categories in
/// await ConsentOS.shared.acceptCategories(categories)
/// }
/// ```
public struct ConsentBannerView: View {
// MARK: - State
@State private var showingManage: Bool = false
@State private var categoryToggles: [ConsentCategory: Bool] = [:]
// MARK: - Properties
private let theme: BannerTheme
private let onAcceptAll: () async -> Void
private let onRejectAll: () async -> Void
private let onSave: (Set<ConsentCategory>) async -> Void
/// The categories available for granular control (excludes `necessary`).
private let manageableCategories: [ConsentCategory] = ConsentCategory.allCases
.filter { $0.requiresConsent }
// MARK: - Initialisers
public init(
theme: BannerTheme = .defaultTheme,
onAcceptAll: @escaping () async -> Void,
onRejectAll: @escaping () async -> Void,
onSave: @escaping (Set<ConsentCategory>) async -> Void
) {
self.theme = theme
self.onAcceptAll = onAcceptAll
self.onRejectAll = onRejectAll
self.onSave = onSave
}
// MARK: - Body
public var body: some View {
VStack(spacing: 0) {
if showingManage {
manageView
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
mainBannerView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.background(theme.backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 12, x: 0, y: -4)
.padding(.horizontal, theme.horizontalPadding)
.onAppear {
// Default all optional categories to off
for category in manageableCategories {
categoryToggles[category] = false
}
}
.animation(.easeInOut(duration: 0.25), value: showingManage)
.accessibilityElement(children: .contain)
.accessibilityLabel("Cookie consent banner")
}
// MARK: - Main Banner
private var mainBannerView: some View {
VStack(alignment: .leading, spacing: 12) {
Text(theme.title)
.font(theme.titleFont)
.foregroundColor(theme.textColor)
.accessibilityAddTraits(.isHeader)
Text(theme.description)
.font(theme.bodyFont)
.foregroundColor(theme.secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
Divider()
.background(theme.dividerColor)
// Action buttons
VStack(spacing: 8) {
acceptButton
HStack(spacing: 8) {
rejectButton
manageButton
}
}
}
.padding(theme.verticalPadding)
}
// MARK: - Manage View
private var manageView: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack {
Button(action: { showingManage = false }) {
Image(systemName: "chevron.left")
.foregroundColor(theme.accentColor)
}
.accessibilityLabel("Back")
Text(theme.manageButtonText)
.font(theme.titleFont)
.foregroundColor(theme.textColor)
.accessibilityAddTraits(.isHeader)
Spacer()
}
.padding(theme.verticalPadding)
Divider().background(theme.dividerColor)
// Necessary category always on
categoryRow(
name: ConsentCategory.necessary.displayName,
description: ConsentCategory.necessary.displayDescription,
isOn: .constant(true),
isToggleable: false
)
Divider().background(theme.dividerColor)
// Optional categories
ScrollView {
VStack(spacing: 0) {
ForEach(manageableCategories, id: \.rawValue) { category in
categoryRow(
name: category.displayName,
description: category.displayDescription,
isOn: Binding(
get: { categoryToggles[category] ?? false },
set: { categoryToggles[category] = $0 }
),
isToggleable: true
)
Divider().background(theme.dividerColor)
}
}
}
// Save button
Button(action: {
Task {
let selected = Set(
manageableCategories.filter { categoryToggles[$0] == true }
)
await onSave(selected)
}
}) {
Text("Save Preferences")
.font(theme.buttonFont)
.foregroundColor(theme.acceptButtonTextColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.padding(theme.verticalPadding)
.accessibilityLabel("Save your cookie preferences")
}
}
// MARK: - Category Row
private func categoryRow(
name: String,
description: String,
isOn: Binding<Bool>,
isToggleable: Bool
) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(theme.bodyFont.weight(.semibold))
.foregroundColor(theme.textColor)
Text(description)
.font(theme.captionFont)
.foregroundColor(theme.secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Toggle("", isOn: isOn)
.labelsHidden()
.toggleStyle(SwitchToggleStyle(tint: theme.accentColor))
.disabled(!isToggleable)
.accessibilityLabel(isToggleable ? "Toggle \(name)" : "\(name) always active")
}
.padding(.horizontal, theme.horizontalPadding)
.padding(.vertical, 12)
}
// MARK: - Buttons
private var acceptButton: some View {
Button(action: {
Task { await onAcceptAll() }
}) {
Text(theme.acceptButtonText)
.font(theme.buttonFont)
.foregroundColor(theme.acceptButtonTextColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.acceptButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.accessibilityLabel("Accept all cookies")
}
private var rejectButton: some View {
Button(action: {
Task { await onRejectAll() }
}) {
Text(theme.rejectButtonText)
.font(theme.buttonFont)
.foregroundColor(theme.rejectButtonTextColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.rejectButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.accessibilityLabel("Reject all non-essential cookies")
}
private var manageButton: some View {
Button(action: { showingManage = true }) {
Text(theme.manageButtonText)
.font(theme.buttonFont)
.foregroundColor(theme.accentColor)
.frame(maxWidth: .infinity)
.frame(height: theme.buttonHeight)
.background(theme.accentColor.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.accessibilityLabel("Manage your cookie preferences")
}
}
// MARK: - Preview
#if DEBUG
#Preview("Main Banner") {
VStack {
Spacer()
ConsentBannerView(
theme: .defaultTheme,
onAcceptAll: {},
onRejectAll: {},
onSave: { _ in }
)
.padding(.bottom, 8)
}
.background(Color(.systemGroupedBackground))
}
#endif
#endif

View File

@@ -0,0 +1,106 @@
#if canImport(UIKit)
import UIKit
import SwiftUI
import ConsentOSCore
// MARK: - Consent Modal Controller
/// A UIKit view controller that presents the SwiftUI ``ConsentBannerView`` as a
/// bottom-sheet modal overlay.
///
/// Use this when your app is primarily UIKit-based.
///
/// ```swift
/// let modal = ConsentModalController()
/// modal.onDismiss = { [weak self] in
/// // Banner dismissed
/// }
/// present(modal, animated: true)
/// ```
public final class ConsentModalController: UIViewController {
// MARK: - Properties
/// Called when the banner is dismissed (after any consent action).
public var onDismiss: (() -> Void)?
/// The theme to apply to the banner. Defaults to ``BannerTheme/defaultTheme``.
public var theme: BannerTheme = .defaultTheme
// MARK: - Private
private var hostingController: UIHostingController<AnyView>?
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.4)
setupBannerView()
setupBackgroundDismiss()
}
// MARK: - Setup
private func setupBannerView() {
let bannerView = ConsentBannerView(
theme: theme,
onAcceptAll: { [weak self] in
await ConsentOS.shared.acceptAll()
await MainActor.run { self?.dismiss(animated: true) }
},
onRejectAll: { [weak self] in
await ConsentOS.shared.rejectAll()
await MainActor.run { self?.dismiss(animated: true) }
},
onSave: { [weak self] categories in
await ConsentOS.shared.acceptCategories(categories)
await MainActor.run { self?.dismiss(animated: true) }
}
)
let hosting = UIHostingController(rootView: AnyView(
VStack {
Spacer()
bannerView
.padding(.bottom, 16)
}
))
hosting.view.backgroundColor = .clear
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
hosting.didMove(toParent: self)
self.hostingController = hosting
}
private func setupBackgroundDismiss() {
// Tapping the dim overlay does not dismiss users must interact with the banner.
// Remove this behaviour if your design requires tap-to-dismiss.
}
// MARK: - Dismiss Override
public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
super.dismiss(animated: flag) { [weak self] in
self?.onDismiss?()
completion?()
}
}
// MARK: - Accessibility
public override func accessibilityPerformEscape() -> Bool {
// Do not allow VoiceOver escape gesture to dismiss the banner without a consent choice.
return false
}
}
#endif

View File

@@ -0,0 +1,22 @@
#if canImport(UIKit)
import UIKit
import ConsentOSCore
// MARK: - UIKit Banner Presentation (ConsentOSUI extension)
public extension ConsentOS {
/// Presents the consent banner modally on the given view controller.
///
/// This extension is provided by the ConsentOSUI module.
/// Ensure you import ConsentOSUI alongside ConsentOSCore to use this method.
///
/// - Parameter viewController: The presenting view controller.
@MainActor
func showBanner(on viewController: UIViewController) {
let modal = ConsentModalController()
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
viewController.present(modal, animated: true)
}
}
#endif

View File

@@ -0,0 +1,200 @@
import XCTest
@testable import ConsentOSCore
// MARK: - Mock API
/// In-memory API implementation for testing without network calls.
final class MockConsentAPI: ConsentAPIProtocol, @unchecked Sendable {
// MARK: - Configurable Behaviour
var configToReturn: ConsentConfig?
var errorToThrow: Error?
var postConsentError: Error?
// MARK: - Call Tracking
private(set) var fetchConfigCallCount = 0
private(set) var postConsentCallCount = 0
private(set) var lastPostedPayload: ConsentPayload?
private(set) var lastFetchedSiteId: String?
func fetchConfig(siteId: String) async throws -> ConsentConfig {
fetchConfigCallCount += 1
lastFetchedSiteId = siteId
if let error = errorToThrow { throw error }
guard let config = configToReturn else {
throw ConsentAPIError.unexpectedStatusCode(404)
}
return config
}
func postConsent(_ payload: ConsentPayload) async throws {
postConsentCallCount += 1
lastPostedPayload = payload
if let error = postConsentError { throw error }
}
}
// MARK: - Tests
final class ConsentAPITests: XCTestCase {
private var mockAPI: MockConsentAPI!
private let siteId = "test-site-001"
override func setUp() {
super.setUp()
mockAPI = MockConsentAPI()
}
// MARK: - fetchConfig
func test_fetchConfig_returnsConfig_whenSuccessful() async throws {
mockAPI.configToReturn = makeSampleConfig()
let result = try await mockAPI.fetchConfig(siteId: siteId)
XCTAssertEqual(result.siteId, siteId)
XCTAssertEqual(mockAPI.fetchConfigCallCount, 1)
XCTAssertEqual(mockAPI.lastFetchedSiteId, siteId)
}
func test_fetchConfig_throwsError_onNetworkFailure() async {
mockAPI.errorToThrow = ConsentAPIError.networkFailure(
NSError(domain: "NSURLErrorDomain", code: -1009)
)
do {
_ = try await mockAPI.fetchConfig(siteId: siteId)
XCTFail("Expected an error to be thrown")
} catch ConsentAPIError.networkFailure {
// Expected
} catch {
XCTFail("Unexpected error type: \(error)")
}
}
func test_fetchConfig_throwsError_on404() async {
mockAPI.errorToThrow = ConsentAPIError.unexpectedStatusCode(404)
do {
_ = try await mockAPI.fetchConfig(siteId: siteId)
XCTFail("Expected an error to be thrown")
} catch ConsentAPIError.unexpectedStatusCode(let code) {
XCTAssertEqual(code, 404)
} catch {
XCTFail("Unexpected error type: \(error)")
}
}
// MARK: - postConsent
func test_postConsent_sendsCorrectPayload() async throws {
let consentedAt = Date(timeIntervalSince1970: 1_700_000_000)
let payload = ConsentPayload(
siteId: siteId,
visitorId: "visitor-xyz",
accepted: [.analytics, .functional],
rejected: [.marketing],
consentedAt: consentedAt,
bannerVersion: "v2",
tcString: "test-tc-string"
)
try await mockAPI.postConsent(payload)
XCTAssertEqual(mockAPI.postConsentCallCount, 1)
let sent = try XCTUnwrap(mockAPI.lastPostedPayload)
XCTAssertEqual(sent.siteId, siteId)
XCTAssertEqual(sent.visitorId, "visitor-xyz")
XCTAssertEqual(sent.platform, "ios")
XCTAssertTrue(sent.accepted.contains("analytics"))
XCTAssertTrue(sent.accepted.contains("functional"))
XCTAssertTrue(sent.rejected.contains("marketing"))
XCTAssertEqual(sent.bannerVersion, "v2")
XCTAssertEqual(sent.tcString, "test-tc-string")
}
func test_postConsent_platformAlwaysIOS() async throws {
let payload = ConsentPayload(
siteId: siteId,
visitorId: "v",
accepted: [],
rejected: [],
consentedAt: Date(),
bannerVersion: nil
)
try await mockAPI.postConsent(payload)
XCTAssertEqual(mockAPI.lastPostedPayload?.platform, "ios")
}
func test_postConsent_throwsError_onFailure() async {
mockAPI.postConsentError = ConsentAPIError.unexpectedStatusCode(500)
let payload = ConsentPayload(
siteId: siteId,
visitorId: "v",
accepted: [],
rejected: [],
consentedAt: Date(),
bannerVersion: nil
)
do {
try await mockAPI.postConsent(payload)
XCTFail("Expected error")
} catch ConsentAPIError.unexpectedStatusCode(let code) {
XCTAssertEqual(code, 500)
} catch {
XCTFail("Unexpected error: \(error)")
}
}
// MARK: - ConsentPayload Serialisation
func test_consentPayload_encodesCategoriesToRawValues() throws {
let payload = ConsentPayload(
siteId: "s1",
visitorId: "v1",
accepted: [.analytics, .marketing],
rejected: [.functional],
consentedAt: Date(),
bannerVersion: nil
)
XCTAssertTrue(payload.accepted.contains("analytics"))
XCTAssertTrue(payload.accepted.contains("marketing"))
XCTAssertTrue(payload.rejected.contains("functional"))
XCTAssertFalse(payload.accepted.contains("necessary"))
}
// MARK: - Error Descriptions
func test_invalidURL_hasDescription() {
let error = ConsentAPIError.invalidURL
XCTAssertNotNil(error.errorDescription)
XCTAssertFalse(error.errorDescription!.isEmpty)
}
func test_unexpectedStatusCode_includesCodeInDescription() {
let error = ConsentAPIError.unexpectedStatusCode(503)
XCTAssertTrue(error.errorDescription?.contains("503") ?? false)
}
// MARK: - Helpers
private func makeSampleConfig() -> ConsentConfig {
ConsentConfig(
siteId: siteId,
siteName: "Test Site",
blockingMode: .optIn,
consentExpiryDays: 365,
bannerVersion: "v1",
bannerConfig: ConsentConfig.BannerConfig(),
categories: []
)
}
}

View File

@@ -0,0 +1,100 @@
import XCTest
@testable import ConsentOSCore
final class ConsentCategoryTests: XCTestCase {
// MARK: - requiresConsent
func test_necessary_doesNotRequireConsent() {
XCTAssertFalse(ConsentCategory.necessary.requiresConsent)
}
func test_allOtherCategories_requireConsent() {
let optionalCategories = ConsentCategory.allCases.filter { $0 != .necessary }
for category in optionalCategories {
XCTAssertTrue(category.requiresConsent, "\(category) should require consent")
}
}
// MARK: - GCM Mappings
func test_necessary_hasNoGCMType() {
XCTAssertNil(ConsentCategory.necessary.gcmConsentType)
}
func test_functional_mapsToFunctionalityStorage() {
XCTAssertEqual(ConsentCategory.functional.gcmConsentType, "functionality_storage")
}
func test_analytics_mapsToAnalyticsStorage() {
XCTAssertEqual(ConsentCategory.analytics.gcmConsentType, "analytics_storage")
}
func test_marketing_mapsToAdStorage() {
XCTAssertEqual(ConsentCategory.marketing.gcmConsentType, "ad_storage")
}
func test_personalisation_mapsToPersonalizationStorage() {
XCTAssertEqual(ConsentCategory.personalisation.gcmConsentType, "personalization_storage")
}
// MARK: - TCF Purpose IDs
func test_necessary_hasNoTCFPurposes() {
XCTAssertTrue(ConsentCategory.necessary.tcfPurposeIds.isEmpty)
}
func test_functional_hasExpectedTCFPurposes() {
XCTAssertEqual(ConsentCategory.functional.tcfPurposeIds, [1])
}
func test_analytics_hasExpectedTCFPurposes() {
XCTAssertEqual(ConsentCategory.analytics.tcfPurposeIds, [7, 8, 9, 10])
}
func test_marketing_hasExpectedTCFPurposes() {
XCTAssertEqual(ConsentCategory.marketing.tcfPurposeIds, [2, 3, 4])
}
func test_personalisation_hasExpectedTCFPurposes() {
XCTAssertEqual(ConsentCategory.personalisation.tcfPurposeIds, [5, 6])
}
// MARK: - Display Names
func test_allCategories_haveNonEmptyDisplayNames() {
for category in ConsentCategory.allCases {
XCTAssertFalse(category.displayName.isEmpty, "\(category) missing display name")
XCTAssertFalse(category.displayDescription.isEmpty, "\(category) missing description")
}
}
// MARK: - Codable
func test_category_roundTripsViaJSON() throws {
for category in ConsentCategory.allCases {
let encoded = try JSONEncoder().encode(category)
let decoded = try JSONDecoder().decode(ConsentCategory.self, from: encoded)
XCTAssertEqual(decoded, category)
}
}
func test_category_decodesFromRawValue() throws {
let json = #""analytics""#.data(using: .utf8)!
let decoded = try JSONDecoder().decode(ConsentCategory.self, from: json)
XCTAssertEqual(decoded, .analytics)
}
// MARK: - CaseIterable
func test_allCases_containsFiveCategories() {
XCTAssertEqual(ConsentCategory.allCases.count, 5)
}
func test_allCases_containsExpectedMembers() {
let expected: Set<ConsentCategory> = [
.necessary, .functional, .analytics, .marketing, .personalisation
]
XCTAssertEqual(Set(ConsentCategory.allCases), expected)
}
}

View File

@@ -0,0 +1,292 @@
import XCTest
@testable import ConsentOSCore
// MARK: - Mock Storage
final class MockConsentStorage: ConsentStorageProtocol, @unchecked Sendable {
var storedState: ConsentState?
var storedCachedConfig: CachedConfig?
var storedVisitorId: String = UUID().uuidString
private(set) var saveStateCallCount = 0
private(set) var clearStateCallCount = 0
func loadState() -> ConsentState? { storedState }
func saveState(_ state: ConsentState) {
saveStateCallCount += 1
storedState = state
}
func clearState() {
clearStateCallCount += 1
storedState = nil
}
func loadCachedConfig() -> CachedConfig? { storedCachedConfig }
func saveCachedConfig(_ cached: CachedConfig) {
storedCachedConfig = cached
}
func clearCachedConfig() {
storedCachedConfig = nil
}
func visitorId() -> String { storedVisitorId }
}
// MARK: - Tests
final class ConsentOSTests: XCTestCase {
private var sdk: ConsentOS!
private var mockStorage: MockConsentStorage!
private var mockAPI: MockConsentAPI!
override func setUp() {
super.setUp()
mockStorage = MockConsentStorage()
mockAPI = MockConsentAPI()
sdk = ConsentOS(storage: mockStorage, api: mockAPI)
}
// MARK: - Configuration
func test_configure_setsIsConfigured() {
XCTAssertFalse(sdk.isConfigured)
sdk.configure(
siteId: "site-001",
apiBase: URL(string: "https://api.example.com")!
)
XCTAssertTrue(sdk.isConfigured)
}
func test_configure_setsSiteId() {
sdk.configure(siteId: "my-site", apiBase: URL(string: "https://api.example.com")!)
XCTAssertEqual(sdk.siteId, "my-site")
}
func test_configure_restoresPersistedState() {
let existing = ConsentState(
visitorId: mockStorage.storedVisitorId,
accepted: [.analytics],
rejected: [],
consentedAt: Date(),
bannerVersion: "v1"
)
mockStorage.storedState = existing
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
XCTAssertEqual(sdk.consentState?.accepted, [.analytics])
}
func test_configure_createsNewState_whenNoPersistedState() {
mockStorage.storedState = nil
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
XCTAssertNotNil(sdk.consentState)
XCTAssertFalse(sdk.consentState!.hasInteracted)
}
// MARK: - shouldShowBanner
func test_shouldShowBanner_returnsTrue_whenNoInteraction() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
// State has no interaction (consentedAt is nil)
let shouldShow = await sdk.shouldShowBanner()
XCTAssertTrue(shouldShow)
}
func test_shouldShowBanner_returnsFalse_whenRecentConsentExists() async {
mockAPI.configToReturn = makeSampleConfig(expiryDays: 365, bannerVersion: "v1")
let recent = ConsentState(
visitorId: "v1",
accepted: [.analytics],
rejected: [.marketing, .functional, .personalisation],
consentedAt: Date(), // just now
bannerVersion: "v1"
)
mockStorage.storedState = recent
// Pre-load cached config so shouldShowBanner sees it
mockStorage.storedCachedConfig = CachedConfig(
config: makeSampleConfig(expiryDays: 365, bannerVersion: "v1"),
fetchedAt: Date()
)
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
let shouldShow = await sdk.shouldShowBanner()
XCTAssertFalse(shouldShow)
}
func test_shouldShowBanner_returnsTrue_whenBannerVersionChanged() async {
mockAPI.configToReturn = makeSampleConfig(expiryDays: 365, bannerVersion: "v2")
let state = ConsentState(
visitorId: "v1",
accepted: [.analytics],
rejected: [],
consentedAt: Date(),
bannerVersion: "v1" // old version
)
mockStorage.storedState = state
mockStorage.storedCachedConfig = CachedConfig(
config: makeSampleConfig(expiryDays: 365, bannerVersion: "v2"),
fetchedAt: Date()
)
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
let shouldShow = await sdk.shouldShowBanner()
XCTAssertTrue(shouldShow)
}
func test_shouldShowBanner_returnsTrue_whenConsentExpired() async {
let expiryDays = 30
let consentedAt = Date(timeIntervalSinceNow: -Double(expiryDays * 86_400 + 1))
let state = ConsentState(
visitorId: "v1",
accepted: [.analytics],
rejected: [],
consentedAt: consentedAt,
bannerVersion: "v1"
)
mockStorage.storedState = state
mockStorage.storedCachedConfig = CachedConfig(
config: makeSampleConfig(expiryDays: expiryDays, bannerVersion: "v1"),
fetchedAt: Date()
)
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
let shouldShow = await sdk.shouldShowBanner()
XCTAssertTrue(shouldShow)
}
// MARK: - acceptAll
func test_acceptAll_updatesConsentState() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.acceptAll()
let state = sdk.consentState
XCTAssertNotNil(state)
XCTAssertTrue(state!.hasInteracted)
XCTAssertFalse(state!.accepted.isEmpty)
}
func test_acceptAll_grantsAllOptionalCategories() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.acceptAll()
for category in ConsentCategory.allCases where category.requiresConsent {
XCTAssertTrue(sdk.getConsentStatus(for: category), "\(category) should be granted")
}
}
func test_acceptAll_persistsToStorage() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.acceptAll()
XCTAssertGreaterThan(mockStorage.saveStateCallCount, 0)
XCTAssertNotNil(mockStorage.storedState)
}
func test_acceptAll_postsConsentToAPI() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.acceptAll()
XCTAssertEqual(mockAPI.postConsentCallCount, 1)
XCTAssertEqual(mockAPI.lastPostedPayload?.platform, "ios")
}
// MARK: - rejectAll
func test_rejectAll_updatesConsentState() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.rejectAll()
let state = sdk.consentState
XCTAssertNotNil(state)
XCTAssertTrue(state!.hasInteracted)
XCTAssertTrue(state!.accepted.isEmpty)
}
func test_rejectAll_deniesAllOptionalCategories() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.rejectAll()
for category in ConsentCategory.allCases where category.requiresConsent {
XCTAssertFalse(sdk.getConsentStatus(for: category), "\(category) should be denied")
}
}
// MARK: - acceptCategories
func test_acceptCategories_onlyGrantsSpecifiedCategories() async {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.acceptCategories([.analytics, .functional])
XCTAssertTrue(sdk.getConsentStatus(for: .analytics))
XCTAssertTrue(sdk.getConsentStatus(for: .functional))
XCTAssertFalse(sdk.getConsentStatus(for: .marketing))
XCTAssertFalse(sdk.getConsentStatus(for: .personalisation))
}
// MARK: - getConsentStatus
func test_getConsentStatus_returnsTrue_forNecessary_always() {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
XCTAssertTrue(sdk.getConsentStatus(for: .necessary))
}
func test_getConsentStatus_returnsFalse_forOptional_beforeInteraction() {
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
XCTAssertFalse(sdk.getConsentStatus(for: .analytics))
}
// MARK: - Delegate
func test_delegate_isNotifiedAfterAcceptAll() async {
class MockDelegate: ConsentOSDelegate {
var didChangeCalled = false
var receivedState: ConsentState?
func consentDidChange(_ state: ConsentState) {
didChangeCalled = true
receivedState = state
}
}
let delegate = MockDelegate()
sdk.delegate = delegate
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
await sdk.acceptAll()
// Allow the MainActor dispatch to complete
await Task.yield()
await Task.yield()
XCTAssertTrue(delegate.didChangeCalled)
XCTAssertNotNil(delegate.receivedState)
}
// MARK: - Helpers
private func makeSampleConfig(expiryDays: Int = 365, bannerVersion: String = "v1") -> ConsentConfig {
ConsentConfig(
siteId: "site-001",
siteName: "Test",
blockingMode: .optIn,
consentExpiryDays: expiryDays,
bannerVersion: bannerVersion,
bannerConfig: ConsentConfig.BannerConfig(),
categories: []
)
}
}

View File

@@ -0,0 +1,212 @@
import XCTest
@testable import ConsentOSCore
final class ConsentStateTests: XCTestCase {
private let visitorId = "test-visitor-123"
// MARK: - Initial State
func test_newState_hasNoInteraction() {
let state = ConsentState(visitorId: visitorId)
XCTAssertFalse(state.hasInteracted)
XCTAssertNil(state.consentedAt)
XCTAssertTrue(state.accepted.isEmpty)
XCTAssertTrue(state.rejected.isEmpty)
}
func test_newState_preservesVisitorId() {
let state = ConsentState(visitorId: visitorId)
XCTAssertEqual(state.visitorId, visitorId)
}
// MARK: - isGranted
func test_necessary_isAlwaysGranted() {
let state = ConsentState(visitorId: visitorId) // no interaction
XCTAssertTrue(state.isGranted(.necessary))
}
func test_optional_isNotGranted_whenNoInteraction() {
let state = ConsentState(visitorId: visitorId)
for category in ConsentCategory.allCases where category != .necessary {
XCTAssertFalse(state.isGranted(category), "\(category) should not be granted by default")
}
}
func test_accepted_category_isGranted() {
let state = ConsentState(
visitorId: visitorId,
accepted: [.analytics],
rejected: [],
consentedAt: Date(),
bannerVersion: nil
)
XCTAssertTrue(state.isGranted(.analytics))
}
func test_rejected_category_isNotGranted() {
let state = ConsentState(
visitorId: visitorId,
accepted: [],
rejected: [.analytics],
consentedAt: Date(),
bannerVersion: nil
)
XCTAssertFalse(state.isGranted(.analytics))
}
// MARK: - isDenied
func test_necessary_isNeverDenied() {
let state = ConsentState(
visitorId: visitorId,
accepted: [],
rejected: [.analytics],
consentedAt: Date(),
bannerVersion: nil
)
XCTAssertFalse(state.isDenied(.necessary))
}
func test_rejected_category_isDenied() {
let state = ConsentState(
visitorId: visitorId,
accepted: [],
rejected: [.marketing],
consentedAt: Date(),
bannerVersion: nil
)
XCTAssertTrue(state.isDenied(.marketing))
}
// MARK: - acceptingAll()
func test_acceptingAll_grantsAllOptionalCategories() {
let state = ConsentState(visitorId: visitorId)
let accepted = state.acceptingAll()
let expected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
XCTAssertEqual(accepted.accepted, expected)
XCTAssertTrue(accepted.rejected.isEmpty)
}
func test_acceptingAll_setsConsentedAt() {
let before = Date()
let state = ConsentState(visitorId: visitorId).acceptingAll()
XCTAssertNotNil(state.consentedAt)
XCTAssertGreaterThanOrEqual(state.consentedAt!, before)
}
func test_acceptingAll_preservesVisitorId() {
let state = ConsentState(visitorId: visitorId).acceptingAll()
XCTAssertEqual(state.visitorId, visitorId)
}
func test_acceptingAll_hasInteracted() {
let state = ConsentState(visitorId: visitorId).acceptingAll()
XCTAssertTrue(state.hasInteracted)
}
// MARK: - rejectingAll()
func test_rejectingAll_emptiesAccepted() {
let state = ConsentState(visitorId: visitorId)
let rejected = state.rejectingAll()
XCTAssertTrue(rejected.accepted.isEmpty)
}
func test_rejectingAll_rejectsAllOptionalCategories() {
let state = ConsentState(visitorId: visitorId).rejectingAll()
let expected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
XCTAssertEqual(state.rejected, expected)
}
func test_rejectingAll_setsConsentedAt() {
let state = ConsentState(visitorId: visitorId).rejectingAll()
XCTAssertNotNil(state.consentedAt)
}
// MARK: - accepting(categories:)
func test_acceptingCategories_onlyAcceptsSpecified() {
let state = ConsentState(visitorId: visitorId)
let result = state.accepting(categories: [.analytics, .functional])
XCTAssertTrue(result.accepted.contains(.analytics))
XCTAssertTrue(result.accepted.contains(.functional))
XCTAssertFalse(result.accepted.contains(.marketing))
XCTAssertFalse(result.accepted.contains(.personalisation))
}
func test_acceptingCategories_rejectsRemainder() {
let state = ConsentState(visitorId: visitorId)
let result = state.accepting(categories: [.analytics])
XCTAssertTrue(result.rejected.contains(.marketing))
XCTAssertTrue(result.rejected.contains(.functional))
XCTAssertTrue(result.rejected.contains(.personalisation))
}
func test_acceptingCategories_ignoresNecessary() {
let state = ConsentState(visitorId: visitorId)
// Passing .necessary should not land in accepted/rejected sets
let result = state.accepting(categories: [.necessary])
XCTAssertFalse(result.accepted.contains(.necessary))
}
func test_acceptingEmptySet_rejectsAll() {
let state = ConsentState(visitorId: visitorId)
let result = state.accepting(categories: [])
XCTAssertTrue(result.accepted.isEmpty)
let expectedRejected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
XCTAssertEqual(result.rejected, expectedRejected)
}
// MARK: - Codable
func test_state_roundTripsViaJSON() throws {
let original = ConsentState(
visitorId: visitorId,
accepted: [.analytics, .functional],
rejected: [.marketing],
consentedAt: Date(timeIntervalSince1970: 1_700_000_000),
bannerVersion: "v2"
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let data = try encoder.encode(original)
let decoded = try decoder.decode(ConsentState.self, from: data)
XCTAssertEqual(decoded.visitorId, original.visitorId)
XCTAssertEqual(decoded.accepted, original.accepted)
XCTAssertEqual(decoded.rejected, original.rejected)
XCTAssertEqual(decoded.bannerVersion, original.bannerVersion)
// Date round-trip: allow 1-second tolerance for ISO8601 sub-second truncation
XCTAssertEqual(
decoded.consentedAt!.timeIntervalSince1970,
original.consentedAt!.timeIntervalSince1970,
accuracy: 1.0
)
}
// MARK: - Equatable
func test_twoStatesWithSameValues_areEqual() {
let date = Date(timeIntervalSince1970: 1_000_000)
let a = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: "v1")
let b = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: "v1")
XCTAssertEqual(a, b)
}
func test_twoStatesWithDifferentAccepted_areNotEqual() {
let date = Date(timeIntervalSince1970: 1_000_000)
let a = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: nil)
let b = ConsentState(visitorId: visitorId, accepted: [.marketing], rejected: [], consentedAt: date, bannerVersion: nil)
XCTAssertNotEqual(a, b)
}
}

View File

@@ -0,0 +1,167 @@
import XCTest
@testable import ConsentOSCore
final class ConsentStorageTests: XCTestCase {
// Use a unique suite per test run to avoid state bleed
private var storage: ConsentStorage!
private let suiteName = "com.cmp.tests.\(UUID().uuidString)"
override func setUp() {
super.setUp()
storage = ConsentStorage(suiteName: suiteName)
}
override func tearDown() {
// Clean up the UserDefaults suite after each test
UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName)
super.tearDown()
}
// MARK: - Consent State
func test_loadState_returnsNil_whenNothingStored() {
XCTAssertNil(storage.loadState())
}
func test_saveAndLoadState_roundTrips() {
let state = ConsentState(
visitorId: "visitor-abc",
accepted: [.analytics, .functional],
rejected: [.marketing],
consentedAt: Date(timeIntervalSince1970: 1_700_000_000),
bannerVersion: "v3"
)
storage.saveState(state)
let loaded = storage.loadState()
XCTAssertNotNil(loaded)
XCTAssertEqual(loaded?.visitorId, state.visitorId)
XCTAssertEqual(loaded?.accepted, state.accepted)
XCTAssertEqual(loaded?.rejected, state.rejected)
XCTAssertEqual(loaded?.bannerVersion, state.bannerVersion)
}
func test_clearState_removesStoredState() {
let state = ConsentState(visitorId: "test-visitor")
storage.saveState(state)
storage.clearState()
XCTAssertNil(storage.loadState())
}
func test_saveState_overwritesPreviousState() {
let state1 = ConsentState(visitorId: "visitor-1")
let state2 = ConsentState(
visitorId: "visitor-1",
accepted: [.analytics],
rejected: [],
consentedAt: Date(),
bannerVersion: "v2"
)
storage.saveState(state1)
storage.saveState(state2)
let loaded = storage.loadState()
XCTAssertEqual(loaded?.accepted, [.analytics])
}
// MARK: - Cached Config
func test_loadCachedConfig_returnsNil_whenNothingStored() {
XCTAssertNil(storage.loadCachedConfig())
}
func test_saveAndLoadCachedConfig_roundTrips() {
let config = makeSampleConfig()
let cached = CachedConfig(config: config, fetchedAt: Date())
storage.saveCachedConfig(cached)
let loaded = storage.loadCachedConfig()
XCTAssertNotNil(loaded)
XCTAssertEqual(loaded?.config.siteId, config.siteId)
XCTAssertEqual(loaded?.config.bannerVersion, config.bannerVersion)
}
func test_clearCachedConfig_removesStoredConfig() {
let config = makeSampleConfig()
let cached = CachedConfig(config: config, fetchedAt: Date())
storage.saveCachedConfig(cached)
storage.clearCachedConfig()
XCTAssertNil(storage.loadCachedConfig())
}
// MARK: - Cache TTL
func test_cachedConfig_isNotExpired_whenFetchedJustNow() {
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: Date())
XCTAssertFalse(cached.isExpired)
}
func test_cachedConfig_isExpired_whenFetchedOverTTLAgo() {
let pastDate = Date(timeIntervalSinceNow: -(CachedConfig.ttl + 1))
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: pastDate)
XCTAssertTrue(cached.isExpired)
}
func test_cachedConfig_isNotExpired_whenFetchedJustBeforeTTL() {
let almostExpired = Date(timeIntervalSinceNow: -(CachedConfig.ttl - 1))
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: almostExpired)
XCTAssertFalse(cached.isExpired)
}
// MARK: - Visitor ID
func test_visitorId_isGeneratedAndPersisted() {
let id1 = storage.visitorId()
let id2 = storage.visitorId()
XCTAssertEqual(id1, id2)
XCTAssertFalse(id1.isEmpty)
}
func test_visitorId_isAValidUUID() {
let id = storage.visitorId()
XCTAssertNotNil(UUID(uuidString: id), "Visitor ID should be a valid UUID")
}
func test_visitorId_isDifferentAcrossFreshInstances() {
let suite2 = "com.cmp.tests.\(UUID().uuidString)"
let storage2 = ConsentStorage(suiteName: suite2)
defer {
UserDefaults(suiteName: suite2)?.removePersistentDomain(forName: suite2)
}
let id1 = storage.visitorId()
let id2 = storage2.visitorId()
XCTAssertNotEqual(id1, id2)
}
// MARK: - Helpers
private func makeSampleConfig() -> ConsentConfig {
ConsentConfig(
siteId: "site-test-001",
siteName: "Test Site",
blockingMode: .optIn,
consentExpiryDays: 365,
bannerVersion: "v1",
bannerConfig: ConsentConfig.BannerConfig(),
categories: [
ConsentConfig.CategoryConfig(
key: "necessary",
enabled: true,
displayName: nil,
description: nil
),
ConsentConfig.CategoryConfig(
key: "analytics",
enabled: true,
displayName: nil,
description: nil
)
]
)
}
}

View File

@@ -0,0 +1,137 @@
import XCTest
@testable import ConsentOSCore
final class TCFStringEncoderTests: XCTestCase {
// MARK: - Encoding Returns nil Before Interaction
func test_encode_returnsNil_whenStateHasNoInteraction() {
let state = ConsentState(visitorId: "v1") // consentedAt is nil
let config = makeSampleConfig()
XCTAssertNil(TCFStringEncoder.encode(state: state, config: config))
}
// MARK: - Encoding Returns a Non-Empty String After Interaction
func test_encode_returnsNonEmptyString_afterAcceptAll() {
let state = ConsentState(visitorId: "v1").acceptingAll()
let config = makeSampleConfig()
let result = TCFStringEncoder.encode(state: state, config: config)
XCTAssertNotNil(result)
XCTAssertFalse(result!.isEmpty)
}
func test_encode_returnsNonEmptyString_afterRejectAll() {
let state = ConsentState(visitorId: "v1").rejectingAll()
let config = makeSampleConfig()
let result = TCFStringEncoder.encode(state: state, config: config)
XCTAssertNotNil(result)
XCTAssertFalse(result!.isEmpty)
}
// MARK: - Base64url Format
func test_encode_producesValidBase64url() {
let state = ConsentState(visitorId: "v1").acceptingAll()
let config = makeSampleConfig()
let tcString = TCFStringEncoder.encode(state: state, config: config)!
// Base64url must not contain standard Base64 characters that were replaced
XCTAssertFalse(tcString.contains("+"), "TC string must not contain '+'")
XCTAssertFalse(tcString.contains("/"), "TC string must not contain '/'")
XCTAssertFalse(tcString.contains("="), "TC string must not contain padding '='")
}
func test_encode_producesDecodableBase64() {
let state = ConsentState(visitorId: "v1").acceptingAll()
let config = makeSampleConfig()
let tcString = TCFStringEncoder.encode(state: state, config: config)!
// Convert back to standard Base64 for decoding
var base64 = tcString
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Pad to multiple of 4
let remainder = base64.count % 4
if remainder != 0 {
base64 += String(repeating: "=", count: 4 - remainder)
}
let data = Data(base64Encoded: base64)
XCTAssertNotNil(data, "TC string should be valid Base64")
XCTAssertGreaterThan(data!.count, 0)
}
// MARK: - Determinism
func test_encode_producesConsistentOutput_forSameInput() {
// The encoder uses a fixed timestamp, so two calls with the same state
// should produce strings of the same length (timestamps differ slightly).
let consentDate = Date(timeIntervalSince1970: 1_700_000_000)
let state = ConsentState(
visitorId: "v1",
accepted: [.analytics, .marketing],
rejected: [.functional, .personalisation],
consentedAt: consentDate,
bannerVersion: "v1"
)
let config = makeSampleConfig()
let result1 = TCFStringEncoder.encode(state: state, config: config)
let result2 = TCFStringEncoder.encode(state: state, config: config)
XCTAssertEqual(result1, result2)
}
// MARK: - Different States Produce Different Strings
func test_encode_producesDistinctStrings_forAcceptAllVsRejectAll() {
let consentDate = Date(timeIntervalSince1970: 1_700_000_000)
let acceptedState = ConsentState(
visitorId: "v1",
accepted: Set(ConsentCategory.allCases.filter { $0.requiresConsent }),
rejected: [],
consentedAt: consentDate,
bannerVersion: nil
)
let rejectedState = ConsentState(
visitorId: "v1",
accepted: [],
rejected: Set(ConsentCategory.allCases.filter { $0.requiresConsent }),
consentedAt: consentDate,
bannerVersion: nil
)
let config = makeSampleConfig()
let tcAccepted = TCFStringEncoder.encode(state: acceptedState, config: config)
let tcRejected = TCFStringEncoder.encode(state: rejectedState, config: config)
XCTAssertNotEqual(tcAccepted, tcRejected)
}
// MARK: - Minimum Length
func test_encode_producesStringOfReasonableLength() {
let state = ConsentState(visitorId: "v1").acceptingAll()
let config = makeSampleConfig()
let tcString = TCFStringEncoder.encode(state: state, config: config)!
// A valid core TC string serialises to at least ~20 bytes before Base64url encoding
// (the first 6 bits alone carry the version). 28 chars is a reasonable lower bound.
XCTAssertGreaterThan(tcString.count, 28, "TC string appears too short")
}
// MARK: - Helpers
private func makeSampleConfig() -> ConsentConfig {
ConsentConfig(
siteId: "site-001",
siteName: "Test Site",
blockingMode: .optIn,
consentExpiryDays: 365,
bannerVersion: "v1",
bannerConfig: ConsentConfig.BannerConfig(),
categories: []
)
}
}