krz/octosentry
macOS menu bar app to monitor GitHub security alerts
clone: git clone https://gitbay.org/krz/octosentry.git
1.1: octosentryTests/AlertExportTests.swift · raw
1//
2// AlertExportTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct AlertExportTests {
11
12 private let events = [
13 TestEvents.event(
14 id: "a",
15 source: .dependabot,
16 repo: "octocat/hello-world",
17 severity: .critical,
18 summary: "Denial of service in some-package"
19 ),
20 TestEvents.event(
21 id: "b",
22 source: .secretScanning,
23 repo: "octocat/spoon-knife",
24 severity: .high,
25 summary: "GitHub Personal Access Token"
26 ),
27 ]
28
29 // MARK: - CSV
30
31 @Test func csvStartsWithTheHeaderRow() {
32 let lines = AlertExport.csv(events).components(separatedBy: "\r\n")
33
34 #expect(lines.first == "Source,Repository,Severity,GitHub Severity,Summary,First Seen,URL")
35 }
36
37 @Test func csvWritesOneRowPerEventInOrder() throws {
38 let lines = AlertExport.csv(events).components(separatedBy: "\r\n")
39
40 try #require(lines.count == 3)
41 #expect(lines[1].hasPrefix("Dependabot,octocat/hello-world,Critical,Critical,Denial of service in some-package,"))
42 #expect(lines[2].hasPrefix("Secret Scanning,octocat/spoon-knife,High,High,GitHub Personal Access Token,"))
43 #expect(lines[1].hasSuffix("https://github.com/octocat/hello-world/security/a"))
44 }
45
46 // The case worth being explicit about: a summary carrying every
47 // character CSV cares about.
48 @Test func csvQuotesAndEscapesAwkwardSummaries() throws {
49 let awkward = [
50 TestEvents.event(
51 id: "x",
52 summary: #"Contains a comma, a "quote", and a"# + "\nnewline"
53 )
54 ]
55
56 // Records are CRLF-separated, so the field's embedded LF stays inside
57 // the record rather than starting a new one.
58 let records = AlertExport.csv(awkward).components(separatedBy: "\r\n")
59 try #require(records.count == 2)
60
61 let record = records[1]
62 #expect(record.contains(#""Contains a comma, a ""quote"", and a"#))
63 #expect(record.contains("\nnewline\""))
64 // The quoted field opens right after the GitHub severity column.
65 #expect(record.contains(#",High,"Contains"#))
66 }
67
68 @Test func csvLeavesOrdinaryFieldsUnquoted() {
69 let plain = [TestEvents.event(id: "x", summary: "Nothing special here")]
70
71 #expect(AlertExport.csv(plain).contains(",Nothing special here,"))
72 }
73
74 @Test func csvOfAnEmptyFeedIsJustTheHeader() {
75 #expect(AlertExport.csv([]) == "Source,Repository,Severity,GitHub Severity,Summary,First Seen,URL")
76 }
77
78 @Test func csvUsesCRLFLineEndings() {
79 #expect(AlertExport.csv(events).contains("\r\n"))
80 }
81
82 // MARK: - JSON
83
84 @Test func jsonEncodesTheDocumentedFields() throws {
85 let data = try AlertExport.json(events)
86 let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]])
87
88 try #require(objects.count == 2)
89
90 let first = try #require(objects.first)
91 #expect(Set(first.keys) == [
92 "source", "repository", "severity", "githubSeverity", "summary", "firstSeen", "url",
93 ])
94 #expect(first["source"] as? String == "Dependabot")
95 #expect(first["repository"] as? String == "octocat/hello-world")
96 #expect(first["severity"] as? String == "Critical")
97 #expect(first["summary"] as? String == "Denial of service in some-package")
98 #expect(first["url"] as? String == "https://github.com/octocat/hello-world/security/a")
99 }
100
101 // Local triage state and the synthetic id are app internals.
102 @Test func jsonOmitsInternalFields() throws {
103 let json = try #require(String(data: try AlertExport.json(events), encoding: .utf8))
104
105 #expect(!json.contains("seenLocally"))
106 #expect(!json.contains("\"id\""))
107 #expect(!json.contains("detailURL"))
108 }
109
110 @Test func jsonEncodesDatesAsISO8601() throws {
111 let data = try AlertExport.json([TestEvents.event(id: "x")])
112 let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]])
113 let firstSeen = try #require(objects.first?["firstSeen"] as? String)
114
115 #expect(ISO8601DateFormatter().date(from: firstSeen) == TestEvents.referenceDate)
116 }
117
118 @Test func jsonOfAnEmptyFeedIsAnEmptyArray() throws {
119 let objects = try JSONSerialization.jsonObject(with: try AlertExport.json([])) as? [Any]
120
121 #expect(objects?.isEmpty == true)
122 }
123
124 // MARK: - Format plumbing
125
126 @Test func dataMatchesTheFormatSpecificEncoders() throws {
127 #expect(try AlertExport.data(events, format: .csv) == Data(AlertExport.csv(events).utf8))
128 #expect(try AlertExport.data(events, format: .json) == (try AlertExport.json(events)))
129 }
130
131 @Test func filenameCarriesTheDateAndExtension() {
132 let date = Date(timeIntervalSince1970: 1_785_000_000)
133
134 #expect(AlertExport.filename(format: .csv, date: date).hasSuffix(".csv"))
135 #expect(AlertExport.filename(format: .json, date: date).hasSuffix(".json"))
136 #expect(AlertExport.filename(format: .csv, date: date).hasPrefix("octosentry-alerts-"))
137 }
138
139 // Export follows the feed, so filter and sort decide its contents.
140 @Test func exportReflectsFilterAndSortApplied() throws {
141 var filter = AlertFilter()
142 filter.sources = [.secretScanning]
143 let shown = AlertSortOrder.severity.sorted(filter.apply(to: events))
144
145 let lines = AlertExport.csv(shown).components(separatedBy: "\r\n")
146
147 try #require(lines.count == 2)
148 #expect(lines[1].hasPrefix("Secret Scanning,"))
149 }
150}