Commit fb42c28038
Unsigned
Layout: unified · split
octosentry/PersistenceStore.swift +35 −6
| @@ -12,6 +12,11 @@ import Foundation | ||
| 12 | 12 | actor PersistenceStore { |
| 13 | 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 | 20 | private static let decoder: JSONDecoder = { |
| 16 | 21 | let decoder = JSONDecoder() |
| 17 | 22 | decoder.dateDecodingStrategy = .iso8601 |
| @@ -24,23 +29,47 @@ actor PersistenceStore { | ||
| 24 | 29 | return encoder |
| 25 | 30 | }() |
| 26 | 31 | |
| 27 | init() { | |
| 28 | let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 29 | let directory = appSupport.appendingPathComponent("octosentry", isDirectory: true) | |
| 32 | init(directory: URL? = nil) { | |
| 33 | let directory = directory ?? FileManager.default | |
| 34 | .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 35 | .appendingPathComponent("octosentry", isDirectory: true) | |
| 30 | 36 | try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) |
| 31 | 37 | fileURL = directory.appendingPathComponent("state.json") |
| 32 | 38 | } |
| 33 | 39 | |
| 34 | 40 | func load() -> PersistedState { |
| 35 | guard let data = try? Data(contentsOf: fileURL), | |
| 36 | let state = try? Self.decoder.decode(PersistedState.self, from: data) else { | |
| 41 | // No file at all is first launch, not a failure. | |
| 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 | 55 | return .placeholder |
| 38 | 56 | } |
| 39 | return state | |
| 40 | 57 | } |
| 41 | 58 | |
| 42 | 59 | func save(_ state: PersistedState) { |
| 43 | 60 | guard let data = try? Self.encoder.encode(state) else { return } |
| 44 | 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 | 43 | defer { isLoading = false } |
| 44 | 44 | |
| 45 | 45 | var state = await persistenceStore.load() |
| 46 | let stateLoadFailure = await persistenceStore.loadFailureMessage | |
| 46 | 47 | minimumSeverity = state.minimumSeverity |
| 47 | 48 | watchedRepos = state.watchedRepos |
| 48 | 49 | |
| 49 | 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 | 53 | return |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | 56 | let client = GitHubSecurityAPIClient(token: token) |
| 55 | 57 | |
| 56 | 58 | var fetchedEvents: [SecurityEvent] = [] |
| 57 | var errors: [String] = [] | |
| 59 | var errors: [String] = [stateLoadFailure].compactMap { $0 } | |
| 58 | 60 | var notices: [String] = [] |
| 59 | 61 | |
| 60 | 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 | } | |