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,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: []
)
}
}