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}