krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

51f436468ac124cf79c93da4bcf531dd9aad7e82

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T20:33:00Z
committer: <noreply@github.com>

Add tests for the deterministic core; keep unreadable state.json (#25)

PersistenceStore.load() returned .placeholder whenever decoding failed,
including when state.json existed but was corrupt or written by a newer
version. The next save then wrote the placeholder watch list over the
user's. It now distinguishes a missing file (first launch) from an
unreadable one, moves the unreadable file aside as
state-unreadable-<epoch>.json, and reports the failure through the
feed's existing error list.

Tests cover SeverityMapping, SecurityEventSeverity ordering and raw
values, PersistedState round-trip and legacy decode, PersistenceStore
load/save including the unreadable-file path, and GitHubAPIModels
decoding from recorded response bodies.

Replaces the empty Xcode stub test.
 octosentry/PersistenceStore.swift                |  41 ++++++-
 octosentry/SecurityEventStore.swift              |   6 +-
 octosentryTests/Fixtures.swift                   | 135 +++++++++++++++++++++++
 octosentryTests/GitHubAPIModelsTests.swift       | 103 +++++++++++++++++
 octosentryTests/PersistedStateTests.swift        |  95 ++++++++++++++++
 octosentryTests/PersistenceStoreTests.swift      | 100 +++++++++++++++++
 octosentryTests/SecurityEventSeverityTests.swift |  53 +++++++++
 octosentryTests/SeverityMappingTests.swift       |  62 +++++++++++
 octosentryTests/octosentryTests.swift            |  19 ----
 9 files changed, 587 insertions(+), 27 deletions(-)

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