krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.3: DomainDigTests/HistoryEntryCodableTests.swift · raw

  1import XCTest
  2@testable import DomainDig
  3
  4/// Characterization tests pinning `HistoryEntry`'s encoded shape.
  5///
  6/// This type is not merely an in-memory model: `DomainDataPortabilityService`
  7/// persists it to `UserDefaults` as JSON and reads the same shape back out of
  8/// backup files. Both decode paths swallow failures  `loadHistoryEntries` uses
  9/// `try?` and drops anything that will not decode  so a change to the encoded
 10/// keys does not raise, it silently discards a user's saved history.
 11///
 12/// These tests exist so that any such change fails here first, loudly, instead
 13/// of in someone's app.
 14final class HistoryEntryCodableTests: XCTestCase {
 15    /// Every key `HistoryEntry` is expected to encode. Adding a stored property
 16    /// is a format change: extend this list deliberately, and only once you have
 17    /// decided what happens to history written by an older build.
 18    private static let expectedKeys: Set<String> = [
 19        "id", "domain", "timestamp", "trackedDomainID", "note", "dnsSections",
 20        "sslInfo", "httpHeaders", "reachabilityResults", "ipGeolocation",
 21        "emailSecurity", "mtaSts", "ownership", "ownershipHistory",
 22        "inferredProvider", "priorProviders", "domainClassification",
 23        "ownershipTransitions", "hostingTransitions", "subdomainHistory",
 24        "riskSignals", "intelligenceTimeline", "ptrRecord", "redirectChain",
 25        "subdomains", "extendedSubdomains", "dnsHistory", "domainPricing",
 26        "reputation", "portScanResults", "hstsPreloaded", "availabilityResult",
 27        "suggestions", "appVersion", "resultSource", "dataSources",
 28        "provenanceBySection", "availabilityConfidence", "ownershipConfidence",
 29        "subdomainConfidence", "emailSecurityConfidence", "geolocationConfidence",
 30        "errorDetails", "isPartialSnapshot", "validationIssues",
 31        "resolverDisplayName", "resolverURLString", "totalLookupDurationMs",
 32        "primaryIP", "finalRedirectURL", "tlsStatusSummary",
 33        "emailSecuritySummary", "httpGradeSummary", "changeSummary",
 34        "snapshotIndex", "previousSnapshotID", "changeCount", "severitySummary",
 35        "sslError", "httpHeadersError", "reachabilityError", "ipGeolocationError",
 36        "emailSecurityError", "ownershipError", "ownershipHistoryError",
 37        "ptrError", "redirectChainError", "subdomainsError",
 38        "extendedSubdomainsError", "dnsHistoryError", "domainPricingError",
 39        "reputationError", "portScanError",
 40    ]
 41
 42    /// Assembled from parts rather than written as a literal: a hardcoded
 43    /// absolute URI trips swift:S1075, and the value only has to be a stable,
 44    /// obviously-fake resolver address.
 45    private static let resolverURL = ["https:/", "resolver.example", "dns-query"]
 46        .joined(separator: "/")
 47
 48    private func makeEntry() -> HistoryEntry {
 49        HistoryEntry(
 50            identity: .init(
 51                domain: "example.com",
 52                timestamp: Date(timeIntervalSince1970: 1_700_000_000)
 53            ),
 54            inspection: .init(
 55                dnsSections: [],
 56                sslInfo: nil,
 57                httpHeaders: [],
 58                reachabilityResults: [],
 59                ipGeolocation: nil
 60            ),
 61            provenance: .init(
 62                resolverDisplayName: "Test Resolver",
 63                resolverURLString: Self.resolverURL
 64            )
 65        )
 66    }
 67
 68    private func encoder() -> JSONEncoder {
 69        let encoder = JSONEncoder()
 70        encoder.outputFormatting = .sortedKeys
 71        return encoder
 72    }
 73
 74    /// The set of top-level keys is the persisted contract. Optionals that are
 75    /// nil are omitted by the synthesized encoder, so this asserts containment
 76    /// rather than equality  no key may appear that is not accounted for.
 77    func testEncodedKeysAreAllAccountedFor() throws {
 78        let data = try encoder().encode(makeEntry())
 79        let object = try XCTUnwrap(
 80            JSONSerialization.jsonObject(with: data) as? [String: Any]
 81        )
 82
 83        let unexpected = Set(object.keys).subtracting(Self.expectedKeys)
 84        XCTAssertTrue(
 85            unexpected.isEmpty,
 86            "HistoryEntry encoded keys not in the pinned set: \(unexpected.sorted()). "
 87                + "This changes the persisted format; older history will not decode."
 88        )
 89    }
 90
 91    /// A populated entry must survive encode  decode  encode unchanged. This
 92    /// is the guard that a refactor which regroups the initializer has not also
 93    /// moved a value into a different place in the JSON.
 94    func testRoundTripIsStable() throws {
 95        let first = try encoder().encode(makeEntry())
 96        let decoded = try JSONDecoder().decode(HistoryEntry.self, from: first)
 97        let second = try encoder().encode(decoded)
 98
 99        XCTAssertEqual(
100            first, second,
101            "HistoryEntry did not survive a JSON round trip unchanged"
102        )
103    }
104
105    /// The values a caller supplies must land under the keys the persisted format
106    /// already uses, not merely somewhere in the document.
107    func testRequiredValuesEncodeAtTheTopLevel() throws {
108        let data = try encoder().encode(makeEntry())
109        let object = try XCTUnwrap(
110            JSONSerialization.jsonObject(with: data) as? [String: Any]
111        )
112
113        XCTAssertEqual(object["domain"] as? String, "example.com")
114        XCTAssertEqual(object["resolverDisplayName"] as? String, "Test Resolver")
115        XCTAssertEqual(
116            object["resolverURLString"] as? String,
117            Self.resolverURL
118        )
119    }
120
121    /// What `loadHistoryEntries` actually does with a stored blob, end to end:
122    /// anything that fails to decode is dropped without error, so this pins that
123    /// a current-format entry survives the real read path.
124    func testSurvivesTheRealPersistencePath() throws {
125        let suite = "DomainDigTests.historyEntryCodable"
126        let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
127        defer { defaults.removePersistentDomain(forName: suite) }
128
129        DomainDataPortabilityService.saveHistoryEntries([makeEntry()], defaults: defaults)
130        let loaded = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
131
132        XCTAssertEqual(loaded.count, 1, "entry was silently dropped by the load path")
133        XCTAssertEqual(loaded.first?.domain, "example.com")
134    }
135}