krz/octosentry

macOS menu bar app to monitor GitHub security alerts

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

1.1: octosentryTests/AlertHistoryTests.swift · raw

  1//
  2//  AlertHistoryTests.swift
  3//  octosentryTests
  4//
  5
  6import Foundation
  7import Testing
  8@testable import octosentry
  9
 10struct AlertHistoryTests {
 11
 12    private let day0 = Date(timeIntervalSince1970: 1_785_000_000)
 13    private func days(_ count: Double) -> TimeInterval { count * 86_400 }
 14
 15    private func event(_ id: String, severity: SecurityEventSeverity = .high, openedDaysAgo: Double = 0) -> SecurityEvent {
 16        TestEvents.event(id: id, severity: severity, ageInHours: openedDaysAgo * 24)
 17    }
 18
 19    // MARK: - Lifecycles
 20
 21    @Test func recordingCreatesALifecyclePerAlert() {
 22        var history = AlertHistory()
 23        history.record([event("a"), event("b")], at: day0)
 24
 25        #expect(Set(history.lifecycles.keys) == ["a", "b"])
 26        #expect(history.currentlyOpenCount == 2)
 27        #expect(history.lifecycles["a"]?.resolvedAt == nil)
 28    }
 29
 30    // openedAt comes from GitHub, not from when octosentry first polled,
 31    // otherwise time-to-resolution starts at install day.
 32    @Test func lifecycleOpenedAtComesFromTheAlertNotThePoll() {
 33        var history = AlertHistory()
 34        let alert = event("a", openedDaysAgo: 10)
 35        history.record([alert], at: day0)
 36
 37        #expect(history.lifecycles["a"]?.openedAt == alert.createdAt)
 38        #expect(history.lifecycles["a"]?.openedAt != day0)
 39    }
 40
 41    @Test func anAlertThatDisappearsIsMarkedResolved() {
 42        var history = AlertHistory()
 43        history.record([event("a"), event("b")], at: day0)
 44
 45        let later = day0.addingTimeInterval(days(1))
 46        history.record([event("a")], at: later)
 47
 48        #expect(history.lifecycles["b"]?.resolvedAt == later)
 49        #expect(history.lifecycles["a"]?.resolvedAt == nil)
 50        #expect(history.currentlyOpenCount == 1)
 51    }
 52
 53    @Test func resolutionTimeIsNotOverwrittenByLaterPolls() {
 54        var history = AlertHistory()
 55        history.record([event("a")], at: day0)
 56        let resolvedAt = day0.addingTimeInterval(days(1))
 57        history.record([], at: resolvedAt)
 58        history.record([], at: day0.addingTimeInterval(days(2)))
 59
 60        #expect(history.lifecycles["a"]?.resolvedAt == resolvedAt)
 61    }
 62
 63    @Test func aReReportedAlertBecomesOpenAgain() {
 64        var history = AlertHistory()
 65        history.record([event("a")], at: day0)
 66        history.record([], at: day0.addingTimeInterval(days(1)))
 67        history.record([event("a")], at: day0.addingTimeInterval(days(2)))
 68
 69        #expect(history.lifecycles["a"]?.resolvedAt == nil)
 70        #expect(history.currentlyOpenCount == 1)
 71    }
 72
 73    // MARK: - Snapshots
 74
 75    @Test func theFirstPollRecordsASnapshot() {
 76        var history = AlertHistory()
 77        history.record([event("a", severity: .critical), event("b", severity: .low)], at: day0)
 78
 79        #expect(history.snapshots.count == 1)
 80        let snapshot = history.snapshots[0]
 81        #expect(snapshot.openCount == 2)
 82        #expect(snapshot.countsBySeverity["Critical"] == 1)
 83        #expect(snapshot.countsBySeverity["Low"] == 1)
 84        #expect(snapshot.countsBySource["dependabot"] == 2)
 85    }
 86
 87    // Polls run every 15 minutes; a snapshot per poll would be 96 a day.
 88    @Test func snapshotsAreRateLimited() {
 89        var history = AlertHistory()
 90        history.record([event("a")], at: day0)
 91        history.record([event("a")], at: day0.addingTimeInterval(900))
 92        history.record([event("a")], at: day0.addingTimeInterval(3600))
 93
 94        #expect(history.snapshots.count == 1)
 95    }
 96
 97    @Test func aSnapshotIsTakenOnceTheIntervalHasPassed() {
 98        var history = AlertHistory()
 99        history.record([event("a")], at: day0)
100        history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval))
101
102        #expect(history.snapshots.count == 2)
103    }
104
105    @Test func openCountOverTimeIsChronological() {
106        var history = AlertHistory()
107        for step in 0..<4 {
108            history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval * Double(step)))
109        }
110
111        let dates = history.openCountOverTime.map(\.recordedAt)
112        #expect(dates == dates.sorted())
113    }
114
115    // MARK: - Retention
116
117    @Test func snapshotsOlderThanRetentionAreDropped() {
118        var history = AlertHistory()
119        history.record([event("a")], at: day0)
120        history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.retention + days(1)))
121
122        #expect(history.snapshots.count == 1)
123        #expect(history.snapshots[0].recordedAt > day0)
124    }
125
126    @Test func resolvedLifecyclesAreDroppedAfterRetentionButOpenOnesAreKept() {
127        var history = AlertHistory()
128        history.record([event("old"), event("survivor")], at: day0)
129        history.record([event("survivor")], at: day0.addingTimeInterval(days(1)))
130
131        // Long enough that "old" resolved outside the retention window.
132        history.record([event("survivor")], at: day0.addingTimeInterval(AlertHistory.retention + days(2)))
133
134        #expect(history.lifecycles["old"] == nil)
135        #expect(history.lifecycles["survivor"] != nil)
136    }
137
138    // The growth question: a per-poll series must stay bounded.
139    @Test func aYearOfPollingStaysBounded() {
140        var history = AlertHistory()
141        // Every 15 minutes for 365 days.
142        let pollInterval: TimeInterval = 900
143        var date = day0
144        for _ in 0..<(365 * 96) {
145            history.record([event("a")], at: date)
146            date = date.addingTimeInterval(pollInterval)
147        }
148
149        let maximumSnapshots = Int(AlertHistory.retention / AlertHistory.snapshotInterval) + 2
150        #expect(history.snapshots.count <= maximumSnapshots)
151        #expect(history.snapshots.count > 0)
152    }
153
154    // MARK: - Trends
155
156    @Test func newAndResolvedCountsAreWindowed() {
157        var history = AlertHistory()
158        history.record([event("old", openedDaysAgo: 60), event("recent", openedDaysAgo: 1)], at: day0)
159
160        let since = day0.addingTimeInterval(-days(30))
161        #expect(history.openedCount(since: since) == 1)
162        #expect(history.resolvedCount(since: since) == 0)
163
164        history.record([event("old", openedDaysAgo: 60)], at: day0.addingTimeInterval(days(1)))
165        #expect(history.resolvedCount(since: since) == 1)
166    }
167
168    @Test func meanTimeToResolutionIsNilUntilSomethingResolves() {
169        var history = AlertHistory()
170        history.record([event("a")], at: day0)
171
172        #expect(history.meanTimeToResolution == nil)
173    }
174
175    @Test func meanTimeToResolutionAveragesOpenToResolved() {
176        var history = AlertHistory()
177        // "a" opened 2 days before day0, "b" opened 4 days before.
178        history.record([event("a", openedDaysAgo: 2), event("b", openedDaysAgo: 4)], at: day0)
179        history.record([], at: day0)
180
181        let mean = try? #require(history.meanTimeToResolution)
182        // Resolved at day0, so durations are 2 and 4 days; mean is 3.
183        #expect(mean != nil)
184        if let mean {
185            #expect(abs(mean - days(3)) < 1)
186        }
187    }
188
189    @Test func meanTimeToResolutionIgnoresStillOpenAlerts() {
190        var history = AlertHistory()
191        history.record([event("resolved", openedDaysAgo: 2), event("open", openedDaysAgo: 100)], at: day0)
192        history.record([event("open", openedDaysAgo: 100)], at: day0)
193
194        if let mean = history.meanTimeToResolution {
195            #expect(abs(mean - days(2)) < 1)
196        } else {
197            Issue.record("expected a mean time to resolution")
198        }
199    }
200
201    // MARK: - Persistence
202
203    @Test func roundTripsThroughCodable() throws {
204        var history = AlertHistory()
205        history.record([event("a", severity: .critical)], at: day0)
206        history.record([], at: day0.addingTimeInterval(AlertHistory.snapshotInterval))
207
208        let encoder = JSONEncoder()
209        encoder.dateEncodingStrategy = .iso8601
210        let decoder = JSONDecoder()
211        decoder.dateDecodingStrategy = .iso8601
212
213        let decoded = try decoder.decode(AlertHistory.self, from: try encoder.encode(history))
214
215        #expect(decoded == history)
216    }
217}