krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
main: 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}