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.
138 lines
5.1 KiB
Swift
138 lines
5.1 KiB
Swift
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: []
|
|
)
|
|
}
|
|
}
|