Commit fb42c28038
Unsigned
Layout: unified · split
octosentry/PersistenceStore.swift +35 −6
| @@ -12,6 +12,11 @@ import Foundation | |||
| 12 | actor PersistenceStore { | 12 | actor PersistenceStore { |
| 13 | private let fileURL: URL | 13 | private let fileURL: URL |
| 14 | 14 | ||
| 15 | /// Set when state.json existed but could not be decoded. The unreadable | ||
| 16 | /// file is moved aside rather than overwritten, so nothing is lost | ||
| 17 | /// silently; the message names where it went. | ||
| 18 | private(set) var loadFailureMessage: String? | ||
| 19 | |||
| 15 | private static let decoder: JSONDecoder = { | 20 | private static let decoder: JSONDecoder = { |
| 16 | let decoder = JSONDecoder() | 21 | let decoder = JSONDecoder() |
| 17 | decoder.dateDecodingStrategy = .iso8601 | 22 | decoder.dateDecodingStrategy = .iso8601 |
| @@ -24,23 +29,47 @@ actor PersistenceStore { | |||
| 24 | return encoder | 29 | return encoder |
| 25 | }() | 30 | }() |
| 26 | 31 | ||
| 27 | init() { | 32 | init(directory: URL? = nil) { |
| 28 | let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | 33 | let directory = directory ?? FileManager.default |
| 29 | let directory = appSupport.appendingPathComponent("octosentry", isDirectory: true) | 34 | .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] |
| 35 | .appendingPathComponent("octosentry", isDirectory: true) | ||
| 30 | try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | 36 | try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) |
| 31 | fileURL = directory.appendingPathComponent("state.json") | 37 | fileURL = directory.appendingPathComponent("state.json") |
| 32 | } | 38 | } |
| 33 | 39 | ||
| 34 | func load() -> PersistedState { | 40 | func load() -> PersistedState { |
| 35 | guard let data = try? Data(contentsOf: fileURL), | 41 | // No file at all is first launch, not a failure. |
| 36 | let state = try? Self.decoder.decode(PersistedState.self, from: data) else { | 42 | guard FileManager.default.fileExists(atPath: fileURL.path) else { |
| 43 | return .placeholder | ||
| 44 | } | ||
| 45 | |||
| 46 | do { | ||
| 47 | let data = try Data(contentsOf: fileURL) | ||
| 48 | let state = try Self.decoder.decode(PersistedState.self, from: data) | ||
| 49 | loadFailureMessage = nil | ||
| 50 | return state | ||
| 51 | } catch { | ||
| 52 | // Falling back to .placeholder here means the next save writes a | ||
| 53 | // default watch list over the user's. Preserve the file first. | ||
| 54 | loadFailureMessage = preserveUnreadableState(error: error) | ||
| 37 | return .placeholder | 55 | return .placeholder |
| 38 | } | 56 | } |
| 39 | return state | ||
| 40 | } | 57 | } |
| 41 | 58 | ||
| 42 | func save(_ state: PersistedState) { | 59 | func save(_ state: PersistedState) { |
| 43 | guard let data = try? Self.encoder.encode(state) else { return } | 60 | guard let data = try? Self.encoder.encode(state) else { return } |
| 44 | try? data.write(to: fileURL, options: .atomic) | 61 | try? data.write(to: fileURL, options: .atomic) |
| 45 | } | 62 | } |
| 63 | |||
| 64 | private func preserveUnreadableState(error: Error) -> String { | ||
| 65 | let backupURL = fileURL | ||
| 66 | .deletingLastPathComponent() | ||
| 67 | .appendingPathComponent("state-unreadable-\(Int(Date().timeIntervalSince1970)).json") | ||
| 68 | |||
| 69 | guard (try? FileManager.default.moveItem(at: fileURL, to: backupURL)) != nil else { | ||
| 70 | return "Saved settings couldn't be read (\(error.localizedDescription))." | ||
| 71 | } | ||
| 72 | return "Saved settings couldn't be read (\(error.localizedDescription)). " | ||
| 73 | + "The previous file was kept as \(backupURL.lastPathComponent); octosentry started from defaults." | ||
| 74 | } | ||
| 46 | } | 75 | } |
octosentry/SecurityEventStore.swift +4 −2
| @@ -43,18 +43,20 @@ final class SecurityEventStore { | |||
| 43 | defer { isLoading = false } | 43 | defer { isLoading = false } |
| 44 | 44 | ||
| 45 | var state = await persistenceStore.load() | 45 | var state = await persistenceStore.load() |
| 46 | let stateLoadFailure = await persistenceStore.loadFailureMessage | ||
| 46 | minimumSeverity = state.minimumSeverity | 47 | minimumSeverity = state.minimumSeverity |
| 47 | watchedRepos = state.watchedRepos | 48 | watchedRepos = state.watchedRepos |
| 48 | 49 | ||
| 49 | guard let token = KeychainTokenStore.load() else { | 50 | guard let token = KeychainTokenStore.load() else { |
| 50 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] | 51 | errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] |
| 52 | .compactMap { $0 } | ||
| 51 | return | 53 | return |
| 52 | } | 54 | } |
| 53 | 55 | ||
| 54 | let client = GitHubSecurityAPIClient(token: token) | 56 | let client = GitHubSecurityAPIClient(token: token) |
| 55 | 57 | ||
| 56 | var fetchedEvents: [SecurityEvent] = [] | 58 | var fetchedEvents: [SecurityEvent] = [] |
| 57 | var errors: [String] = [] | 59 | var errors: [String] = [stateLoadFailure].compactMap { $0 } |
| 58 | var notices: [String] = [] | 60 | var notices: [String] = [] |
| 59 | 61 | ||
| 60 | for repoFullName in state.watchedRepos { | 62 | for repoFullName in state.watchedRepos { |
octosentryTests/Fixtures.swift added +135
| @@ -0,0 +1,135 @@ | |||
| 1 | // | ||
| 2 | // Fixtures.swift | ||
| 3 | // octosentryTests | ||
| 4 | // | ||
| 5 | // Response bodies recorded from the GitHub REST alert endpoints, trimmed | ||
| 6 | // to a couple of entries each but otherwise left as the API returns them. | ||
| 7 | // | ||
| 8 | |||
| 9 | enum Fixtures { | ||
| 10 | |||
| 11 | static let dependabotAlerts = """ | ||
| 12 | [ | ||
| 13 | { | ||
| 14 | "number": 4, | ||
| 15 | "state": "open", | ||
| 16 | "html_url": "https://github.com/octocat/hello-world/security/dependabot/4", | ||
| 17 | "created_at": "2026-06-21T22:12:22Z", | ||
| 18 | "updated_at": "2026-06-22T13:10:00Z", | ||
| 19 | "dependency": { | ||
| 20 | "package": { "ecosystem": "npm", "name": "some-package" }, | ||
| 21 | "manifest_path": "package-lock.json", | ||
| 22 | "scope": "runtime" | ||
| 23 | }, | ||
| 24 | "security_advisory": { | ||
| 25 | "ghsa_id": "GHSA-rf4j-j272-fj86", | ||
| 26 | "cve_id": "CVE-2026-11111", | ||
| 27 | "summary": "Denial of service in some-package", | ||
| 28 | "severity": "high" | ||
| 29 | }, | ||
| 30 | "security_vulnerability": { | ||
| 31 | "severity": "high", | ||
| 32 | "vulnerable_version_range": "< 1.2.3" | ||
| 33 | } | ||
| 34 | }, | ||
| 35 | { | ||
| 36 | "number": 3, | ||
| 37 | "state": "open", | ||
| 38 | "html_url": "https://github.com/octocat/hello-world/security/dependabot/3", | ||
| 39 | "created_at": "2026-06-19T09:01:00Z", | ||
| 40 | "updated_at": "2026-06-19T09:01:00Z", | ||
| 41 | "dependency": { | ||
| 42 | "package": { "ecosystem": "npm", "name": "other-package" }, | ||
| 43 | "manifest_path": "package-lock.json", | ||
| 44 | "scope": "development" | ||
| 45 | }, | ||
| 46 | "security_advisory": { | ||
| 47 | "ghsa_id": "GHSA-aaaa-bbbb-cccc", | ||
| 48 | "cve_id": null, | ||
| 49 | "summary": "Prototype pollution in other-package", | ||
| 50 | "severity": "moderate" | ||
| 51 | }, | ||
| 52 | "security_vulnerability": { | ||
| 53 | "severity": "moderate", | ||
| 54 | "vulnerable_version_range": "< 4.0.0" | ||
| 55 | } | ||
| 56 | } | ||
| 57 | ] | ||
| 58 | """ | ||
| 59 | |||
| 60 | static let codeScanningAlerts = """ | ||
| 61 | [ | ||
| 62 | { | ||
| 63 | "number": 12, | ||
| 64 | "state": "open", | ||
| 65 | "html_url": "https://github.com/octocat/hello-world/security/code-scanning/12", | ||
| 66 | "created_at": "2026-06-20T08:00:00Z", | ||
| 67 | "updated_at": "2026-06-20T08:30:00Z", | ||
| 68 | "rule": { | ||
| 69 | "id": "js/sql-injection", | ||
| 70 | "name": "js/sql-injection", | ||
| 71 | "severity": "error", | ||
| 72 | "security_severity_level": "high", | ||
| 73 | "description": "Query built from user-controlled sources", | ||
| 74 | "tags": ["security", "external/cwe/cwe-089"] | ||
| 75 | }, | ||
| 76 | "tool": { "name": "CodeQL", "version": "2.16.0" }, | ||
| 77 | "most_recent_instance": { | ||
| 78 | "ref": "refs/heads/main", | ||
| 79 | "state": "open", | ||
| 80 | "message": { "text": "This query depends on a user-provided value." }, | ||
| 81 | "location": { "path": "src/db.js", "start_line": 42 } | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | { | ||
| 85 | "number": 11, | ||
| 86 | "state": "open", | ||
| 87 | "html_url": "https://github.com/octocat/hello-world/security/code-scanning/11", | ||
| 88 | "created_at": "2026-06-18T11:00:00Z", | ||
| 89 | "updated_at": "2026-06-18T11:00:00Z", | ||
| 90 | "rule": { | ||
| 91 | "id": "js/unused-local-variable", | ||
| 92 | "name": "js/unused-local-variable", | ||
| 93 | "severity": "warning", | ||
| 94 | "security_severity_level": null, | ||
| 95 | "description": "Unused variable", | ||
| 96 | "tags": ["maintainability"] | ||
| 97 | }, | ||
| 98 | "tool": { "name": "CodeQL", "version": "2.16.0" } | ||
| 99 | } | ||
| 100 | ] | ||
| 101 | """ | ||
| 102 | |||
| 103 | static let secretScanningAlerts = """ | ||
| 104 | [ | ||
| 105 | { | ||
| 106 | "number": 2, | ||
| 107 | "state": "open", | ||
| 108 | "html_url": "https://github.com/octocat/hello-world/security/secret-scanning/2", | ||
| 109 | "created_at": "2026-06-22T17:45:00Z", | ||
| 110 | "updated_at": "2026-06-22T17:45:00Z", | ||
| 111 | "secret_type": "github_personal_access_token", | ||
| 112 | "secret_type_display_name": "GitHub Personal Access Token", | ||
| 113 | "validity": "active", | ||
| 114 | "push_protection_bypassed": false | ||
| 115 | }, | ||
| 116 | { | ||
| 117 | "number": 1, | ||
| 118 | "state": "open", | ||
| 119 | "html_url": "https://github.com/octocat/hello-world/security/secret-scanning/1", | ||
| 120 | "created_at": "2026-06-15T10:00:00Z", | ||
| 121 | "updated_at": "2026-06-15T10:00:00Z", | ||
| 122 | "secret_type": "generic_api_key", | ||
| 123 | "secret_type_display_name": "Generic API Key", | ||
| 124 | "push_protection_bypassed": false | ||
| 125 | } | ||
| 126 | ] | ||
| 127 | """ | ||
| 128 | |||
| 129 | static let userRepos = """ | ||
| 130 | [ | ||
| 131 | { "id": 1296269, "name": "hello-world", "full_name": "octocat/hello-world", "private": false }, | ||
| 132 | { "id": 1296270, "name": "spoon-knife", "full_name": "octocat/spoon-knife", "private": true } | ||
| 133 | ] | ||
| 134 | """ | ||
| 135 | } | ||
octosentryTests/GitHubAPIModelsTests.swift added +103
| @@ -0,0 +1,103 @@ | |||
| 1 | // | ||
| 2 | // GitHubAPIModelsTests.swift | ||
| 3 | // octosentryTests | ||
| 4 | // | ||
| 5 | // Decoding against recorded response bodies from the three alert | ||
| 6 | // endpoints. Fields octosentry doesn't read are left in the fixtures on | ||
| 7 | // purpose — decoding must tolerate them. | ||
| 8 | // | ||
| 9 | |||
| 10 | import Foundation | ||
| 11 | import Testing | ||
| 12 | @testable import octosentry | ||
| 13 | |||
| 14 | struct GitHubAPIModelsTests { | ||
| 15 | |||
| 16 | private static let decoder: JSONDecoder = { | ||
| 17 | let decoder = JSONDecoder() | ||
| 18 | decoder.dateDecodingStrategy = .iso8601 | ||
| 19 | return decoder | ||
| 20 | }() | ||
| 21 | |||
| 22 | private static func iso8601(_ string: String) -> Date { | ||
| 23 | ISO8601DateFormatter().date(from: string)! | ||
| 24 | } | ||
| 25 | |||
| 26 | // MARK: - Dependabot | ||
| 27 | |||
| 28 | @Test func decodesDependabotAlerts() throws { | ||
| 29 | let alerts = try Self.decoder.decode([DependabotAlertDTO].self, from: Data(Fixtures.dependabotAlerts.utf8)) | ||
| 30 | |||
| 31 | #expect(alerts.count == 2) | ||
| 32 | |||
| 33 | let first = try #require(alerts.first) | ||
| 34 | #expect(first.number == 4) | ||
| 35 | #expect(first.htmlUrl.absoluteString == "https://github.com/octocat/hello-world/security/dependabot/4") | ||
| 36 | #expect(first.securityAdvisory.summary == "Denial of service in some-package") | ||
| 37 | #expect(first.securityAdvisory.severity == "high") | ||
| 38 | #expect(first.createdAt == Self.iso8601("2026-06-21T22:12:22Z")) | ||
| 39 | #expect(first.updatedAt == Self.iso8601("2026-06-22T13:10:00Z")) | ||
| 40 | |||
| 41 | #expect(alerts[1].securityAdvisory.severity == "moderate") | ||
| 42 | } | ||
| 43 | |||
| 44 | // MARK: - Code scanning | ||
| 45 | |||
| 46 | @Test func decodesCodeScanningAlertWithSecuritySeverity() throws { | ||
| 47 | let alerts = try Self.decoder.decode([CodeScanningAlertDTO].self, from: Data(Fixtures.codeScanningAlerts.utf8)) | ||
| 48 | |||
| 49 | let first = try #require(alerts.first) | ||
| 50 | #expect(first.number == 12) | ||
| 51 | #expect(first.rule.id == "js/sql-injection") | ||
| 52 | #expect(first.rule.severity == "error") | ||
| 53 | #expect(first.rule.securitySeverityLevel == "high") | ||
| 54 | #expect(first.mostRecentInstance?.message?.text == "This query depends on a user-provided value.") | ||
| 55 | } | ||
| 56 | |||
| 57 | // A rule with no security_severity_level and no instance message is the | ||
| 58 | // case that drives the summary and severity fallbacks in the client. | ||
| 59 | @Test func decodesCodeScanningAlertWithNullsAndNoInstance() throws { | ||
| 60 | let alerts = try Self.decoder.decode([CodeScanningAlertDTO].self, from: Data(Fixtures.codeScanningAlerts.utf8)) | ||
| 61 | |||
| 62 | let second = try #require(alerts.dropFirst().first) | ||
| 63 | #expect(second.rule.securitySeverityLevel == nil) | ||
| 64 | #expect(second.rule.severity == "warning") | ||
| 65 | #expect(second.rule.description == "Unused variable") | ||
| 66 | #expect(second.mostRecentInstance == nil) | ||
| 67 | } | ||
| 68 | |||
| 69 | // MARK: - Secret scanning | ||
| 70 | |||
| 71 | @Test func decodesSecretScanningAlerts() throws { | ||
| 72 | let alerts = try Self.decoder.decode([SecretScanningAlertDTO].self, from: Data(Fixtures.secretScanningAlerts.utf8)) | ||
| 73 | |||
| 74 | #expect(alerts.count == 2) | ||
| 75 | |||
| 76 | let first = try #require(alerts.first) | ||
| 77 | #expect(first.number == 2) | ||
| 78 | #expect(first.secretTypeDisplayName == "GitHub Personal Access Token") | ||
| 79 | #expect(first.validity == "active") | ||
| 80 | |||
| 81 | // validity is absent on providers GitHub can't check. | ||
| 82 | #expect(alerts[1].validity == nil) | ||
| 83 | #expect(alerts[1].secretTypeDisplayName == "Generic API Key") | ||
| 84 | } | ||
| 85 | |||
| 86 | // MARK: - Repos | ||
| 87 | |||
| 88 | @Test func decodesAccessibleRepos() throws { | ||
| 89 | let repos = try Self.decoder.decode([GitHubRepoDTO].self, from: Data(Fixtures.userRepos.utf8)) | ||
| 90 | |||
| 91 | #expect(repos.map(\.fullName) == ["octocat/hello-world", "octocat/spoon-knife"]) | ||
| 92 | } | ||
| 93 | |||
| 94 | // MARK: - Empty responses | ||
| 95 | |||
| 96 | @Test func decodesAnEmptyAlertList() throws { | ||
| 97 | let empty = Data("[]".utf8) | ||
| 98 | |||
| 99 | #expect(try Self.decoder.decode([DependabotAlertDTO].self, from: empty).isEmpty) | ||
| 100 | #expect(try Self.decoder.decode([CodeScanningAlertDTO].self, from: empty).isEmpty) | ||
| 101 | #expect(try Self.decoder.decode([SecretScanningAlertDTO].self, from: empty).isEmpty) | ||
| 102 | } | ||
| 103 | } | ||
octosentryTests/PersistedStateTests.swift added +95
| @@ -0,0 +1,95 @@ | |||
| 1 | // | ||
| 2 | // PersistedStateTests.swift | ||
| 3 | // octosentryTests | ||
| 4 | // | ||
| 5 | // PersistedState is a file format: state.json written by one version has | ||
| 6 | // to load in the next. These pin the current shape. | ||
| 7 | // | ||
| 8 | |||
| 9 | import Foundation | ||
| 10 | import Testing | ||
| 11 | @testable import octosentry | ||
| 12 | |||
| 13 | struct PersistedStateTests { | ||
| 14 | |||
| 15 | private static let encoder: JSONEncoder = { | ||
| 16 | let encoder = JSONEncoder() | ||
| 17 | encoder.dateEncodingStrategy = .iso8601 | ||
| 18 | return encoder | ||
| 19 | }() | ||
| 20 | |||
| 21 | private static let decoder: JSONDecoder = { | ||
| 22 | let decoder = JSONDecoder() | ||
| 23 | decoder.dateDecodingStrategy = .iso8601 | ||
| 24 | return decoder | ||
| 25 | }() | ||
| 26 | |||
| 27 | @Test func roundTripsEveryField() throws { | ||
| 28 | let fetchedAt = Date(timeIntervalSince1970: 1_785_000_000) | ||
| 29 | let original = PersistedState( | ||
| 30 | watchedRepos: ["octocat/hello-world", "octocat/spoon-knife"], | ||
| 31 | seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"], | ||
| 32 | lastFetchByRepo: ["octocat/hello-world": fetchedAt], | ||
| 33 | minimumSeverity: .high, | ||
| 34 | hasRepoScope: true | ||
| 35 | ) | ||
| 36 | |||
| 37 | let decoded = try Self.decoder.decode( | ||
| 38 | PersistedState.self, | ||
| 39 | from: Self.encoder.encode(original) | ||
| 40 | ) | ||
| 41 | |||
| 42 | #expect(decoded.watchedRepos == original.watchedRepos) | ||
| 43 | #expect(decoded.seenEventIDs == original.seenEventIDs) | ||
| 44 | #expect(decoded.lastFetchByRepo == original.lastFetchByRepo) | ||
| 45 | #expect(decoded.minimumSeverity == original.minimumSeverity) | ||
| 46 | #expect(decoded.hasRepoScope == original.hasRepoScope) | ||
| 47 | } | ||
| 48 | |||
| 49 | @Test func encodesTheKeysOnDiskReadersDependOn() throws { | ||
| 50 | let data = try Self.encoder.encode(PersistedState.placeholder) | ||
| 51 | let object = try #require( | ||
| 52 | try JSONSerialization.jsonObject(with: data) as? [String: Any] | ||
| 53 | ) | ||
| 54 | |||
| 55 | #expect(Set(object.keys) == [ | ||
| 56 | "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", | ||
| 57 | ]) | ||
| 58 | } | ||
| 59 | |||
| 60 | // A state.json written before hasRepoScope existed must still load. | ||
| 61 | @Test func decodesLegacyStateWithoutRepoScope() throws { | ||
| 62 | let legacy = """ | ||
| 63 | { | ||
| 64 | "watchedRepos": ["octocat/hello-world"], | ||
| 65 | "seenEventIDs": ["dependabot-octocat/hello-world-1"], | ||
| 66 | "lastFetchByRepo": {"octocat/hello-world": "2026-08-01T12:00:00Z"}, | ||
| 67 | "minimumSeverity": 1 | ||
| 68 | } | ||
| 69 | """ | ||
| 70 | |||
| 71 | let state = try Self.decoder.decode(PersistedState.self, from: Data(legacy.utf8)) | ||
| 72 | |||
| 73 | #expect(state.watchedRepos == ["octocat/hello-world"]) | ||
| 74 | #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"]) | ||
| 75 | #expect(state.minimumSeverity == .medium) | ||
| 76 | #expect(state.hasRepoScope == false) | ||
| 77 | } | ||
| 78 | |||
| 79 | @Test func rejectsStateMissingARequiredField() { | ||
| 80 | let missingWatchList = """ | ||
| 81 | {"seenEventIDs": [], "lastFetchByRepo": {}, "minimumSeverity": 0} | ||
| 82 | """ | ||
| 83 | |||
| 84 | #expect(throws: (any Error).self) { | ||
| 85 | try Self.decoder.decode(PersistedState.self, from: Data(missingWatchList.utf8)) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | @Test func placeholderStartsWithNoSeenStateAndNoRepoScope() { | ||
| 90 | #expect(PersistedState.placeholder.seenEventIDs.isEmpty) | ||
| 91 | #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty) | ||
| 92 | #expect(PersistedState.placeholder.minimumSeverity == .low) | ||
| 93 | #expect(PersistedState.placeholder.hasRepoScope == false) | ||
| 94 | } | ||
| 95 | } | ||
octosentryTests/PersistenceStoreTests.swift added +100
| @@ -0,0 +1,100 @@ | |||
| 1 | // | ||
| 2 | // PersistenceStoreTests.swift | ||
| 3 | // octosentryTests | ||
| 4 | // | ||
| 5 | |||
| 6 | import Foundation | ||
| 7 | import Testing | ||
| 8 | @testable import octosentry | ||
| 9 | |||
| 10 | struct PersistenceStoreTests { | ||
| 11 | |||
| 12 | /// Each test gets its own directory so none of them touch the real | ||
| 13 | /// Application Support container. | ||
| 14 | private func makeTemporaryDirectory() throws -> URL { | ||
| 15 | let directory = URL(fileURLWithPath: NSTemporaryDirectory()) | ||
| 16 | .appendingPathComponent("octosentry-tests-\(UUID().uuidString)", isDirectory: true) | ||
| 17 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | ||
| 18 | return directory | ||
| 19 | } | ||
| 20 | |||
| 21 | @Test func loadReturnsPlaceholderOnFirstLaunch() async throws { | ||
| 22 | let directory = try makeTemporaryDirectory() | ||
| 23 | defer { try? FileManager.default.removeItem(at: directory) } | ||
| 24 | |||
| 25 | let store = PersistenceStore(directory: directory) | ||
| 26 | let state = await store.load() | ||
| 27 | |||
| 28 | #expect(state.watchedRepos == PersistedState.placeholder.watchedRepos) | ||
| 29 | #expect(await store.loadFailureMessage == nil) | ||
| 30 | } | ||
| 31 | |||
| 32 | @Test func savedStateSurvivesAReload() async throws { | ||
| 33 | let directory = try makeTemporaryDirectory() | ||
| 34 | defer { try? FileManager.default.removeItem(at: directory) } | ||
| 35 | |||
| 36 | let saved = PersistedState( | ||
| 37 | watchedRepos: ["octocat/hello-world"], | ||
| 38 | seenEventIDs: ["dependabot-octocat/hello-world-1"], | ||
| 39 | lastFetchByRepo: ["octocat/hello-world": Date(timeIntervalSince1970: 1_785_000_000)], | ||
| 40 | minimumSeverity: .high, | ||
| 41 | hasRepoScope: true | ||
| 42 | ) | ||
| 43 | await PersistenceStore(directory: directory).save(saved) | ||
| 44 | |||
| 45 | let reloaded = await PersistenceStore(directory: directory).load() | ||
| 46 | |||
| 47 | #expect(reloaded.watchedRepos == saved.watchedRepos) | ||
| 48 | #expect(reloaded.seenEventIDs == saved.seenEventIDs) | ||
| 49 | #expect(reloaded.lastFetchByRepo == saved.lastFetchByRepo) | ||
| 50 | #expect(reloaded.minimumSeverity == saved.minimumSeverity) | ||
| 51 | #expect(reloaded.hasRepoScope == saved.hasRepoScope) | ||
| 52 | } | ||
| 53 | |||
| 54 | // The failure that matters: state.json exists but won't decode. Falling | ||
| 55 | // back to .placeholder is fine, overwriting the user's watch list on the | ||
| 56 | // next save is not. | ||
| 57 | @Test func unreadableStateIsKeptAndReported() async throws { | ||
| 58 | let directory = try makeTemporaryDirectory() | ||
| 59 | defer { try? FileManager.default.removeItem(at: directory) } | ||
| 60 | |||
| 61 | let stateURL = directory.appendingPathComponent("state.json") | ||
| 62 | let original = Data(#"{"watchedRepos":["octocat/hello-world"],"#.utf8) | ||
| 63 | try original.write(to: stateURL) | ||
| 64 | |||
| 65 | let store = PersistenceStore(directory: directory) | ||
| 66 | let state = await store.load() | ||
| 67 | #expect(state.watchedRepos == PersistedState.placeholder.watchedRepos) | ||
| 68 | |||
| 69 | let message = await store.loadFailureMessage | ||
| 70 | #expect(message != nil) | ||
| 71 | |||
| 72 | // The original bytes are still on disk under a different name... | ||
| 73 | let preserved = try FileManager.default | ||
| 74 | .contentsOfDirectory(atPath: directory.path) | ||
| 75 | .filter { $0.hasPrefix("state-unreadable-") } | ||
| 76 | #expect(preserved.count == 1) | ||
| 77 | let preservedName = try #require(preserved.first) | ||
| 78 | #expect(message?.contains(preservedName) == true) | ||
| 79 | #expect(try Data(contentsOf: directory.appendingPathComponent(preservedName)) == original) | ||
| 80 | |||
| 81 | // ...and a later save doesn't clobber the preserved copy. | ||
| 82 | await store.save(state) | ||
| 83 | #expect(try Data(contentsOf: directory.appendingPathComponent(preservedName)) == original) | ||
| 84 | } | ||
| 85 | |||
| 86 | @Test func loadFailureIsClearedByASubsequentGoodLoad() async throws { | ||
| 87 | let directory = try makeTemporaryDirectory() | ||
| 88 | defer { try? FileManager.default.removeItem(at: directory) } | ||
| 89 | |||
| 90 | try Data("not json".utf8).write(to: directory.appendingPathComponent("state.json")) | ||
| 91 | |||
| 92 | let store = PersistenceStore(directory: directory) | ||
| 93 | _ = await store.load() | ||
| 94 | #expect(await store.loadFailureMessage != nil) | ||
| 95 | |||
| 96 | await store.save(PersistedState.placeholder) | ||
| 97 | _ = await store.load() | ||
| 98 | #expect(await store.loadFailureMessage == nil) | ||
| 99 | } | ||
| 100 | } | ||
octosentryTests/SecurityEventSeverityTests.swift added +53
| @@ -0,0 +1,53 @@ | |||
| 1 | // | ||
| 2 | // SecurityEventSeverityTests.swift | ||
| 3 | // octosentryTests | ||
| 4 | // | ||
| 5 | |||
| 6 | import Foundation | ||
| 7 | import Testing | ||
| 8 | @testable import octosentry | ||
| 9 | |||
| 10 | struct SecurityEventSeverityTests { | ||
| 11 | |||
| 12 | @Test func ordersLowToCritical() { | ||
| 13 | #expect(SecurityEventSeverity.low < .medium) | ||
| 14 | #expect(SecurityEventSeverity.medium < .high) | ||
| 15 | #expect(SecurityEventSeverity.high < .critical) | ||
| 16 | #expect(SecurityEventSeverity.critical > .low) | ||
| 17 | } | ||
| 18 | |||
| 19 | @Test func sortsAscendingByRawValue() { | ||
| 20 | let sorted = SecurityEventSeverity.allCases.shuffled().sorted() | ||
| 21 | #expect(sorted == [.low, .medium, .high, .critical]) | ||
| 22 | } | ||
| 23 | |||
| 24 | // The minimum-severity filter is a `>=` comparison, so a threshold of | ||
| 25 | // .high must admit exactly high and critical. | ||
| 26 | @Test func thresholdComparisonAdmitsAtOrAboveOnly() { | ||
| 27 | let admitted = SecurityEventSeverity.allCases.filter { $0 >= .high } | ||
| 28 | #expect(admitted == [.high, .critical]) | ||
| 29 | } | ||
| 30 | |||
| 31 | @Test func allCasesIsDeclarationOrder() { | ||
| 32 | #expect(SecurityEventSeverity.allCases == [.low, .medium, .high, .critical]) | ||
| 33 | } | ||
| 34 | |||
| 35 | // Raw values are written into state.json, so they are a file format. | ||
| 36 | @Test func rawValuesAreStable() { | ||
| 37 | #expect(SecurityEventSeverity.low.rawValue == 0) | ||
| 38 | #expect(SecurityEventSeverity.medium.rawValue == 1) | ||
| 39 | #expect(SecurityEventSeverity.high.rawValue == 2) | ||
| 40 | #expect(SecurityEventSeverity.critical.rawValue == 3) | ||
| 41 | } | ||
| 42 | |||
| 43 | @Test func roundTripsThroughCodable() throws { | ||
| 44 | for severity in SecurityEventSeverity.allCases { | ||
| 45 | let data = try JSONEncoder().encode(severity) | ||
| 46 | #expect(try JSONDecoder().decode(SecurityEventSeverity.self, from: data) == severity) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | @Test func hasADisplayNameForEveryCase() { | ||
| 51 | #expect(SecurityEventSeverity.allCases.allSatisfy { !$0.displayName.isEmpty }) | ||
| 52 | } | ||
| 53 | } | ||
octosentryTests/SeverityMappingTests.swift added +62
| @@ -0,0 +1,62 @@ | |||
| 1 | // | ||
| 2 | // SeverityMappingTests.swift | ||
| 3 | // octosentryTests | ||
| 4 | // | ||
| 5 | |||
| 6 | import Testing | ||
| 7 | @testable import octosentry | ||
| 8 | |||
| 9 | struct SeverityMappingTests { | ||
| 10 | |||
| 11 | @Test func dependabotMapsGitHubVocabulary() { | ||
| 12 | #expect(SeverityMapping.dependabot("critical") == .critical) | ||
| 13 | #expect(SeverityMapping.dependabot("high") == .high) | ||
| 14 | #expect(SeverityMapping.dependabot("moderate") == .medium) | ||
| 15 | #expect(SeverityMapping.dependabot("medium") == .medium) | ||
| 16 | #expect(SeverityMapping.dependabot("low") == .low) | ||
| 17 | } | ||
| 18 | |||
| 19 | @Test func dependabotIsCaseInsensitive() { | ||
| 20 | #expect(SeverityMapping.dependabot("CRITICAL") == .critical) | ||
| 21 | #expect(SeverityMapping.dependabot("Moderate") == .medium) | ||
| 22 | } | ||
| 23 | |||
| 24 | @Test func dependabotFallsBackToMediumOnUnknownSeverity() { | ||
| 25 | #expect(SeverityMapping.dependabot("") == .medium) | ||
| 26 | #expect(SeverityMapping.dependabot("catastrophic") == .medium) | ||
| 27 | } | ||
| 28 | |||
| 29 | @Test func codeScanningPrefersSecuritySeverityLevel() { | ||
| 30 | // security_severity_level wins even when rule.severity disagrees. | ||
| 31 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: "critical", ruleSeverity: "note") == .critical) | ||
| 32 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: "high", ruleSeverity: "note") == .high) | ||
| 33 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: "medium", ruleSeverity: "error") == .medium) | ||
| 34 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: "low", ruleSeverity: "error") == .low) | ||
| 35 | } | ||
| 36 | |||
| 37 | @Test func codeScanningFallsBackToRuleSeverity() { | ||
| 38 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: "error") == .high) | ||
| 39 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: "warning") == .medium) | ||
| 40 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: "note") == .low) | ||
| 41 | } | ||
| 42 | |||
| 43 | @Test func codeScanningFallsBackToRuleSeverityWhenLevelIsUnrecognized() { | ||
| 44 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: "unknown", ruleSeverity: "error") == .high) | ||
| 45 | } | ||
| 46 | |||
| 47 | @Test func codeScanningDefaultsToMediumWithNothingUsable() { | ||
| 48 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: nil) == .medium) | ||
| 49 | #expect(SeverityMapping.codeScanning(securitySeverityLevel: "unknown", ruleSeverity: "unknown") == .medium) | ||
| 50 | } | ||
| 51 | |||
| 52 | @Test func secretScanningTreatsActiveSecretsAsCritical() { | ||
| 53 | #expect(SeverityMapping.secretScanning(validity: "active") == .critical) | ||
| 54 | #expect(SeverityMapping.secretScanning(validity: "ACTIVE") == .critical) | ||
| 55 | } | ||
| 56 | |||
| 57 | @Test func secretScanningTreatsEverythingElseAsHigh() { | ||
| 58 | #expect(SeverityMapping.secretScanning(validity: nil) == .high) | ||
| 59 | #expect(SeverityMapping.secretScanning(validity: "inactive") == .high) | ||
| 60 | #expect(SeverityMapping.secretScanning(validity: "unknown") == .high) | ||
| 61 | } | ||
| 62 | } | ||
octosentryTests/octosentryTests.swift deleted −19
| @@ -1,19 +0,0 @@ | |||
| 1 | // | ||
| 2 | // octosentryTests.swift | ||
| 3 | // octosentryTests | ||
| 4 | // | ||
| 5 | // Created by cmc on 2026-07-17. | ||
| 6 | // | ||
| 7 | |||
| 8 | import Testing | ||
| 9 | @testable import octosentry | ||
| 10 | |||
| 11 | struct octosentryTests { | ||
| 12 | |||
| 13 | @Test func example() async throws { | ||
| 14 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. | ||
| 15 | // Swift Testing Documentation | ||
| 16 | // https://developer.apple.com/documentation/testing | ||
| 17 | } | ||
| 18 | |||
| 19 | } | ||