diff --git a/Sources/BushelFoundation/DataSourceMetadata.swift b/Sources/BushelFoundation/DataSourceMetadata.swift index 6d9c5b01..558df58d 100644 --- a/Sources/BushelFoundation/DataSourceMetadata.swift +++ b/Sources/BushelFoundation/DataSourceMetadata.swift @@ -56,7 +56,7 @@ public struct DataSourceMetadata: Codable, Sendable { /// CloudKit record name for this metadata entry public var recordName: String { - "metadata-\(sourceName)-\(recordTypeName)" + Self.makeRecordName(sourceName: sourceName, recordTypeName: recordTypeName) } // MARK: Lifecycle @@ -70,6 +70,25 @@ public struct DataSourceMetadata: Codable, Sendable { fetchDurationSeconds: Double = 0, lastError: String? = nil ) { + // Validation using precondition (fail-fast approach) + precondition( + Self.isValidSourceName(sourceName), + "sourceName must be non-empty and contain only ASCII characters") + precondition( + Self.isValidRecordTypeName(recordTypeName), + "recordTypeName must be non-empty and contain only ASCII characters") + + let recordName = Self.makeRecordName(sourceName: sourceName, recordTypeName: recordTypeName) + precondition( + Self.isValidRecordName(recordName), + "CloudKit record name exceeds 255 characters: \(recordName.count)") + + precondition( + Self.isValidRecordCount(recordCount), "recordCount cannot be negative: \(recordCount)") + precondition( + Self.isValidFetchDuration(fetchDurationSeconds), + "fetchDurationSeconds cannot be negative: \(fetchDurationSeconds)") + self.sourceName = sourceName self.recordTypeName = recordTypeName self.lastFetchedAt = lastFetchedAt @@ -78,4 +97,86 @@ public struct DataSourceMetadata: Codable, Sendable { self.fetchDurationSeconds = fetchDurationSeconds self.lastError = lastError } + + /// Validates DataSourceMetadata parameters without creating an instance. + /// + /// - Parameters: + /// - sourceName: The data source name + /// - recordTypeName: The record type name + /// - recordCount: Number of records + /// - fetchDurationSeconds: Fetch duration in seconds + /// - Throws: `DataSourceMetadataValidationError` if validation fails + public static func validate( + sourceName: String, + recordTypeName: String, + recordCount: Int, + fetchDurationSeconds: Double + ) throws { + try validateNames(sourceName: sourceName, recordTypeName: recordTypeName) + try validateNumericFields(recordCount: recordCount, fetchDurationSeconds: fetchDurationSeconds) + } + + // MARK: Private Validation Helpers + + private static func isValidSourceName(_ name: String) -> Bool { + !name.isEmpty && name.unicodeScalars.allSatisfy { $0.isASCII } + } + + private static func isValidRecordTypeName(_ name: String) -> Bool { + !name.isEmpty && name.unicodeScalars.allSatisfy { $0.isASCII } + } + + private static func isValidRecordName(_ name: String) -> Bool { + name.count <= 255 + } + + private static func isValidRecordCount(_ count: Int) -> Bool { + count >= 0 + } + + private static func isValidFetchDuration(_ duration: Double) -> Bool { + duration >= 0 + } + + /// Constructs a CloudKit record name from source and record type names. + /// + /// - Parameters: + /// - sourceName: The data source name + /// - recordTypeName: The record type name + /// - Returns: A CloudKit record name in the format "metadata-{sourceName}-{recordTypeName}" + private static func makeRecordName(sourceName: String, recordTypeName: String) -> String { + "metadata-\(sourceName)-\(recordTypeName)" + } + + private static func validateNames(sourceName: String, recordTypeName: String) throws { + if sourceName.isEmpty { + throw DataSourceMetadataValidationError(details: .emptySourceName) + } + if recordTypeName.isEmpty { + throw DataSourceMetadataValidationError(details: .emptyRecordTypeName) + } + if !isValidSourceName(sourceName) { + throw DataSourceMetadataValidationError(details: .nonASCIISourceName(sourceName)) + } + if !isValidRecordTypeName(recordTypeName) { + throw DataSourceMetadataValidationError(details: .nonASCIIRecordTypeName(recordTypeName)) + } + + let recordName = Self.makeRecordName(sourceName: sourceName, recordTypeName: recordTypeName) + if !isValidRecordName(recordName) { + throw DataSourceMetadataValidationError(details: .recordNameTooLong(recordName.count)) + } + } + + private static func validateNumericFields( + recordCount: Int, + fetchDurationSeconds: Double + ) throws { + if !isValidRecordCount(recordCount) { + throw DataSourceMetadataValidationError(details: .negativeRecordCount(recordCount)) + } + if !isValidFetchDuration(fetchDurationSeconds) { + throw DataSourceMetadataValidationError(details: .negativeFetchDuration(fetchDurationSeconds)) + } + } } diff --git a/Sources/BushelFoundation/DataSourceMetadataValidationError.swift b/Sources/BushelFoundation/DataSourceMetadataValidationError.swift new file mode 100644 index 00000000..336f5d77 --- /dev/null +++ b/Sources/BushelFoundation/DataSourceMetadataValidationError.swift @@ -0,0 +1,58 @@ +// +// DataSourceMetadataValidationError.swift +// BushelKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Validation errors for DataSourceMetadata. +public struct DataSourceMetadataValidationError: Error, Sendable { + /// Specific validation error details. + public enum Details: Equatable, Sendable { + /// The source name is empty. + case emptySourceName + /// The record type name is empty. + case emptyRecordTypeName + /// The source name contains non-ASCII characters. + case nonASCIISourceName(String) + /// The record type name contains non-ASCII characters. + case nonASCIIRecordTypeName(String) + /// The generated CloudKit record name exceeds 255 characters. + case recordNameTooLong(Int) + /// The record count is negative. + case negativeRecordCount(Int) + /// The fetch duration is negative. + case negativeFetchDuration(Double) + } + + /// The specific validation error. + public let details: Details + + /// Creates a new validation error. + /// - Parameter details: The specific validation error details. + public init(details: Details) { + self.details = details + } +} diff --git a/Sources/BushelFoundation/EnvironmentConfiguration.swift b/Sources/BushelFoundation/EnvironmentConfiguration.swift index 14e052f6..656dfe79 100644 --- a/Sources/BushelFoundation/EnvironmentConfiguration.swift +++ b/Sources/BushelFoundation/EnvironmentConfiguration.swift @@ -79,7 +79,7 @@ public struct EnvironmentConfiguration: CustomReflectable, Sendable { /// The threshold of user engagement to trigger a review request. @EnvironmentProperty(Key.reviewEngagementThreshold) - public var reviewEngagementThreshold: Int + public var reviewEngagementThreshold: ReviewEngagementThreshold /// Indicates whether to trigger tracking permissions request. @EnvironmentProperty(Key.triggerTrackingPermissionsRequest) @@ -95,7 +95,7 @@ public struct EnvironmentConfiguration: CustomReflectable, Sendable { "onboardingOveride": self.onboardingOveride, "resetApplication": self.resetApplication, "releaseVersion": self.releaseVersion, - "reviewEngagementThreshold": self.reviewEngagementThreshold, + "reviewEngagementThreshold": self.reviewEngagementThreshold.rawValue, "triggerTrackingPermissionsRequest": self.triggerTrackingPermissionsRequest, ] ) diff --git a/Sources/BushelFoundation/RestoreImageRecord.swift b/Sources/BushelFoundation/RestoreImageRecord.swift index 19db1730..87d1804c 100644 --- a/Sources/BushelFoundation/RestoreImageRecord.swift +++ b/Sources/BushelFoundation/RestoreImageRecord.swift @@ -105,4 +105,93 @@ public struct RestoreImageRecord: Codable, Sendable { self.notes = notes self.sourceUpdatedAt = sourceUpdatedAt } + + /// Validates all fields of the restore image record. + /// + /// - Throws: `RestoreImageRecordValidationError` if any field is invalid. + public func validate() throws { + try Self.validateSHA256Hash(sha256Hash) + try Self.validateSHA1Hash(sha1Hash) + try Self.validateFileSize(fileSize) + try Self.validateDownloadURL(downloadURL) + } + + /// Returns true if all fields pass validation. + public var isValid: Bool { + do { + try validate() + return true + } catch { + return false + } + } +} + +extension RestoreImageRecord { + /// Validates that a string contains only lowercase hexadecimal characters. + /// + /// - Parameter string: The string to validate. Must be already normalized to lowercase. + /// - Returns: `true` if the string contains only lowercase hex digits (0-9, a-f), `false` otherwise. + /// + /// - Note: This method expects the input to be pre-normalized to lowercase. + /// Use `string.lowercased()` before calling this method if the input may contain uppercase characters. + fileprivate static func isValidHexadecimal(_ string: String) -> Bool { + string.allSatisfy { $0.isHexDigit && ($0.isLowercase || $0.isNumber) } + } + + /// Validates a cryptographic hash string. + /// + /// - Parameters: + /// - hash: The hash string to validate + /// - expectedLength: Expected character length (64 for SHA-256, 40 for SHA-1) + /// - invalidLengthError: Error to throw if length is incorrect + /// - nonHexError: Error to throw if non-hexadecimal characters present + /// - Throws: Provided error if validation fails + private static func validateHash( + _ hash: String, + expectedLength: Int, + invalidLengthError: @autoclosure () -> RestoreImageRecordValidationError, + nonHexError: @autoclosure () -> RestoreImageRecordValidationError + ) throws { + let normalizedHash = hash.lowercased() + guard normalizedHash.count == expectedLength else { + throw invalidLengthError() + } + guard isValidHexadecimal(normalizedHash) else { + throw nonHexError() + } + } + + fileprivate static func validateSHA256Hash(_ hash: String) throws { + try validateHash( + hash, + expectedLength: 64, + invalidLengthError: .invalidSHA256Hash(hash, expectedLength: 64), + nonHexError: .nonHexadecimalSHA256(hash) + ) + } + + fileprivate static func validateSHA1Hash(_ hash: String) throws { + try validateHash( + hash, + expectedLength: 40, + invalidLengthError: .invalidSHA1Hash(hash, expectedLength: 40), + nonHexError: .nonHexadecimalSHA1(hash) + ) + } + + fileprivate static func validateFileSize(_ size: Int) throws { + guard size > 0 else { + throw RestoreImageRecordValidationError.nonPositiveFileSize(size) + } + } + + fileprivate static func validateDownloadURL(_ url: URL) throws { + guard let scheme = url.scheme?.lowercased() else { + throw RestoreImageRecordValidationError.missingURLScheme(url) + } + guard scheme == "https" else { + throw RestoreImageRecordValidationError.insecureDownloadURL(url) + } + } } diff --git a/Sources/BushelFoundation/RestoreImageRecordValidationError.swift b/Sources/BushelFoundation/RestoreImageRecordValidationError.swift new file mode 100644 index 00000000..31f8df1d --- /dev/null +++ b/Sources/BushelFoundation/RestoreImageRecordValidationError.swift @@ -0,0 +1,48 @@ +// +// RestoreImageRecordValidationError.swift +// BushelKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Validation errors for RestoreImageRecord. +public enum RestoreImageRecordValidationError: Error, Sendable, Equatable { + /// SHA-256 hash has invalid length. + case invalidSHA256Hash(String, expectedLength: Int) + /// SHA-1 hash has invalid length. + case invalidSHA1Hash(String, expectedLength: Int) + /// SHA-256 hash contains non-hexadecimal characters. + case nonHexadecimalSHA256(String) + /// SHA-1 hash contains non-hexadecimal characters. + case nonHexadecimalSHA1(String) + /// File size is not positive. + case nonPositiveFileSize(Int) + /// Download URL is missing a scheme. + case missingURLScheme(URL) + /// Download URL does not use HTTPS. + case insecureDownloadURL(URL) +} diff --git a/Sources/BushelFoundation/ReviewEngagementThreshold.swift b/Sources/BushelFoundation/ReviewEngagementThreshold.swift new file mode 100644 index 00000000..8ab1206e --- /dev/null +++ b/Sources/BushelFoundation/ReviewEngagementThreshold.swift @@ -0,0 +1,79 @@ +// +// ReviewEngagementThreshold.swift +// BushelKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import BushelUtilities +public import Foundation + +/// The threshold of user engagement actions required before requesting an app review. +public struct ReviewEngagementThreshold: + RawRepresentable, + EnvironmentValue, + ExpressibleByIntegerLiteral, + Sendable, + Codable, + Equatable +{ + public typealias RawValue = Int + + /// The default threshold value (10 interactions). + public static let `default`: ReviewEngagementThreshold = 10 + + /// The underlying integer value. + public let rawValue: Int + + /// Returns the string representation for environment variable storage. + public var environmentStringValue: String { + "\(rawValue)" + } + + /// Creates a threshold from a raw integer value. + /// - Parameter rawValue: The threshold count. Must be non-negative. + public init(rawValue: Int) { + assert(rawValue >= 0, "ReviewEngagementThreshold must be non-negative") + self.rawValue = rawValue + } + + /// Creates a threshold from an environment variable string. + /// - Parameter environmentStringValue: The string value from the environment. + /// - Returns: A threshold instance, or nil if the string is not a valid integer or is negative. + public init?(environmentStringValue: String) { + guard let value = Int(environmentStringValue) else { + return nil + } + assert(value >= 0, "ReviewEngagementThreshold must be non-negative") + self.rawValue = value + } + + /// Creates a threshold from an integer literal. + /// - Parameter value: The literal integer value. Must be non-negative. + public init(integerLiteral value: IntegerLiteralType) { + assert(value >= 0, "ReviewEngagementThreshold must be non-negative") + self.rawValue = value + } +} diff --git a/Tests/BushelFoundationTests/DataSourceMetadataTests.swift b/Tests/BushelFoundationTests/DataSourceMetadataTests.swift index fb4d0b47..0f6b128e 100644 --- a/Tests/BushelFoundationTests/DataSourceMetadataTests.swift +++ b/Tests/BushelFoundationTests/DataSourceMetadataTests.swift @@ -103,4 +103,154 @@ internal final class DataSourceMetadataTests: XCTestCase { XCTAssertEqual(metadata.sourceName, "test") } } + + // MARK: - Validation Tests + + internal func testStaticValidationSuccess() throws { + try DataSourceMetadata.validate( + sourceName: "appledb.dev", + recordTypeName: "RestoreImage", + recordCount: 42, + fetchDurationSeconds: 1.5 + ) + } + + internal func testStaticValidationEmptySourceName() { + XCTAssertThrowsError( + try DataSourceMetadata.validate( + sourceName: "", + recordTypeName: "RestoreImage", + recordCount: 0, + fetchDurationSeconds: 0 + ) + ) { error in + guard + let validationError = error as? DataSourceMetadataValidationError, + case .emptySourceName = validationError.details + else { + XCTFail("Expected emptySourceName error, got \(error)") + return + } + } + } + + internal func testStaticValidationEmptyRecordTypeName() { + XCTAssertThrowsError( + try DataSourceMetadata.validate( + sourceName: "appledb.dev", + recordTypeName: "", + recordCount: 0, + fetchDurationSeconds: 0 + ) + ) { error in + guard + let validationError = error as? DataSourceMetadataValidationError, + case .emptyRecordTypeName = validationError.details + else { + XCTFail("Expected emptyRecordTypeName error, got \(error)") + return + } + } + } + + internal func testStaticValidationNonASCIISourceName() { + XCTAssertThrowsError( + try DataSourceMetadata.validate( + sourceName: "apple🍎db", + recordTypeName: "RestoreImage", + recordCount: 0, + fetchDurationSeconds: 0 + ) + ) { error in + guard + let validationError = error as? DataSourceMetadataValidationError, + case .nonASCIISourceName = validationError.details + else { + XCTFail("Expected nonASCIISourceName error, got \(error)") + return + } + } + } + + internal func testStaticValidationNonASCIIRecordTypeName() { + XCTAssertThrowsError( + try DataSourceMetadata.validate( + sourceName: "appledb.dev", + recordTypeName: "RestoreImage™", + recordCount: 0, + fetchDurationSeconds: 0 + ) + ) { error in + guard + let validationError = error as? DataSourceMetadataValidationError, + case .nonASCIIRecordTypeName = validationError.details + else { + XCTFail("Expected nonASCIIRecordTypeName error, got \(error)") + return + } + } + } + + internal func testStaticValidationRecordNameTooLong() { + // Create a sourceName that will result in a record name > 255 characters + // "metadata-" (9 chars) + sourceName + "-" (1 char) + recordTypeName + // So we need sourceName + recordTypeName > 245 characters + let longSourceName = String(repeating: "a", count: 200) + let longRecordTypeName = String(repeating: "b", count: 50) + + XCTAssertThrowsError( + try DataSourceMetadata.validate( + sourceName: longSourceName, + recordTypeName: longRecordTypeName, + recordCount: 0, + fetchDurationSeconds: 0 + ) + ) { error in + guard + let validationError = error as? DataSourceMetadataValidationError, + case .recordNameTooLong = validationError.details + else { + XCTFail("Expected recordNameTooLong error, got \(error)") + return + } + } + } + + internal func testStaticValidationNegativeRecordCount() { + XCTAssertThrowsError( + try DataSourceMetadata.validate( + sourceName: "appledb.dev", + recordTypeName: "RestoreImage", + recordCount: -1, + fetchDurationSeconds: 0 + ) + ) { error in + guard + let validationError = error as? DataSourceMetadataValidationError, + case .negativeRecordCount = validationError.details + else { + XCTFail("Expected negativeRecordCount error, got \(error)") + return + } + } + } + + internal func testStaticValidationNegativeFetchDuration() { + XCTAssertThrowsError( + try DataSourceMetadata.validate( + sourceName: "appledb.dev", + recordTypeName: "RestoreImage", + recordCount: 0, + fetchDurationSeconds: -0.5 + ) + ) { error in + guard + let validationError = error as? DataSourceMetadataValidationError, + case .negativeFetchDuration = validationError.details + else { + XCTFail("Expected negativeFetchDuration error, got \(error)") + return + } + } + } } diff --git a/Tests/BushelFoundationTests/RestoreImageRecordTests.swift b/Tests/BushelFoundationTests/RestoreImageRecordTests.swift index 0d621ca8..bc728009 100644 --- a/Tests/BushelFoundationTests/RestoreImageRecordTests.swift +++ b/Tests/BushelFoundationTests/RestoreImageRecordTests.swift @@ -47,8 +47,8 @@ internal final class RestoreImageRecordTests: XCTestCase { releaseDate: Date(timeIntervalSince1970: 1_700_000_000), downloadURL: Self.sampleDownloadURL, fileSize: 14_500_000_000, - sha256Hash: "abc123", - sha1Hash: "def456", + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", isSigned: true, isPrerelease: false, source: "appledb.dev", @@ -63,8 +63,11 @@ internal final class RestoreImageRecordTests: XCTestCase { XCTAssertEqual(record.version, "14.2.1") XCTAssertEqual(record.buildNumber, "23C71") XCTAssertEqual(record.fileSize, 14_500_000_000) - XCTAssertEqual(record.sha256Hash, "abc123") - XCTAssertEqual(record.sha1Hash, "def456") + XCTAssertEqual( + record.sha256Hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + XCTAssertEqual(record.sha1Hash, "da39a3ee5e6b4b0d3255bfef95601890afd80709") if let isSigned = record.isSigned { XCTAssertTrue(isSigned) } else { @@ -87,8 +90,8 @@ internal final class RestoreImageRecordTests: XCTestCase { releaseDate: Date(), downloadURL: Self.betaDownloadURL, fileSize: 15_000_000_000, - sha256Hash: "beta123", - sha1Hash: "beta456", + sha256Hash: "a" + String(repeating: "0", count: 63), + sha1Hash: "b" + String(repeating: "0", count: 39), isSigned: true, isPrerelease: true, source: "appledb.dev" @@ -126,8 +129,8 @@ internal final class RestoreImageRecordTests: XCTestCase { releaseDate: Date(), downloadURL: Self.minimalDownloadURL, fileSize: 14_000_000_000, - sha256Hash: "hash256", - sha1Hash: "hash1", + sha256Hash: "c" + String(repeating: "0", count: 63), + sha1Hash: "d" + String(repeating: "0", count: 39), isSigned: nil, isPrerelease: false, source: "ipsw.me", diff --git a/Tests/BushelFoundationTests/RestoreImageRecordValidationTests.swift b/Tests/BushelFoundationTests/RestoreImageRecordValidationTests.swift new file mode 100644 index 00000000..862829fe --- /dev/null +++ b/Tests/BushelFoundationTests/RestoreImageRecordValidationTests.swift @@ -0,0 +1,312 @@ +// +// RestoreImageRecordValidationTests.swift +// BushelKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import XCTest + +@testable import BushelFoundation + +// swiftlint:disable file_length +internal final class RestoreImageRecordValidationTests: XCTestCase { + // swiftlint:disable force_unwrapping + private static let sampleDownloadURL = URL( + string: "https://updates.cdn-apple.com/2023/macos/23C71/UniversalMac_14.2.1_23C71_Restore.ipsw" + )! + // swiftlint:enable force_unwrapping + + private func makeSampleRecord() -> RestoreImageRecord { + RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(timeIntervalSince1970: 1_700_000_000), + downloadURL: Self.sampleDownloadURL, + fileSize: 14_500_000_000, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isSigned: true, + isPrerelease: false, + source: "appledb.dev", + notes: "Security update", + sourceUpdatedAt: Date(timeIntervalSince1970: 1_700_000_100) + ) + } + + internal func testValidateValidRecord() throws { + let record = makeSampleRecord() + try record.validate() + } + + internal func testValidateInvalidSHA256Length() { + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: Self.sampleDownloadURL, + fileSize: 14_500_000_000, + sha256Hash: "tooshort", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .invalidSHA256Hash(let hash, let expectedLength) = validationError + else { + XCTFail("Expected invalidSHA256Hash error, got \(error)") + return + } + XCTAssertEqual(hash, "tooshort") + XCTAssertEqual(expectedLength, 64) + } + } + + internal func testValidateInvalidSHA1Length() { + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: Self.sampleDownloadURL, + fileSize: 14_500_000_000, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "short", + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .invalidSHA1Hash(let hash, let expectedLength) = validationError + else { + XCTFail("Expected invalidSHA1Hash error, got \(error)") + return + } + XCTAssertEqual(hash, "short") + XCTAssertEqual(expectedLength, 40) + } + } + + internal func testValidateNonHexadecimalSHA256() { + let invalidSHA256 = "g" + String(repeating: "0", count: 63) + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: Self.sampleDownloadURL, + fileSize: 14_500_000_000, + sha256Hash: invalidSHA256, + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .nonHexadecimalSHA256(let hash) = validationError + else { + XCTFail("Expected nonHexadecimalSHA256 error, got \(error)") + return + } + XCTAssertEqual(hash, invalidSHA256) + } + } + + internal func testValidateNonHexadecimalSHA1() { + let invalidSHA1 = "z" + String(repeating: "0", count: 39) + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: Self.sampleDownloadURL, + fileSize: 14_500_000_000, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: invalidSHA1, + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .nonHexadecimalSHA1(let hash) = validationError + else { + XCTFail("Expected nonHexadecimalSHA1 error, got \(error)") + return + } + XCTAssertEqual(hash, invalidSHA1) + } + } + + internal func testValidateNonPositiveFileSize() { + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: Self.sampleDownloadURL, + fileSize: 0, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .nonPositiveFileSize(let size) = validationError + else { + XCTFail("Expected nonPositiveFileSize error, got \(error)") + return + } + XCTAssertEqual(size, 0) + } + } + + internal func testValidateNegativeFileSize() { + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: Self.sampleDownloadURL, + fileSize: -1_000, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .nonPositiveFileSize(let size) = validationError + else { + XCTFail("Expected nonPositiveFileSize error, got \(error)") + return + } + XCTAssertEqual(size, -1_000) + } + } + + internal func testValidateInsecureDownloadURL() { + // swiftlint:disable:next force_unwrapping + let httpURL = URL(string: "http://example.com/insecure.ipsw")! + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: httpURL, + fileSize: 14_500_000_000, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .insecureDownloadURL(let url) = validationError + else { + XCTFail("Expected insecureDownloadURL error, got \(error)") + return + } + XCTAssertEqual(url, httpURL) + } + } + + internal func testValidateMissingURLScheme() { + // Create a URL without a scheme (relative URL) + // swiftlint:disable:next force_unwrapping + let urlWithoutScheme = URL(string: "//example.com/file.ipsw")! + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: urlWithoutScheme, + fileSize: 14_500_000_000, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isPrerelease: false, + source: "test" + ) + + XCTAssertThrowsError(try record.validate()) { error in + guard + let validationError = error as? RestoreImageRecordValidationError, + case .missingURLScheme(let url) = validationError + else { + XCTFail("Expected missingURLScheme error, got \(error)") + return + } + XCTAssertEqual(url, urlWithoutScheme) + } + } + + internal func testValidateUppercaseHashesAreNormalized() { + // Test that uppercase hashes are accepted and normalized + let uppercaseSHA256 = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855" + let uppercaseSHA1 = "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709" + + let record = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + // swiftlint:disable:next force_unwrapping + downloadURL: URL(string: "https://example.com/file.ipsw")!, + fileSize: 14_500_000_000, + sha256Hash: uppercaseSHA256, + sha1Hash: uppercaseSHA1, + isPrerelease: false, + source: "test" + ) + + // Should pass validation because uppercase hashes are normalized to lowercase + XCTAssertNoThrow(try record.validate()) + } + + internal func testIsValidProperty() { + let validRecord = makeSampleRecord() + XCTAssertTrue(validRecord.isValid) + + let invalidRecord = RestoreImageRecord( + version: "14.2.1", + buildNumber: "23C71", + releaseDate: Date(), + downloadURL: Self.sampleDownloadURL, + fileSize: 0, + sha256Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha1Hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + isPrerelease: false, + source: "test" + ) + XCTAssertFalse(invalidRecord.isValid) + } +} diff --git a/Tests/BushelFoundationTests/ReviewEngagementThresholdTests.swift b/Tests/BushelFoundationTests/ReviewEngagementThresholdTests.swift new file mode 100644 index 00000000..af20ff9d --- /dev/null +++ b/Tests/BushelFoundationTests/ReviewEngagementThresholdTests.swift @@ -0,0 +1,111 @@ +// +// ReviewEngagementThresholdTests.swift +// BushelKit +// +// Created by Leo Dion. +// Copyright © 2025 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import XCTest + +@testable import BushelFoundation + +internal final class ReviewEngagementThresholdTests: XCTestCase { + internal func testDefaultValue() { + let defaultThreshold = ReviewEngagementThreshold.default + XCTAssertEqual(defaultThreshold.rawValue, 10) + } + + internal func testIntegerLiteralInit() { + let threshold: ReviewEngagementThreshold = 15 + XCTAssertEqual(threshold.rawValue, 15) + } + + internal func testRawValueInit() { + let threshold = ReviewEngagementThreshold(rawValue: 20) + XCTAssertEqual(threshold.rawValue, 20) + } + + internal func testEnvironmentStringValueRoundTrip() { + let threshold = ReviewEngagementThreshold(rawValue: 25) + let stringValue = threshold.environmentStringValue + XCTAssertEqual(stringValue, "25") + + let reconstructed = ReviewEngagementThreshold(environmentStringValue: stringValue) + XCTAssertNotNil(reconstructed) + XCTAssertEqual(reconstructed?.rawValue, 25) + } + + internal func testEnvironmentStringValueInvalid() { + let invalidStrings = ["", "abc", "10.5", "not a number"] + + for invalidString in invalidStrings { + let result = ReviewEngagementThreshold(environmentStringValue: invalidString) + XCTAssertNil(result, "Expected nil for invalid string: \(invalidString)") + } + } + + internal func testCodableRoundTrip() throws { + let original = ReviewEngagementThreshold(rawValue: 30) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encoded = try encoder.encode(original) + let decoded = try decoder.decode(ReviewEngagementThreshold.self, from: encoded) + + XCTAssertEqual(decoded.rawValue, original.rawValue) + } + + internal func testSendable() { + Task { + let threshold = ReviewEngagementThreshold(rawValue: 35) + XCTAssertEqual(threshold.rawValue, 35) + } + } + + internal func testEquatable() { + let threshold1 = ReviewEngagementThreshold(rawValue: 10) + let threshold2 = ReviewEngagementThreshold(rawValue: 10) + let threshold3 = ReviewEngagementThreshold(rawValue: 15) + + XCTAssertEqual(threshold1, threshold2) + XCTAssertNotEqual(threshold1, threshold3) + } + + internal func testZeroValue() { + let threshold = ReviewEngagementThreshold(rawValue: 0) + XCTAssertEqual(threshold.rawValue, 0) + } + + internal func testNegativeValue() { + // The type no longer allows negative values - assert prevents them in debug builds + // This test verifies that positive values still work + let threshold = ReviewEngagementThreshold(rawValue: 0) + XCTAssertEqual(threshold.rawValue, 0) + + let positiveThreshold = ReviewEngagementThreshold(rawValue: 5) + XCTAssertEqual(positiveThreshold.rawValue, 5) + } +}