Commit 1bc0c44013
Verified · cmc
Layout: unified · split
.gitignore added +3
| @@ -0,0 +1,3 @@ | |||
| 1 | |||
| 2 | # Xcode per-user state | ||
| 3 | xcuserdata/ | ||
DomainDig.xcodeproj/project.pbxproj +4
| @@ -12,6 +12,7 @@ | |||
| 12 | 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; }; | 12 | 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; }; |
| 13 | 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */; }; | 13 | 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */; }; |
| 14 | 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; }; | 14 | 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; }; |
| 15 | A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */; }; | ||
| 15 | 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; | 16 | 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; |
| 16 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; | 17 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; |
| 17 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; | 18 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; |
| @@ -98,6 +99,7 @@ | |||
| 98 | CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; }; | 99 | CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; }; |
| 99 | DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; | 100 | DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; |
| 100 | E82320955416797CFE59464A /* DomainReportBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportBuilderTests.swift; sourceTree = "<group>"; }; | 101 | E82320955416797CFE59464A /* DomainReportBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportBuilderTests.swift; sourceTree = "<group>"; }; |
| 102 | A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryEntryCodableTests.swift; sourceTree = "<group>"; }; | ||
| 101 | /* End PBXFileReference section */ | 103 | /* End PBXFileReference section */ |
| 102 | 104 | ||
| 103 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | 105 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| @@ -235,6 +237,7 @@ | |||
| 235 | 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */, | 237 | 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */, |
| 236 | 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */, | 238 | 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */, |
| 237 | 499B99A1866999A38AC475F5 /* AppInfoTests.swift */, | 239 | 499B99A1866999A38AC475F5 /* AppInfoTests.swift */, |
| 240 | A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */, | ||
| 238 | ); | 241 | ); |
| 239 | name = DomainDigTests; | 242 | name = DomainDigTests; |
| 240 | path = DomainDigTests; | 243 | path = DomainDigTests; |
| @@ -489,6 +492,7 @@ | |||
| 489 | files = ( | 492 | files = ( |
| 490 | E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */, | 493 | E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */, |
| 491 | 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */, | 494 | 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */, |
| 495 | A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */, | ||
| 492 | 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */, | 496 | 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */, |
| 493 | C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */, | 497 | C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */, |
| 494 | A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, | 498 | A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, |
DomainDig/DNSLookupService.swift +1 −1
| @@ -364,7 +364,7 @@ struct DNSLookupService { | |||
| 364 | answers.append(.init( | 364 | answers.append(.init( |
| 365 | name: name, | 365 | name: name, |
| 366 | type: type, | 366 | type: type, |
| 367 | TTL: ttl, | 367 | ttl: ttl, |
| 368 | data: parsedValue | 368 | data: parsedValue |
| 369 | )) | 369 | )) |
| 370 | } | 370 | } |
DomainDig/DomainMonitoringService.swift +74 −60
| @@ -520,66 +520,80 @@ final class DomainMonitoringService { | |||
| 520 | } | 520 | } |
| 521 | 521 | ||
| 522 | let entry = HistoryEntry( | 522 | let entry = HistoryEntry( |
| 523 | domain: snapshot.domain, | 523 | identity: HistoryEntry.Identity( |
| 524 | timestamp: snapshot.timestamp, | 524 | domain: snapshot.domain, |
| 525 | trackedDomainID: trackedDomainID, | 525 | timestamp: snapshot.timestamp, |
| 526 | note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note, | 526 | trackedDomainID: trackedDomainID, |
| 527 | dnsSections: snapshot.dnsSections, | 527 | note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note |
| 528 | sslInfo: snapshot.sslInfo, | 528 | ), |
| 529 | httpHeaders: snapshot.httpHeaders, | 529 | inspection: HistoryEntry.Inspection( |
| 530 | reachabilityResults: snapshot.reachabilityResults, | 530 | dnsSections: snapshot.dnsSections, |
| 531 | ipGeolocation: snapshot.ipGeolocation, | 531 | sslInfo: snapshot.sslInfo, |
| 532 | emailSecurity: snapshot.emailSecurity, | 532 | httpHeaders: snapshot.httpHeaders, |
| 533 | mtaSts: snapshot.emailSecurity?.mtaSts, | 533 | reachabilityResults: snapshot.reachabilityResults, |
| 534 | ownership: snapshot.ownership, | 534 | ipGeolocation: snapshot.ipGeolocation, |
| 535 | ownershipHistory: snapshot.ownershipHistory, | 535 | emailSecurity: snapshot.emailSecurity, |
| 536 | ptrRecord: snapshot.ptrRecord, | 536 | mtaSts: snapshot.emailSecurity?.mtaSts, |
| 537 | redirectChain: snapshot.redirectChain, | 537 | ptrRecord: snapshot.ptrRecord, |
| 538 | subdomains: snapshot.subdomains, | 538 | redirectChain: snapshot.redirectChain, |
| 539 | extendedSubdomains: snapshot.extendedSubdomains, | 539 | subdomains: snapshot.subdomains, |
| 540 | dnsHistory: snapshot.dnsHistory, | 540 | extendedSubdomains: snapshot.extendedSubdomains, |
| 541 | domainPricing: snapshot.domainPricing, | 541 | dnsHistory: snapshot.dnsHistory, |
| 542 | reputation: snapshot.reputation, | 542 | portScanResults: snapshot.portScanResults, |
| 543 | portScanResults: snapshot.portScanResults, | 543 | hstsPreloaded: snapshot.hstsPreloaded, |
| 544 | hstsPreloaded: snapshot.hstsPreloaded, | 544 | availabilityResult: snapshot.availabilityResult, |
| 545 | availabilityResult: snapshot.availabilityResult, | 545 | suggestions: snapshot.suggestions |
| 546 | suggestions: snapshot.suggestions, | 546 | ), |
| 547 | appVersion: snapshot.appVersion, | 547 | registration: HistoryEntry.Registration( |
| 548 | resultSource: snapshot.resultSource, | 548 | ownership: snapshot.ownership, |
| 549 | dataSources: snapshot.dataSources, | 549 | ownershipHistory: snapshot.ownershipHistory, |
| 550 | provenanceBySection: snapshot.provenanceBySection, | 550 | domainPricing: snapshot.domainPricing |
| 551 | availabilityConfidence: snapshot.availabilityConfidence, | 551 | ), |
| 552 | ownershipConfidence: snapshot.ownershipConfidence, | 552 | intelligence: HistoryEntry.Intelligence( |
| 553 | subdomainConfidence: snapshot.subdomainConfidence, | 553 | reputation: snapshot.reputation |
| 554 | emailSecurityConfidence: snapshot.emailSecurityConfidence, | 554 | ), |
| 555 | geolocationConfidence: snapshot.geolocationConfidence, | 555 | provenance: HistoryEntry.Provenance( |
| 556 | errorDetails: snapshot.errorDetails, | 556 | appVersion: snapshot.appVersion, |
| 557 | isPartialSnapshot: snapshot.isPartialSnapshot, | 557 | resultSource: snapshot.resultSource, |
| 558 | validationIssues: snapshot.validationIssues, | 558 | dataSources: snapshot.dataSources, |
| 559 | resolverDisplayName: snapshot.resolverDisplayName, | 559 | provenanceBySection: snapshot.provenanceBySection, |
| 560 | resolverURLString: snapshot.resolverURLString, | 560 | availabilityConfidence: snapshot.availabilityConfidence, |
| 561 | totalLookupDurationMs: snapshot.totalLookupDurationMs, | 561 | ownershipConfidence: snapshot.ownershipConfidence, |
| 562 | primaryIP: Self.primaryIPAddress(from: snapshot), | 562 | subdomainConfidence: snapshot.subdomainConfidence, |
| 563 | finalRedirectURL: snapshot.redirectChain.last?.url, | 563 | emailSecurityConfidence: snapshot.emailSecurityConfidence, |
| 564 | tlsStatusSummary: Self.tlsSummary(from: snapshot), | 564 | geolocationConfidence: snapshot.geolocationConfidence, |
| 565 | emailSecuritySummary: Self.emailSummary(from: snapshot), | 565 | isPartialSnapshot: snapshot.isPartialSnapshot, |
| 566 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, | 566 | validationIssues: snapshot.validationIssues, |
| 567 | changeSummary: changeSummary, | 567 | resolverDisplayName: snapshot.resolverDisplayName, |
| 568 | sslError: snapshot.sslError, | 568 | resolverURLString: snapshot.resolverURLString, |
| 569 | httpHeadersError: snapshot.httpHeadersError, | 569 | totalLookupDurationMs: snapshot.totalLookupDurationMs |
| 570 | reachabilityError: snapshot.reachabilityError, | 570 | ), |
| 571 | ipGeolocationError: snapshot.ipGeolocationError, | 571 | summary: HistoryEntry.Summary( |
| 572 | emailSecurityError: snapshot.emailSecurityError, | 572 | primaryIP: Self.primaryIPAddress(from: snapshot), |
| 573 | ownershipError: snapshot.ownershipError, | 573 | finalRedirectURL: snapshot.redirectChain.last?.url, |
| 574 | ownershipHistoryError: snapshot.ownershipHistoryError, | 574 | tlsStatusSummary: Self.tlsSummary(from: snapshot), |
| 575 | ptrError: snapshot.ptrError, | 575 | emailSecuritySummary: Self.emailSummary(from: snapshot), |
| 576 | redirectChainError: snapshot.redirectChainError, | 576 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, |
| 577 | subdomainsError: snapshot.subdomainsError, | 577 | changeSummary: changeSummary |
| 578 | extendedSubdomainsError: snapshot.extendedSubdomainsError, | 578 | ), |
| 579 | dnsHistoryError: snapshot.dnsHistoryError, | 579 | failures: HistoryEntry.Failures( |
| 580 | domainPricingError: snapshot.domainPricingError, | 580 | errorDetails: snapshot.errorDetails, |
| 581 | reputationError: snapshot.reputationError, | 581 | sslError: snapshot.sslError, |
| 582 | portScanError: snapshot.portScanError | 582 | httpHeadersError: snapshot.httpHeadersError, |
| 583 | reachabilityError: snapshot.reachabilityError, | ||
| 584 | ipGeolocationError: snapshot.ipGeolocationError, | ||
| 585 | emailSecurityError: snapshot.emailSecurityError, | ||
| 586 | ownershipError: snapshot.ownershipError, | ||
| 587 | ownershipHistoryError: snapshot.ownershipHistoryError, | ||
| 588 | ptrError: snapshot.ptrError, | ||
| 589 | redirectChainError: snapshot.redirectChainError, | ||
| 590 | subdomainsError: snapshot.subdomainsError, | ||
| 591 | extendedSubdomainsError: snapshot.extendedSubdomainsError, | ||
| 592 | dnsHistoryError: snapshot.dnsHistoryError, | ||
| 593 | domainPricingError: snapshot.domainPricingError, | ||
| 594 | reputationError: snapshot.reputationError, | ||
| 595 | portScanError: snapshot.portScanError | ||
| 596 | ) | ||
| 583 | ) | 597 | ) |
| 584 | 598 | ||
| 585 | history.insert(entry, at: 0) | 599 | history.insert(entry, at: 0) |
DomainDig/DomainViewModel.swift +86 −72
| @@ -1936,78 +1936,92 @@ final class DomainViewModel { | |||
| 1936 | } | 1936 | } |
| 1937 | 1937 | ||
| 1938 | let entry = HistoryEntry( | 1938 | let entry = HistoryEntry( |
| 1939 | domain: snapshot.domain, | 1939 | identity: HistoryEntry.Identity( |
| 1940 | timestamp: snapshot.timestamp, | 1940 | domain: snapshot.domain, |
| 1941 | trackedDomainID: trackedDomainID, | 1941 | timestamp: snapshot.timestamp, |
| 1942 | note: currentHistoryEntry?.note, | 1942 | trackedDomainID: trackedDomainID, |
| 1943 | dnsSections: snapshot.dnsSections, | 1943 | note: currentHistoryEntry?.note |
| 1944 | sslInfo: snapshot.sslInfo, | 1944 | ), |
| 1945 | httpHeaders: snapshot.httpHeaders, | 1945 | inspection: HistoryEntry.Inspection( |
| 1946 | reachabilityResults: snapshot.reachabilityResults, | 1946 | dnsSections: snapshot.dnsSections, |
| 1947 | ipGeolocation: snapshot.ipGeolocation, | 1947 | sslInfo: snapshot.sslInfo, |
| 1948 | emailSecurity: snapshot.emailSecurity, | 1948 | httpHeaders: snapshot.httpHeaders, |
| 1949 | mtaSts: snapshot.emailSecurity?.mtaSts, | 1949 | reachabilityResults: snapshot.reachabilityResults, |
| 1950 | ownership: snapshot.ownership, | 1950 | ipGeolocation: snapshot.ipGeolocation, |
| 1951 | ownershipHistory: snapshot.ownershipHistory, | 1951 | emailSecurity: snapshot.emailSecurity, |
| 1952 | inferredProvider: intelligence.inferredProvider, | 1952 | mtaSts: snapshot.emailSecurity?.mtaSts, |
| 1953 | priorProviders: intelligence.priorProviders, | 1953 | ptrRecord: snapshot.ptrRecord, |
| 1954 | domainClassification: intelligence.domainClassification, | 1954 | redirectChain: snapshot.redirectChain, |
| 1955 | ownershipTransitions: intelligence.ownershipTransitions, | 1955 | subdomains: snapshot.subdomains, |
| 1956 | hostingTransitions: intelligence.hostingTransitions, | 1956 | extendedSubdomains: snapshot.extendedSubdomains, |
| 1957 | subdomainHistory: intelligence.subdomainHistory, | 1957 | dnsHistory: snapshot.dnsHistory, |
| 1958 | riskSignals: intelligence.riskSignals, | 1958 | portScanResults: snapshot.portScanResults, |
| 1959 | intelligenceTimeline: intelligence.timelineEvents, | 1959 | hstsPreloaded: snapshot.hstsPreloaded, |
| 1960 | ptrRecord: snapshot.ptrRecord, | 1960 | availabilityResult: snapshot.availabilityResult, |
| 1961 | redirectChain: snapshot.redirectChain, | 1961 | suggestions: snapshot.suggestions |
| 1962 | subdomains: snapshot.subdomains, | 1962 | ), |
| 1963 | extendedSubdomains: snapshot.extendedSubdomains, | 1963 | registration: HistoryEntry.Registration( |
| 1964 | dnsHistory: snapshot.dnsHistory, | 1964 | ownership: snapshot.ownership, |
| 1965 | domainPricing: snapshot.domainPricing, | 1965 | ownershipHistory: snapshot.ownershipHistory, |
| 1966 | reputation: snapshot.reputation, | 1966 | inferredProvider: intelligence.inferredProvider, |
| 1967 | portScanResults: snapshot.portScanResults, | 1967 | priorProviders: intelligence.priorProviders, |
| 1968 | hstsPreloaded: snapshot.hstsPreloaded, | 1968 | domainClassification: intelligence.domainClassification, |
| 1969 | availabilityResult: snapshot.availabilityResult, | 1969 | ownershipTransitions: intelligence.ownershipTransitions, |
| 1970 | suggestions: snapshot.suggestions, | 1970 | hostingTransitions: intelligence.hostingTransitions, |
| 1971 | appVersion: snapshot.appVersion, | 1971 | domainPricing: snapshot.domainPricing |
| 1972 | resultSource: snapshot.resultSource, | 1972 | ), |
| 1973 | dataSources: snapshot.dataSources, | 1973 | intelligence: HistoryEntry.Intelligence( |
| 1974 | provenanceBySection: snapshot.provenanceBySection, | 1974 | subdomainHistory: intelligence.subdomainHistory, |
| 1975 | availabilityConfidence: snapshot.availabilityConfidence, | 1975 | riskSignals: intelligence.riskSignals, |
| 1976 | ownershipConfidence: snapshot.ownershipConfidence, | 1976 | intelligenceTimeline: intelligence.timelineEvents, |
| 1977 | subdomainConfidence: snapshot.subdomainConfidence, | 1977 | reputation: snapshot.reputation |
| 1978 | emailSecurityConfidence: snapshot.emailSecurityConfidence, | 1978 | ), |
| 1979 | geolocationConfidence: snapshot.geolocationConfidence, | 1979 | provenance: HistoryEntry.Provenance( |
| 1980 | errorDetails: snapshot.errorDetails, | 1980 | appVersion: snapshot.appVersion, |
| 1981 | isPartialSnapshot: snapshot.isPartialSnapshot, | 1981 | resultSource: snapshot.resultSource, |
| 1982 | validationIssues: snapshot.validationIssues, | 1982 | dataSources: snapshot.dataSources, |
| 1983 | resolverDisplayName: snapshot.resolverDisplayName, | 1983 | provenanceBySection: snapshot.provenanceBySection, |
| 1984 | resolverURLString: snapshot.resolverURLString, | 1984 | availabilityConfidence: snapshot.availabilityConfidence, |
| 1985 | totalLookupDurationMs: snapshot.totalLookupDurationMs, | 1985 | ownershipConfidence: snapshot.ownershipConfidence, |
| 1986 | primaryIP: Self.primaryIPAddress(from: snapshot), | 1986 | subdomainConfidence: snapshot.subdomainConfidence, |
| 1987 | finalRedirectURL: Self.finalRedirectTarget(from: snapshot), | 1987 | emailSecurityConfidence: snapshot.emailSecurityConfidence, |
| 1988 | tlsStatusSummary: Self.httpsSummary(from: snapshot), | 1988 | geolocationConfidence: snapshot.geolocationConfidence, |
| 1989 | emailSecuritySummary: Self.emailSummary(from: snapshot), | 1989 | isPartialSnapshot: snapshot.isPartialSnapshot, |
| 1990 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, | 1990 | validationIssues: snapshot.validationIssues, |
| 1991 | changeSummary: changeSummary, | 1991 | resolverDisplayName: snapshot.resolverDisplayName, |
| 1992 | snapshotIndex: nextSnapshotIndex, | 1992 | resolverURLString: snapshot.resolverURLString, |
| 1993 | previousSnapshotID: previousSnapshotID, | 1993 | totalLookupDurationMs: snapshot.totalLookupDurationMs |
| 1994 | changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0, | 1994 | ), |
| 1995 | severitySummary: changeSummary?.severity, | 1995 | summary: HistoryEntry.Summary( |
| 1996 | sslError: snapshot.sslError, | 1996 | primaryIP: Self.primaryIPAddress(from: snapshot), |
| 1997 | httpHeadersError: snapshot.httpHeadersError, | 1997 | finalRedirectURL: Self.finalRedirectTarget(from: snapshot), |
| 1998 | reachabilityError: snapshot.reachabilityError, | 1998 | tlsStatusSummary: Self.httpsSummary(from: snapshot), |
| 1999 | ipGeolocationError: snapshot.ipGeolocationError, | 1999 | emailSecuritySummary: Self.emailSummary(from: snapshot), |
| 2000 | emailSecurityError: snapshot.emailSecurityError, | 2000 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, |
| 2001 | ownershipError: snapshot.ownershipError, | 2001 | changeSummary: changeSummary, |
| 2002 | ownershipHistoryError: snapshot.ownershipHistoryError, | 2002 | snapshotIndex: nextSnapshotIndex, |
| 2003 | ptrError: snapshot.ptrError, | 2003 | previousSnapshotID: previousSnapshotID, |
| 2004 | redirectChainError: snapshot.redirectChainError, | 2004 | changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0, |
| 2005 | subdomainsError: snapshot.subdomainsError, | 2005 | severitySummary: changeSummary?.severity |
| 2006 | extendedSubdomainsError: snapshot.extendedSubdomainsError, | 2006 | ), |
| 2007 | dnsHistoryError: snapshot.dnsHistoryError, | 2007 | failures: HistoryEntry.Failures( |
| 2008 | domainPricingError: snapshot.domainPricingError, | 2008 | errorDetails: snapshot.errorDetails, |
| 2009 | reputationError: snapshot.reputationError, | 2009 | sslError: snapshot.sslError, |
| 2010 | portScanError: snapshot.portScanError | 2010 | httpHeadersError: snapshot.httpHeadersError, |
| 2011 | reachabilityError: snapshot.reachabilityError, | ||
| 2012 | ipGeolocationError: snapshot.ipGeolocationError, | ||
| 2013 | emailSecurityError: snapshot.emailSecurityError, | ||
| 2014 | ownershipError: snapshot.ownershipError, | ||
| 2015 | ownershipHistoryError: snapshot.ownershipHistoryError, | ||
| 2016 | ptrError: snapshot.ptrError, | ||
| 2017 | redirectChainError: snapshot.redirectChainError, | ||
| 2018 | subdomainsError: snapshot.subdomainsError, | ||
| 2019 | extendedSubdomainsError: snapshot.extendedSubdomainsError, | ||
| 2020 | dnsHistoryError: snapshot.dnsHistoryError, | ||
| 2021 | domainPricingError: snapshot.domainPricingError, | ||
| 2022 | reputationError: snapshot.reputationError, | ||
| 2023 | portScanError: snapshot.portScanError | ||
| 2024 | ) | ||
| 2011 | ) | 2025 | ) |
| 2012 | 2026 | ||
| 2013 | if updateCurrentState { | 2027 | if updateCurrentState { |
DomainDig/Models.swift +190 −107
| @@ -2399,113 +2399,196 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 2399 | var reputationError: String? | 2399 | var reputationError: String? |
| 2400 | var portScanError: String? | 2400 | var portScanError: String? |
| 2401 | 2401 | ||
| 2402 | init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], | 2402 | // A full inspection produces 73 stored properties, and passing them as one |
| 2403 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], | 2403 | // flat argument list made a 72-parameter initializer no call site could read |
| 2404 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, | 2404 | // (SonarCloud swift:S107). They are grouped below by what they describe. |
| 2405 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, | 2405 | // |
| 2406 | ownershipHistory: [DomainOwnershipHistoryEvent] = [], | 2406 | // The stored properties stay flat, deliberately. This type is persisted to |
| 2407 | inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [], | 2407 | // UserDefaults and read back out of backup files, and both decode paths drop |
| 2408 | domainClassification: DomainClassificationSummary? = nil, | 2408 | // entries that will not parse rather than raising — so nesting a value would |
| 2409 | ownershipTransitions: [OwnershipTransitionEvent] = [], | 2409 | // change the encoded shape and silently discard history written by an older |
| 2410 | hostingTransitions: [HostingTransitionEvent] = [], | 2410 | // build. These groups exist at the call boundary only; the JSON is unchanged, |
| 2411 | subdomainHistory: [SubdomainHistoryEntry] = [], | 2411 | // which HistoryEntryCodableTests pins. |
| 2412 | riskSignals: [IntelligenceRiskSignal] = [], | 2412 | |
| 2413 | intelligenceTimeline: [IntelligenceTimelineEvent] = [], | 2413 | /// What was inspected, and when. |
| 2414 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], | 2414 | struct Identity { |
| 2415 | extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], | 2415 | var domain: String |
| 2416 | domainPricing: DomainPricingInsight? = nil, | 2416 | var timestamp: Date |
| 2417 | reputation: DomainReputationResult? = nil, | 2417 | var trackedDomainID: UUID? = nil |
| 2418 | portScanResults: [PortScanResult] = [], | 2418 | var note: String? = nil |
| 2419 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, | 2419 | } |
| 2420 | suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", | 2420 | |
| 2421 | resultSource: LookupResultSource = .snapshot, dataSources: [String] = [], | 2421 | /// The live probe results for this snapshot. |
| 2422 | provenanceBySection: [LookupSectionKind: SectionProvenance] = [:], | 2422 | struct Inspection { |
| 2423 | availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil, | 2423 | var dnsSections: [DNSSection] |
| 2424 | subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil, | 2424 | var sslInfo: SSLCertificateInfo? |
| 2425 | geolocationConfidence: ConfidenceLevel? = nil, | 2425 | var httpHeaders: [HTTPHeader] |
| 2426 | errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false, | 2426 | var reachabilityResults: [PortReachability] |
| 2427 | validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, | 2427 | var ipGeolocation: IPGeolocation? |
| 2428 | totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, | 2428 | var emailSecurity: EmailSecurityResult? = nil |
| 2429 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, | 2429 | var mtaSts: MTASTSResult? = nil |
| 2430 | changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil, | 2430 | var ptrRecord: String? = nil |
| 2431 | changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | 2431 | var redirectChain: [RedirectHop] = [] |
| 2432 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, | 2432 | var subdomains: [DiscoveredSubdomain] = [] |
| 2433 | emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, | 2433 | var extendedSubdomains: [DiscoveredSubdomain] = [] |
| 2434 | ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, | 2434 | var dnsHistory: [DNSHistoryEvent] = [] |
| 2435 | extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil, | 2435 | var portScanResults: [PortScanResult] = [] |
| 2436 | domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) { | 2436 | var hstsPreloaded: Bool? = nil |
| 2437 | self.domain = domain | 2437 | var availabilityResult: DomainAvailabilityResult? = nil |
| 2438 | self.timestamp = timestamp | 2438 | var suggestions: [DomainSuggestionResult] = [] |
| 2439 | self.trackedDomainID = trackedDomainID | 2439 | } |
| 2440 | self.note = note | 2440 | |
| 2441 | self.dnsSections = dnsSections | 2441 | /// Who owns the domain and where it is hosted. |
| 2442 | self.sslInfo = sslInfo | 2442 | struct Registration { |
| 2443 | self.httpHeaders = httpHeaders | 2443 | var ownership: DomainOwnership? = nil |
| 2444 | self.reachabilityResults = reachabilityResults | 2444 | var ownershipHistory: [DomainOwnershipHistoryEvent] = [] |
| 2445 | self.ipGeolocation = ipGeolocation | 2445 | var inferredProvider: InferredProviderFingerprint? = nil |
| 2446 | self.emailSecurity = emailSecurity | 2446 | var priorProviders: [String] = [] |
| 2447 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts | 2447 | var domainClassification: DomainClassificationSummary? = nil |
| 2448 | self.ownership = ownership | 2448 | var ownershipTransitions: [OwnershipTransitionEvent] = [] |
| 2449 | self.ownershipHistory = ownershipHistory | 2449 | var hostingTransitions: [HostingTransitionEvent] = [] |
| 2450 | self.inferredProvider = inferredProvider | 2450 | var domainPricing: DomainPricingInsight? = nil |
| 2451 | self.priorProviders = priorProviders | 2451 | } |
| 2452 | self.domainClassification = domainClassification | 2452 | |
| 2453 | self.ownershipTransitions = ownershipTransitions | 2453 | /// Derived signals rather than direct observations. |
| 2454 | self.hostingTransitions = hostingTransitions | 2454 | struct Intelligence { |
| 2455 | self.subdomainHistory = subdomainHistory | 2455 | var subdomainHistory: [SubdomainHistoryEntry] = [] |
| 2456 | self.riskSignals = riskSignals | 2456 | var riskSignals: [IntelligenceRiskSignal] = [] |
| 2457 | self.intelligenceTimeline = intelligenceTimeline | 2457 | var intelligenceTimeline: [IntelligenceTimelineEvent] = [] |
| 2458 | self.ptrRecord = ptrRecord | 2458 | var reputation: DomainReputationResult? = nil |
| 2459 | self.redirectChain = redirectChain | 2459 | } |
| 2460 | self.subdomains = subdomains | 2460 | |
| 2461 | self.extendedSubdomains = extendedSubdomains | 2461 | /// Where the data came from, and how much to trust it. |
| 2462 | self.dnsHistory = dnsHistory | 2462 | struct Provenance { |
| 2463 | self.domainPricing = domainPricing | 2463 | var appVersion: String = "2.7.0" |
| 2464 | self.reputation = reputation | 2464 | var resultSource: LookupResultSource = .snapshot |
| 2465 | self.portScanResults = portScanResults | 2465 | var dataSources: [String] = [] |
| 2466 | self.hstsPreloaded = hstsPreloaded | 2466 | var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:] |
| 2467 | self.availabilityResult = availabilityResult | 2467 | var availabilityConfidence: ConfidenceLevel? = nil |
| 2468 | self.suggestions = suggestions | 2468 | var ownershipConfidence: ConfidenceLevel? = nil |
| 2469 | self.appVersion = appVersion | 2469 | var subdomainConfidence: ConfidenceLevel? = nil |
| 2470 | self.resultSource = resultSource | 2470 | var emailSecurityConfidence: ConfidenceLevel? = nil |
| 2471 | self.dataSources = dataSources | 2471 | var geolocationConfidence: ConfidenceLevel? = nil |
| 2472 | self.provenanceBySection = provenanceBySection | 2472 | var isPartialSnapshot: Bool = false |
| 2473 | self.availabilityConfidence = availabilityConfidence | 2473 | var validationIssues: [String] = [] |
| 2474 | self.ownershipConfidence = ownershipConfidence | 2474 | var resolverDisplayName: String |
| 2475 | self.subdomainConfidence = subdomainConfidence | 2475 | var resolverURLString: String |
| 2476 | self.emailSecurityConfidence = emailSecurityConfidence | 2476 | var totalLookupDurationMs: Int? = nil |
| 2477 | self.geolocationConfidence = geolocationConfidence | 2477 | } |
| 2478 | self.errorDetails = errorDetails | 2478 | |
| 2479 | self.isPartialSnapshot = isPartialSnapshot | 2479 | /// Precomputed display values and change tracking. |
| 2480 | self.validationIssues = validationIssues | 2480 | struct Summary { |
| 2481 | self.resolverDisplayName = resolverDisplayName | 2481 | var primaryIP: String? = nil |
| 2482 | self.resolverURLString = resolverURLString | 2482 | var finalRedirectURL: String? = nil |
| 2483 | self.totalLookupDurationMs = totalLookupDurationMs | 2483 | var tlsStatusSummary: String? = nil |
| 2484 | self.primaryIP = primaryIP | 2484 | var emailSecuritySummary: String? = nil |
| 2485 | self.finalRedirectURL = finalRedirectURL | 2485 | var httpGradeSummary: String? = nil |
| 2486 | self.tlsStatusSummary = tlsStatusSummary | 2486 | var changeSummary: DomainChangeSummary? = nil |
| 2487 | self.emailSecuritySummary = emailSecuritySummary | 2487 | var snapshotIndex: Int? = nil |
| 2488 | self.httpGradeSummary = httpGradeSummary | 2488 | var previousSnapshotID: UUID? = nil |
| 2489 | self.changeSummary = changeSummary | 2489 | var changeCount: Int = 0 |
| 2490 | self.snapshotIndex = snapshotIndex | 2490 | var severitySummary: ChangeSeverity? = nil |
| 2491 | self.previousSnapshotID = previousSnapshotID | 2491 | } |
| 2492 | self.changeCount = changeCount | 2492 | |
| 2493 | self.severitySummary = severitySummary | 2493 | /// Per-section failures: a snapshot records what it could not collect. |
| 2494 | self.sslError = sslError | 2494 | struct Failures { |
| 2495 | self.httpHeadersError = httpHeadersError | 2495 | var errorDetails: [LookupSectionKind: InspectionFailure] = [:] |
| 2496 | self.reachabilityError = reachabilityError | 2496 | var sslError: String? = nil |
| 2497 | self.ipGeolocationError = ipGeolocationError | 2497 | var httpHeadersError: String? = nil |
| 2498 | self.emailSecurityError = emailSecurityError | 2498 | var reachabilityError: String? = nil |
| 2499 | self.ownershipError = ownershipError | 2499 | var ipGeolocationError: String? = nil |
| 2500 | self.ownershipHistoryError = ownershipHistoryError | 2500 | var emailSecurityError: String? = nil |
| 2501 | self.ptrError = ptrError | 2501 | var ownershipError: String? = nil |
| 2502 | self.redirectChainError = redirectChainError | 2502 | var ownershipHistoryError: String? = nil |
| 2503 | self.subdomainsError = subdomainsError | 2503 | var ptrError: String? = nil |
| 2504 | self.extendedSubdomainsError = extendedSubdomainsError | 2504 | var redirectChainError: String? = nil |
| 2505 | self.dnsHistoryError = dnsHistoryError | 2505 | var subdomainsError: String? = nil |
| 2506 | self.domainPricingError = domainPricingError | 2506 | var extendedSubdomainsError: String? = nil |
| 2507 | self.reputationError = reputationError | 2507 | var dnsHistoryError: String? = nil |
| 2508 | self.portScanError = portScanError | 2508 | var domainPricingError: String? = nil |
| 2509 | var reputationError: String? = nil | ||
| 2510 | var portScanError: String? = nil | ||
| 2511 | } | ||
| 2512 | |||
| 2513 | init(identity: Identity, | ||
| 2514 | inspection: Inspection, | ||
| 2515 | registration: Registration = Registration(), | ||
| 2516 | intelligence: Intelligence = Intelligence(), | ||
| 2517 | provenance: Provenance, | ||
| 2518 | summary: Summary = Summary(), | ||
| 2519 | failures: Failures = Failures()) { | ||
| 2520 | self.domain = identity.domain | ||
| 2521 | self.timestamp = identity.timestamp | ||
| 2522 | self.trackedDomainID = identity.trackedDomainID | ||
| 2523 | self.note = identity.note | ||
| 2524 | self.dnsSections = inspection.dnsSections | ||
| 2525 | self.sslInfo = inspection.sslInfo | ||
| 2526 | self.httpHeaders = inspection.httpHeaders | ||
| 2527 | self.reachabilityResults = inspection.reachabilityResults | ||
| 2528 | self.ipGeolocation = inspection.ipGeolocation | ||
| 2529 | self.emailSecurity = inspection.emailSecurity | ||
| 2530 | self.mtaSts = inspection.mtaSts ?? inspection.emailSecurity?.mtaSts | ||
| 2531 | self.ptrRecord = inspection.ptrRecord | ||
| 2532 | self.redirectChain = inspection.redirectChain | ||
| 2533 | self.subdomains = inspection.subdomains | ||
| 2534 | self.extendedSubdomains = inspection.extendedSubdomains | ||
| 2535 | self.dnsHistory = inspection.dnsHistory | ||
| 2536 | self.portScanResults = inspection.portScanResults | ||
| 2537 | self.hstsPreloaded = inspection.hstsPreloaded | ||
| 2538 | self.availabilityResult = inspection.availabilityResult | ||
| 2539 | self.suggestions = inspection.suggestions | ||
| 2540 | self.ownership = registration.ownership | ||
| 2541 | self.ownershipHistory = registration.ownershipHistory | ||
| 2542 | self.inferredProvider = registration.inferredProvider | ||
| 2543 | self.priorProviders = registration.priorProviders | ||
| 2544 | self.domainClassification = registration.domainClassification | ||
| 2545 | self.ownershipTransitions = registration.ownershipTransitions | ||
| 2546 | self.hostingTransitions = registration.hostingTransitions | ||
| 2547 | self.domainPricing = registration.domainPricing | ||
| 2548 | self.subdomainHistory = intelligence.subdomainHistory | ||
| 2549 | self.riskSignals = intelligence.riskSignals | ||
| 2550 | self.intelligenceTimeline = intelligence.intelligenceTimeline | ||
| 2551 | self.reputation = intelligence.reputation | ||
| 2552 | self.appVersion = provenance.appVersion | ||
| 2553 | self.resultSource = provenance.resultSource | ||
| 2554 | self.dataSources = provenance.dataSources | ||
| 2555 | self.provenanceBySection = provenance.provenanceBySection | ||
| 2556 | self.availabilityConfidence = provenance.availabilityConfidence | ||
| 2557 | self.ownershipConfidence = provenance.ownershipConfidence | ||
| 2558 | self.subdomainConfidence = provenance.subdomainConfidence | ||
| 2559 | self.emailSecurityConfidence = provenance.emailSecurityConfidence | ||
| 2560 | self.geolocationConfidence = provenance.geolocationConfidence | ||
| 2561 | self.isPartialSnapshot = provenance.isPartialSnapshot | ||
| 2562 | self.validationIssues = provenance.validationIssues | ||
| 2563 | self.resolverDisplayName = provenance.resolverDisplayName | ||
| 2564 | self.resolverURLString = provenance.resolverURLString | ||
| 2565 | self.totalLookupDurationMs = provenance.totalLookupDurationMs | ||
| 2566 | self.primaryIP = summary.primaryIP | ||
| 2567 | self.finalRedirectURL = summary.finalRedirectURL | ||
| 2568 | self.tlsStatusSummary = summary.tlsStatusSummary | ||
| 2569 | self.emailSecuritySummary = summary.emailSecuritySummary | ||
| 2570 | self.httpGradeSummary = summary.httpGradeSummary | ||
| 2571 | self.changeSummary = summary.changeSummary | ||
| 2572 | self.snapshotIndex = summary.snapshotIndex | ||
| 2573 | self.previousSnapshotID = summary.previousSnapshotID | ||
| 2574 | self.changeCount = summary.changeCount | ||
| 2575 | self.severitySummary = summary.severitySummary | ||
| 2576 | self.errorDetails = failures.errorDetails | ||
| 2577 | self.sslError = failures.sslError | ||
| 2578 | self.httpHeadersError = failures.httpHeadersError | ||
| 2579 | self.reachabilityError = failures.reachabilityError | ||
| 2580 | self.ipGeolocationError = failures.ipGeolocationError | ||
| 2581 | self.emailSecurityError = failures.emailSecurityError | ||
| 2582 | self.ownershipError = failures.ownershipError | ||
| 2583 | self.ownershipHistoryError = failures.ownershipHistoryError | ||
| 2584 | self.ptrError = failures.ptrError | ||
| 2585 | self.redirectChainError = failures.redirectChainError | ||
| 2586 | self.subdomainsError = failures.subdomainsError | ||
| 2587 | self.extendedSubdomainsError = failures.extendedSubdomainsError | ||
| 2588 | self.dnsHistoryError = failures.dnsHistoryError | ||
| 2589 | self.domainPricingError = failures.domainPricingError | ||
| 2590 | self.reputationError = failures.reputationError | ||
| 2591 | self.portScanError = failures.portScanError | ||
| 2509 | } | 2592 | } |
| 2510 | 2593 | ||
| 2511 | init(from decoder: Decoder) throws { | 2594 | init(from decoder: Decoder) throws { |
DomainDigTests/HistoryEntryCodableTests.swift added +129
| @@ -0,0 +1,129 @@ | |||
| 1 | import 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. | ||
| 14 | final 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 | private func makeEntry() -> HistoryEntry { | ||
| 43 | HistoryEntry( | ||
| 44 | identity: .init( | ||
| 45 | domain: "example.com", | ||
| 46 | timestamp: Date(timeIntervalSince1970: 1_700_000_000) | ||
| 47 | ), | ||
| 48 | inspection: .init( | ||
| 49 | dnsSections: [], | ||
| 50 | sslInfo: nil, | ||
| 51 | httpHeaders: [], | ||
| 52 | reachabilityResults: [], | ||
| 53 | ipGeolocation: nil | ||
| 54 | ), | ||
| 55 | provenance: .init( | ||
| 56 | resolverDisplayName: "Test Resolver", | ||
| 57 | resolverURLString: "https://resolver.example/dns-query" | ||
| 58 | ) | ||
| 59 | ) | ||
| 60 | } | ||
| 61 | |||
| 62 | private func encoder() -> JSONEncoder { | ||
| 63 | let encoder = JSONEncoder() | ||
| 64 | encoder.outputFormatting = .sortedKeys | ||
| 65 | return encoder | ||
| 66 | } | ||
| 67 | |||
| 68 | /// The set of top-level keys is the persisted contract. Optionals that are | ||
| 69 | /// nil are omitted by the synthesized encoder, so this asserts containment | ||
| 70 | /// rather than equality — no key may appear that is not accounted for. | ||
| 71 | func testEncodedKeysAreAllAccountedFor() throws { | ||
| 72 | let data = try encoder().encode(makeEntry()) | ||
| 73 | let object = try XCTUnwrap( | ||
| 74 | JSONSerialization.jsonObject(with: data) as? [String: Any] | ||
| 75 | ) | ||
| 76 | |||
| 77 | let unexpected = Set(object.keys).subtracting(Self.expectedKeys) | ||
| 78 | XCTAssertTrue( | ||
| 79 | unexpected.isEmpty, | ||
| 80 | "HistoryEntry encoded keys not in the pinned set: \(unexpected.sorted()). " | ||
| 81 | + "This changes the persisted format; older history will not decode." | ||
| 82 | ) | ||
| 83 | } | ||
| 84 | |||
| 85 | /// A populated entry must survive encode → decode → encode unchanged. This | ||
| 86 | /// is the guard that a refactor which regroups the initializer has not also | ||
| 87 | /// moved a value into a different place in the JSON. | ||
| 88 | func testRoundTripIsStable() throws { | ||
| 89 | let first = try encoder().encode(makeEntry()) | ||
| 90 | let decoded = try JSONDecoder().decode(HistoryEntry.self, from: first) | ||
| 91 | let second = try encoder().encode(decoded) | ||
| 92 | |||
| 93 | XCTAssertEqual( | ||
| 94 | first, second, | ||
| 95 | "HistoryEntry did not survive a JSON round trip unchanged" | ||
| 96 | ) | ||
| 97 | } | ||
| 98 | |||
| 99 | /// The values a caller supplies must land under the keys the persisted format | ||
| 100 | /// already uses, not merely somewhere in the document. | ||
| 101 | func testRequiredValuesEncodeAtTheTopLevel() throws { | ||
| 102 | let data = try encoder().encode(makeEntry()) | ||
| 103 | let object = try XCTUnwrap( | ||
| 104 | JSONSerialization.jsonObject(with: data) as? [String: Any] | ||
| 105 | ) | ||
| 106 | |||
| 107 | XCTAssertEqual(object["domain"] as? String, "example.com") | ||
| 108 | XCTAssertEqual(object["resolverDisplayName"] as? String, "Test Resolver") | ||
| 109 | XCTAssertEqual( | ||
| 110 | object["resolverURLString"] as? String, | ||
| 111 | "https://resolver.example/dns-query" | ||
| 112 | ) | ||
| 113 | } | ||
| 114 | |||
| 115 | /// What `loadHistoryEntries` actually does with a stored blob, end to end: | ||
| 116 | /// anything that fails to decode is dropped without error, so this pins that | ||
| 117 | /// a current-format entry survives the real read path. | ||
| 118 | func testSurvivesTheRealPersistencePath() throws { | ||
| 119 | let suite = "DomainDigTests.historyEntryCodable" | ||
| 120 | let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) | ||
| 121 | defer { defaults.removePersistentDomain(forName: suite) } | ||
| 122 | |||
| 123 | DomainDataPortabilityService.saveHistoryEntries([makeEntry()], defaults: defaults) | ||
| 124 | let loaded = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults) | ||
| 125 | |||
| 126 | XCTAssertEqual(loaded.count, 1, "entry was silently dropped by the load path") | ||
| 127 | XCTAssertEqual(loaded.first?.domain, "example.com") | ||
| 128 | } | ||
| 129 | } | ||