Commit 08428003cb
Unsigned
Layout: unified · split
octosentry/AlertFilter.swift added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | // | |
| 2 | // AlertFilter.swift | |
| 3 | // octosentry | |
| 4 | // | |
| 5 | // Per-session narrowing of the feed by source and repo. An empty set means | |
| 6 | // "no restriction" rather than "match nothing", so the default value shows | |
| 7 | // everything. Severity is not here: the minimum-severity threshold already | |
| 8 | // filters on it, and it stays a persisted floor because the badge and | |
| 9 | // notifications key off it. | |
| 10 | // | |
| 11 | ||
| 12 | import Foundation | |
| 13 | ||
| 14 | nonisolated struct AlertFilter: Equatable { | |
| 15 | var sources: Set<SecurityEventSource> = [] | |
| 16 | var repos: Set<String> = [] | |
| 17 | ||
| 18 | var isActive: Bool { | |
| 19 | !sources.isEmpty || !repos.isEmpty | |
| 20 | } | |
| 21 | ||
| 22 | func matches(_ event: SecurityEvent) -> Bool { | |
| 23 | (sources.isEmpty || sources.contains(event.source)) | |
| 24 | && (repos.isEmpty || repos.contains(event.repoFullName)) | |
| 25 | } | |
| 26 | ||
| 27 | func apply(to events: [SecurityEvent]) -> [SecurityEvent] { | |
| 28 | isActive ? events.filter(matches) : events | |
| 29 | } | |
| 30 | } | |
octosentry/AlertSortOrder.swift added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | // | |
| 2 | // AlertSortOrder.swift | |
| 3 | // octosentry | |
| 4 | // | |
| 5 | // How the feed is ordered. Persisted (see PersistedState) because it's a | |
| 6 | // standing preference, unlike the source/repo filters which are per-session. | |
| 7 | // | |
| 8 | ||
| 9 | import Foundation | |
| 10 | ||
| 11 | nonisolated enum AlertSortOrder: String, Codable, CaseIterable, Hashable { | |
| 12 | case severity | |
| 13 | case oldest | |
| 14 | case repo | |
| 15 | ||
| 16 | var displayName: String { | |
| 17 | switch self { | |
| 18 | case .severity: "Severity" | |
| 19 | case .oldest: "Oldest first" | |
| 20 | case .repo: "Repository" | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | func sorted(_ events: [SecurityEvent]) -> [SecurityEvent] { | |
| 25 | switch self { | |
| 26 | case .severity: | |
| 27 | events.sorted { lhs, rhs in | |
| 28 | lhs.severity != rhs.severity | |
| 29 | ? lhs.severity > rhs.severity | |
| 30 | : lhs.createdAt > rhs.createdAt | |
| 31 | } | |
| 32 | case .oldest: | |
| 33 | events.sorted { $0.createdAt < $1.createdAt } | |
| 34 | case .repo: | |
| 35 | events.sorted { lhs, rhs in | |
| 36 | let order = lhs.repoFullName.localizedCaseInsensitiveCompare(rhs.repoFullName) | |
| 37 | guard order == .orderedSame else { return order == .orderedAscending } | |
| 38 | return lhs.severity != rhs.severity | |
| 39 | ? lhs.severity > rhs.severity | |
| 40 | : lhs.createdAt > rhs.createdAt | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
octosentry/PersistedState.swift +9 −5
| @@ -4,8 +4,8 @@ | ||
| 4 | 4 | // |
| 5 | 5 | // Everything the app remembers across launches: the repo watch list, |
| 6 | 6 | // local-only seen-state per event, last-fetch timestamp per repo, the |
| 7 | // minimum severity filter, and whether the current token has the | |
| 8 | // broader "repo" scope needed to list repos. Flat JSON over SwiftData | |
| 7 | // minimum severity filter, the feed sort order, and whether the current | |
| 8 | // token has the broader "repo" scope needed to list repos. Flat JSON over SwiftData | |
| 9 | 9 | // (see #1) — small, inspectable, and these are already plain Codable |
| 10 | 10 | // values passed across actor boundaries, not reference types tied to a |
| 11 | 11 | // persistence context. |
| @@ -19,9 +19,10 @@ nonisolated struct PersistedState: Codable { | ||
| 19 | 19 | var lastFetchByRepo: [String: Date] |
| 20 | 20 | var minimumSeverity: SecurityEventSeverity |
| 21 | 21 | var hasRepoScope: Bool |
| 22 | var sortOrder: AlertSortOrder | |
| 22 | 23 | |
| 23 | 24 | enum CodingKeys: String, CodingKey { |
| 24 | case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope | |
| 25 | case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | init( |
| @@ -29,17 +30,19 @@ nonisolated struct PersistedState: Codable { | ||
| 29 | 30 | seenEventIDs: Set<String>, |
| 30 | 31 | lastFetchByRepo: [String: Date], |
| 31 | 32 | minimumSeverity: SecurityEventSeverity, |
| 32 | hasRepoScope: Bool = false | |
| 33 | hasRepoScope: Bool = false, | |
| 34 | sortOrder: AlertSortOrder = .severity | |
| 33 | 35 | ) { |
| 34 | 36 | self.watchedRepos = watchedRepos |
| 35 | 37 | self.seenEventIDs = seenEventIDs |
| 36 | 38 | self.lastFetchByRepo = lastFetchByRepo |
| 37 | 39 | self.minimumSeverity = minimumSeverity |
| 38 | 40 | self.hasRepoScope = hasRepoScope |
| 41 | self.sortOrder = sortOrder | |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | // Custom decode so existing state.json files saved before hasRepoScope |
| 42 | // existed still load instead of falling back to .placeholder. | |
| 45 | // and sortOrder existed still load instead of falling back to .placeholder. | |
| 43 | 46 | init(from decoder: Decoder) throws { |
| 44 | 47 | let container = try decoder.container(keyedBy: CodingKeys.self) |
| 45 | 48 | watchedRepos = try container.decode([String].self, forKey: .watchedRepos) |
| @@ -47,6 +50,7 @@ nonisolated struct PersistedState: Codable { | ||
| 47 | 50 | lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo) |
| 48 | 51 | minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity) |
| 49 | 52 | hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false |
| 53 | sortOrder = try container.decodeIfPresent(AlertSortOrder.self, forKey: .sortOrder) ?? .severity | |
| 50 | 54 | } |
| 51 | 55 | |
| 52 | 56 | static let placeholder = PersistedState( |
octosentry/SecurityEvent.swift +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | |
| 6 | 6 | import Foundation |
| 7 | 7 | |
| 8 | struct SecurityEvent: Identifiable, Codable, Sendable { | |
| 8 | nonisolated struct SecurityEvent: Identifiable, Codable, Sendable { | |
| 9 | 9 | let id: String |
| 10 | 10 | let source: SecurityEventSource |
| 11 | 11 | let repoFullName: String |
octosentry/SecurityEventListView.swift +106 −1
| @@ -26,6 +26,8 @@ struct SecurityEventListView: View { | ||
| 26 | 26 | } else if showingRepoManager { |
| 27 | 27 | RepoManagerView(store: store, authStore: authStore) |
| 28 | 28 | } else { |
| 29 | filterBar | |
| 30 | Divider() | |
| 29 | 31 | content |
| 30 | 32 | } |
| 31 | 33 | } |
| @@ -101,13 +103,104 @@ struct SecurityEventListView: View { | ||
| 101 | 103 | .padding(12) |
| 102 | 104 | } |
| 103 | 105 | |
| 106 | private var filterBar: some View { | |
| 107 | HStack(spacing: 8) { | |
| 108 | Menu { | |
| 109 | ForEach(SecurityEventSource.allCases, id: \.self) { source in | |
| 110 | Toggle(source.displayName, isOn: binding(for: source)) | |
| 111 | } | |
| 112 | } label: { | |
| 113 | FilterLabel(title: "Source", count: store.filter.sources.count) | |
| 114 | } | |
| 115 | .menuStyle(.borderlessButton) | |
| 116 | .fixedSize() | |
| 117 | ||
| 118 | Menu { | |
| 119 | if store.reposInFeed.isEmpty { | |
| 120 | Text("No repos in the current feed") | |
| 121 | } else { | |
| 122 | ForEach(store.reposInFeed, id: \.self) { repo in | |
| 123 | Toggle(repo, isOn: binding(for: repo)) | |
| 124 | } | |
| 125 | } | |
| 126 | } label: { | |
| 127 | FilterLabel(title: "Repo", count: store.filter.repos.count) | |
| 128 | } | |
| 129 | .menuStyle(.borderlessButton) | |
| 130 | .fixedSize() | |
| 131 | .disabled(store.reposInFeed.isEmpty) | |
| 132 | ||
| 133 | Menu { | |
| 134 | Picker("Sort", selection: Binding( | |
| 135 | get: { store.sortOrder }, | |
| 136 | set: { newValue in Task { await store.setSortOrder(newValue) } } | |
| 137 | )) { | |
| 138 | ForEach(AlertSortOrder.allCases, id: \.self) { order in | |
| 139 | Text(order.displayName).tag(order) | |
| 140 | } | |
| 141 | } | |
| 142 | .pickerStyle(.inline) | |
| 143 | .labelsHidden() | |
| 144 | } label: { | |
| 145 | FilterLabel(title: store.sortOrder.displayName, count: 0, systemImage: "arrow.up.arrow.down") | |
| 146 | } | |
| 147 | .menuStyle(.borderlessButton) | |
| 148 | .fixedSize() | |
| 149 | ||
| 150 | Spacer() | |
| 151 | ||
| 152 | if store.filter.isActive { | |
| 153 | Button("Clear") { | |
| 154 | store.filter = AlertFilter() | |
| 155 | } | |
| 156 | .buttonStyle(.plain) | |
| 157 | .font(.caption) | |
| 158 | .foregroundStyle(Color.accentColor) | |
| 159 | } | |
| 160 | } | |
| 161 | .padding(.horizontal, 12) | |
| 162 | .padding(.vertical, 6) | |
| 163 | } | |
| 164 | ||
| 165 | private func binding(for source: SecurityEventSource) -> Binding<Bool> { | |
| 166 | Binding( | |
| 167 | get: { store.filter.sources.contains(source) }, | |
| 168 | set: { isOn in | |
| 169 | if isOn { | |
| 170 | store.filter.sources.insert(source) | |
| 171 | } else { | |
| 172 | store.filter.sources.remove(source) | |
| 173 | } | |
| 174 | } | |
| 175 | ) | |
| 176 | } | |
| 177 | ||
| 178 | private func binding(for repo: String) -> Binding<Bool> { | |
| 179 | Binding( | |
| 180 | get: { store.filter.repos.contains(repo) }, | |
| 181 | set: { isOn in | |
| 182 | if isOn { | |
| 183 | store.filter.repos.insert(repo) | |
| 184 | } else { | |
| 185 | store.filter.repos.remove(repo) | |
| 186 | } | |
| 187 | } | |
| 188 | ) | |
| 189 | } | |
| 190 | ||
| 104 | 191 | @ViewBuilder |
| 105 | 192 | private var content: some View { |
| 106 | 193 | if store.events.isEmpty && !store.errorMessages.isEmpty { |
| 107 | 194 | StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n")) |
| 108 | 195 | } else if store.events.isEmpty && !store.isLoading { |
| 109 | 196 | VStack(spacing: 8) { |
| 110 | if store.totalFetchedCount > 0 { | |
| 197 | if store.filter.isActive && store.filteredOutCount > 0 { | |
| 198 | StatusView( | |
| 199 | systemImage: "line.3.horizontal.decrease.circle", | |
| 200 | tint: .secondary, | |
| 201 | message: "\(store.filteredOutCount) alert(s) hidden by the current filter" | |
| 202 | ) | |
| 203 | } else if store.totalFetchedCount > 0 { | |
| 111 | 204 | StatusView( |
| 112 | 205 | systemImage: "line.3.horizontal.decrease.circle", |
| 113 | 206 | tint: .secondary, |
| @@ -298,6 +391,18 @@ private struct RepoManagerView: View { | ||
| 298 | 391 | } |
| 299 | 392 | } |
| 300 | 393 | |
| 394 | private struct FilterLabel: View { | |
| 395 | let title: String | |
| 396 | let count: Int | |
| 397 | var systemImage = "line.3.horizontal.decrease.circle" | |
| 398 | ||
| 399 | var body: some View { | |
| 400 | Label(count > 0 ? "\(title) (\(count))" : title, systemImage: systemImage) | |
| 401 | .font(.caption) | |
| 402 | .foregroundStyle(count > 0 ? Color.accentColor : .secondary) | |
| 403 | } | |
| 404 | } | |
| 405 | ||
| 301 | 406 | private struct UpdateBanner: View { |
| 302 | 407 | let release: UpdateChecker.LatestRelease |
| 303 | 408 | |
octosentry/SecurityEventSource.swift +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | |
| 6 | 6 | import Foundation |
| 7 | 7 | |
| 8 | enum SecurityEventSource: String, Codable, CaseIterable { | |
| 8 | nonisolated enum SecurityEventSource: String, Codable, CaseIterable { | |
| 9 | 9 | case dependabot |
| 10 | 10 | case codeScanning |
| 11 | 11 | case secretScanning |
octosentry/SecurityEventStore.swift +34 −9
| @@ -24,10 +24,28 @@ final class SecurityEventStore { | ||
| 24 | 24 | private(set) var errorMessages: [String] = [] |
| 25 | 25 | private(set) var unavailableNotices: [String] = [] |
| 26 | 26 | private(set) var minimumSeverity: SecurityEventSeverity = .low |
| 27 | private(set) var sortOrder: AlertSortOrder = .severity | |
| 27 | 28 | private(set) var totalFetchedCount = 0 |
| 28 | 29 | private(set) var watchedRepos: [String] = [] |
| 29 | 30 | private(set) var watchListErrorMessage: String? |
| 30 | 31 | |
| 32 | /// Per-session narrowing, not persisted. Setting it re-derives `events`. | |
| 33 | var filter = AlertFilter() { | |
| 34 | didSet { applyFilters() } | |
| 35 | } | |
| 36 | ||
| 37 | /// Repos represented in the current fetch, for the repo filter menu — | |
| 38 | /// the watch list can contain repos that returned nothing. | |
| 39 | var reposInFeed: [String] { | |
| 40 | Set(rawEvents.map(\.repoFullName)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } | |
| 41 | } | |
| 42 | ||
| 43 | /// Alerts held back by the source/repo filter, as opposed to the | |
| 44 | /// severity floor, so the empty state can say which one is hiding them. | |
| 45 | var filteredOutCount: Int { | |
| 46 | rawEvents.filter { $0.severity >= minimumSeverity }.count - events.count | |
| 47 | } | |
| 48 | ||
| 31 | 49 | var unseenCriticalCount: Int { |
| 32 | 50 | rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count |
| 33 | 51 | } |
| @@ -45,6 +63,7 @@ final class SecurityEventStore { | ||
| 45 | 63 | var state = await persistenceStore.load() |
| 46 | 64 | let stateLoadFailure = await persistenceStore.loadFailureMessage |
| 47 | 65 | minimumSeverity = state.minimumSeverity |
| 66 | sortOrder = state.sortOrder | |
| 48 | 67 | watchedRepos = state.watchedRepos |
| 49 | 68 | |
| 50 | 69 | guard let token = KeychainTokenStore.load() else { |
| @@ -99,7 +118,7 @@ final class SecurityEventStore { | ||
| 99 | 118 | return event |
| 100 | 119 | } |
| 101 | 120 | totalFetchedCount = rawEvents.count |
| 102 | applyMinimumSeverityFilter() | |
| 121 | applyFilters() | |
| 103 | 122 | |
| 104 | 123 | errorMessages = errors |
| 105 | 124 | unavailableNotices = notices |
| @@ -108,13 +127,22 @@ final class SecurityEventStore { | ||
| 108 | 127 | |
| 109 | 128 | func setMinimumSeverity(_ severity: SecurityEventSeverity) async { |
| 110 | 129 | minimumSeverity = severity |
| 111 | applyMinimumSeverityFilter() | |
| 130 | applyFilters() | |
| 112 | 131 | |
| 113 | 132 | var state = await persistenceStore.load() |
| 114 | 133 | state.minimumSeverity = severity |
| 115 | 134 | await persistenceStore.save(state) |
| 116 | 135 | } |
| 117 | 136 | |
| 137 | func setSortOrder(_ order: AlertSortOrder) async { | |
| 138 | sortOrder = order | |
| 139 | applyFilters() | |
| 140 | ||
| 141 | var state = await persistenceStore.load() | |
| 142 | state.sortOrder = order | |
| 143 | await persistenceStore.save(state) | |
| 144 | } | |
| 145 | ||
| 118 | 146 | func addRepo(_ input: String) async { |
| 119 | 147 | watchListErrorMessage = nil |
| 120 | 148 | let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) |
| @@ -163,7 +191,7 @@ final class SecurityEventStore { | ||
| 163 | 191 | |
| 164 | 192 | rawEvents.removeAll { $0.id == eventID } |
| 165 | 193 | totalFetchedCount = rawEvents.count |
| 166 | applyMinimumSeverityFilter() | |
| 194 | applyFilters() | |
| 167 | 195 | } |
| 168 | 196 | |
| 169 | 197 | func removeRepo(_ repoFullName: String) async { |
| @@ -190,12 +218,9 @@ final class SecurityEventStore { | ||
| 190 | 218 | } |
| 191 | 219 | } |
| 192 | 220 | |
| 193 | private func applyMinimumSeverityFilter() { | |
| 194 | events = rawEvents | |
| 195 | .filter { $0.severity >= minimumSeverity } | |
| 196 | .sorted { lhs, rhs in | |
| 197 | lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt | |
| 198 | } | |
| 221 | private func applyFilters() { | |
| 222 | let admitted = rawEvents.filter { $0.severity >= minimumSeverity } | |
| 223 | events = sortOrder.sorted(filter.apply(to: admitted)) | |
| 199 | 224 | } |
| 200 | 225 | |
| 201 | 226 | private enum SourceOutcome { |
octosentryTests/AlertFilterTests.swift added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | // | |
| 2 | // AlertFilterTests.swift | |
| 3 | // octosentryTests | |
| 4 | // | |
| 5 | ||
| 6 | import Foundation | |
| 7 | import Testing | |
| 8 | @testable import octosentry | |
| 9 | ||
| 10 | struct AlertFilterTests { | |
| 11 | ||
| 12 | private let dependabotHello = TestEvents.event(id: "a", source: .dependabot, repo: "octocat/hello-world") | |
| 13 | private let codeScanningHello = TestEvents.event(id: "b", source: .codeScanning, repo: "octocat/hello-world") | |
| 14 | private let secretScanningSpoon = TestEvents.event(id: "c", source: .secretScanning, repo: "octocat/spoon-knife") | |
| 15 | ||
| 16 | private var allEvents: [SecurityEvent] { | |
| 17 | [dependabotHello, codeScanningHello, secretScanningSpoon] | |
| 18 | } | |
| 19 | ||
| 20 | @Test func emptyFilterIsInactiveAndMatchesEverything() { | |
| 21 | let filter = AlertFilter() | |
| 22 | ||
| 23 | #expect(filter.isActive == false) | |
| 24 | #expect(filter.apply(to: allEvents).map(\.id) == ["a", "b", "c"]) | |
| 25 | } | |
| 26 | ||
| 27 | @Test func filtersBySource() { | |
| 28 | var filter = AlertFilter() | |
| 29 | filter.sources = [.dependabot] | |
| 30 | ||
| 31 | #expect(filter.isActive) | |
| 32 | #expect(filter.apply(to: allEvents).map(\.id) == ["a"]) | |
| 33 | } | |
| 34 | ||
| 35 | @Test func sourceFilterUnionsSelectedSources() { | |
| 36 | var filter = AlertFilter() | |
| 37 | filter.sources = [.dependabot, .secretScanning] | |
| 38 | ||
| 39 | #expect(filter.apply(to: allEvents).map(\.id) == ["a", "c"]) | |
| 40 | } | |
| 41 | ||
| 42 | @Test func filtersByRepo() { | |
| 43 | var filter = AlertFilter() | |
| 44 | filter.repos = ["octocat/spoon-knife"] | |
| 45 | ||
| 46 | #expect(filter.apply(to: allEvents).map(\.id) == ["c"]) | |
| 47 | } | |
| 48 | ||
| 49 | // Source and repo intersect: an event has to satisfy both. | |
| 50 | @Test func sourceAndRepoAreCombinedWithAnd() { | |
| 51 | var filter = AlertFilter() | |
| 52 | filter.sources = [.dependabot] | |
| 53 | filter.repos = ["octocat/spoon-knife"] | |
| 54 | ||
| 55 | #expect(filter.apply(to: allEvents).isEmpty) | |
| 56 | ||
| 57 | filter.repos = ["octocat/hello-world"] | |
| 58 | #expect(filter.apply(to: allEvents).map(\.id) == ["a"]) | |
| 59 | } | |
| 60 | ||
| 61 | @Test func filterPreservesInputOrder() { | |
| 62 | var filter = AlertFilter() | |
| 63 | filter.repos = ["octocat/hello-world"] | |
| 64 | ||
| 65 | #expect(filter.apply(to: allEvents.reversed()).map(\.id) == ["b", "a"]) | |
| 66 | } | |
| 67 | } | |
octosentryTests/AlertSortOrderTests.swift added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | // | |
| 2 | // AlertSortOrderTests.swift | |
| 3 | // octosentryTests | |
| 4 | // | |
| 5 | ||
| 6 | import Foundation | |
| 7 | import Testing | |
| 8 | @testable import octosentry | |
| 9 | ||
| 10 | struct AlertSortOrderTests { | |
| 11 | ||
| 12 | // Deliberately out of order on every axis. | |
| 13 | private let events = [ | |
| 14 | TestEvents.event(id: "old-low", repo: "zulu/repo", severity: .low, ageInHours: 500), | |
| 15 | TestEvents.event(id: "new-critical", repo: "alpha/repo", severity: .critical, ageInHours: 1), | |
| 16 | TestEvents.event(id: "old-critical", repo: "mike/repo", severity: .critical, ageInHours: 100), | |
| 17 | TestEvents.event(id: "new-medium", repo: "alpha/repo", severity: .medium, ageInHours: 2), | |
| 18 | ] | |
| 19 | ||
| 20 | @Test func severityOrdersHighestFirstThenNewest() { | |
| 21 | let sorted = AlertSortOrder.severity.sorted(events) | |
| 22 | ||
| 23 | #expect(sorted.map(\.id) == ["new-critical", "old-critical", "new-medium", "old-low"]) | |
| 24 | } | |
| 25 | ||
| 26 | @Test func oldestOrdersLongestOpenFirst() { | |
| 27 | let sorted = AlertSortOrder.oldest.sorted(events) | |
| 28 | ||
| 29 | #expect(sorted.map(\.id) == ["old-low", "old-critical", "new-medium", "new-critical"]) | |
| 30 | } | |
| 31 | ||
| 32 | @Test func repoGroupsByNameThenSeverityWithinRepo() { | |
| 33 | let sorted = AlertSortOrder.repo.sorted(events) | |
| 34 | ||
| 35 | #expect(sorted.map(\.repoFullName) == ["alpha/repo", "alpha/repo", "mike/repo", "zulu/repo"]) | |
| 36 | // Within alpha/repo, critical sorts above medium. | |
| 37 | #expect(sorted.prefix(2).map(\.id) == ["new-critical", "new-medium"]) | |
| 38 | } | |
| 39 | ||
| 40 | @Test func repoOrderIsCaseInsensitive() { | |
| 41 | let mixedCase = [ | |
| 42 | TestEvents.event(id: "upper", repo: "Zulu/repo"), | |
| 43 | TestEvents.event(id: "lower", repo: "alpha/repo"), | |
| 44 | ] | |
| 45 | ||
| 46 | #expect(AlertSortOrder.repo.sorted(mixedCase).map(\.id) == ["lower", "upper"]) | |
| 47 | } | |
| 48 | ||
| 49 | @Test func sortingNeverDropsOrDuplicatesEvents() { | |
| 50 | for order in AlertSortOrder.allCases { | |
| 51 | #expect(Set(order.sorted(events).map(\.id)) == Set(events.map(\.id))) | |
| 52 | #expect(order.sorted(events).count == events.count) | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | @Test func sortingAnEmptyFeedIsEmpty() { | |
| 57 | for order in AlertSortOrder.allCases { | |
| 58 | #expect(order.sorted([]).isEmpty) | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | // Raw values are persisted in state.json. | |
| 63 | @Test func rawValuesAreStable() { | |
| 64 | #expect(AlertSortOrder.severity.rawValue == "severity") | |
| 65 | #expect(AlertSortOrder.oldest.rawValue == "oldest") | |
| 66 | #expect(AlertSortOrder.repo.rawValue == "repo") | |
| 67 | } | |
| 68 | } | |
octosentryTests/PersistedStateTests.swift +8 −4
| @@ -31,7 +31,8 @@ struct PersistedStateTests { | ||
| 31 | 31 | seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"], |
| 32 | 32 | lastFetchByRepo: ["octocat/hello-world": fetchedAt], |
| 33 | 33 | minimumSeverity: .high, |
| 34 | hasRepoScope: true | |
| 34 | hasRepoScope: true, | |
| 35 | sortOrder: .repo | |
| 35 | 36 | ) |
| 36 | 37 | |
| 37 | 38 | let decoded = try Self.decoder.decode( |
| @@ -44,6 +45,7 @@ struct PersistedStateTests { | ||
| 44 | 45 | #expect(decoded.lastFetchByRepo == original.lastFetchByRepo) |
| 45 | 46 | #expect(decoded.minimumSeverity == original.minimumSeverity) |
| 46 | 47 | #expect(decoded.hasRepoScope == original.hasRepoScope) |
| 48 | #expect(decoded.sortOrder == original.sortOrder) | |
| 47 | 49 | } |
| 48 | 50 | |
| 49 | 51 | @Test func encodesTheKeysOnDiskReadersDependOn() throws { |
| @@ -53,12 +55,12 @@ struct PersistedStateTests { | ||
| 53 | 55 | ) |
| 54 | 56 | |
| 55 | 57 | #expect(Set(object.keys) == [ |
| 56 | "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", | |
| 58 | "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder", | |
| 57 | 59 | ]) |
| 58 | 60 | } |
| 59 | 61 | |
| 60 | // A state.json written before hasRepoScope existed must still load. | |
| 61 | @Test func decodesLegacyStateWithoutRepoScope() throws { | |
| 62 | // A state.json written before hasRepoScope and sortOrder existed must still load. | |
| 63 | @Test func decodesLegacyStateWithoutRepoScopeOrSortOrder() throws { | |
| 62 | 64 | let legacy = """ |
| 63 | 65 | { |
| 64 | 66 | "watchedRepos": ["octocat/hello-world"], |
| @@ -74,6 +76,7 @@ struct PersistedStateTests { | ||
| 74 | 76 | #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"]) |
| 75 | 77 | #expect(state.minimumSeverity == .medium) |
| 76 | 78 | #expect(state.hasRepoScope == false) |
| 79 | #expect(state.sortOrder == .severity) | |
| 77 | 80 | } |
| 78 | 81 | |
| 79 | 82 | @Test func rejectsStateMissingARequiredField() { |
| @@ -91,5 +94,6 @@ struct PersistedStateTests { | ||
| 91 | 94 | #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty) |
| 92 | 95 | #expect(PersistedState.placeholder.minimumSeverity == .low) |
| 93 | 96 | #expect(PersistedState.placeholder.hasRepoScope == false) |
| 97 | #expect(PersistedState.placeholder.sortOrder == .severity) | |
| 94 | 98 | } |
| 95 | 99 | } |
octosentryTests/TestEvents.swift added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | // | |
| 2 | // TestEvents.swift | |
| 3 | // octosentryTests | |
| 4 | // | |
| 5 | ||
| 6 | import Foundation | |
| 7 | @testable import octosentry | |
| 8 | ||
| 9 | enum TestEvents { | |
| 10 | static let referenceDate = Date(timeIntervalSince1970: 1_785_000_000) | |
| 11 | ||
| 12 | static func event( | |
| 13 | id: String, | |
| 14 | source: SecurityEventSource = .dependabot, | |
| 15 | repo: String = "octocat/hello-world", | |
| 16 | severity: SecurityEventSeverity = .high, | |
| 17 | summary: String = "A summary", | |
| 18 | ageInHours: Double = 0 | |
| 19 | ) -> SecurityEvent { | |
| 20 | let createdAt = referenceDate.addingTimeInterval(-ageInHours * 3600) | |
| 21 | return SecurityEvent( | |
| 22 | id: id, | |
| 23 | source: source, | |
| 24 | repoFullName: repo, | |
| 25 | severity: severity, | |
| 26 | nativeSeverityLabel: severity.displayName, | |
| 27 | summary: summary, | |
| 28 | detailURL: URL(string: "https://github.com/\(repo)/security/\(id)")!, | |
| 29 | createdAt: createdAt, | |
| 30 | updatedAt: createdAt, | |
| 31 | seenLocally: false | |
| 32 | ) | |
| 33 | } | |
| 34 | } | |