krz/octosentry

macOS menu bar app to monitor GitHub security alerts

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

1.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}