Commit bee5f10a92
Unsigned
Layout: unified · split
octosentry/Account.swift added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | // | |
| 2 | // Account.swift | |
| 3 | // octosentry | |
| 4 | // | |
| 5 | // A signed-in GitHub identity. The token itself stays in the Keychain; | |
| 6 | // `keychainAccount` is the item name it lives under. | |
| 7 | // | |
| 8 | // Existing single-account installs keep the item they already have — | |
| 9 | // `KeychainTokenStore.legacyAccount` is a perfectly good name for one | |
| 10 | // account, and rewriting it at upgrade time would risk stranding a token to | |
| 11 | // gain nothing. | |
| 12 | // | |
| 13 | ||
| 14 | import Foundation | |
| 15 | ||
| 16 | nonisolated struct Account: Codable, Equatable, Identifiable, Hashable { | |
| 17 | /// Stable across renames, unlike the login. Zero for an account carried | |
| 18 | /// over from a single-account install before its login was resolved. | |
| 19 | var id: Int | |
| 20 | var login: String | |
| 21 | var keychainAccount: String | |
| 22 | var hasRepoScope: Bool | |
| 23 | ||
| 24 | var displayName: String { | |
| 25 | login.isEmpty ? "GitHub account" : login | |
| 26 | } | |
| 27 | ||
| 28 | /// The account an upgrading install already has a token for. | |
| 29 | static func legacy() -> Account { | |
| 30 | Account( | |
| 31 | id: 0, | |
| 32 | login: "", | |
| 33 | keychainAccount: KeychainTokenStore.legacyAccount, | |
| 34 | hasRepoScope: false | |
| 35 | ) | |
| 36 | } | |
| 37 | ||
| 38 | static func new(id: Int, login: String, hasRepoScope: Bool) -> Account { | |
| 39 | Account( | |
| 40 | id: id, | |
| 41 | login: login, | |
| 42 | keychainAccount: "account-\(id)", | |
| 43 | hasRepoScope: hasRepoScope | |
| 44 | ) | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | /// A repo on the watch list, and the account whose token can see it. The same | |
| 49 | /// repo can appear under more than one account; the feed merges those and | |
| 50 | /// attributes them. | |
| 51 | nonisolated struct WatchedRepo: Codable, Equatable, Hashable { | |
| 52 | var fullName: String | |
| 53 | /// Account id, or 0 for entries carried over from a single-account install. | |
| 54 | var accountID: Int | |
| 55 | } | |
octosentry/AuthStore.swift +136 −20
| @@ -2,9 +2,9 @@ | ||
| 2 | 2 | // AuthStore.swift |
| 3 | 3 | // octosentry |
| 4 | 4 | // |
| 5 | // Drives the device authorization flow and mirrors whether a token is | |
| 6 | // currently in the Keychain. Replaces the GITHUB_TOKEN env var dev | |
| 7 | // shortcut (spec §13) with the real v1 auth flow (spec §6). | |
| 5 | // Drives the device authorization flow and tracks which GitHub identities | |
| 6 | // are signed in. Each account's token lives in its own Keychain item; this | |
| 7 | // holds only the account list, which is persisted alongside everything else. | |
| 8 | 8 | // |
| 9 | 9 | // Sign-in requests the minimal security_events scope by default. |
| 10 | 10 | // Broader "repo" scope (needed to list repos for the picker, #15) is |
| @@ -19,17 +19,18 @@ import Observation | ||
| 19 | 19 | final class AuthStore { |
| 20 | 20 | private(set) var state: AuthState |
| 21 | 21 | private(set) var errorMessage: String? |
| 22 | private(set) var hasRepoAccess = false | |
| 22 | private(set) var accounts: [Account] = [] | |
| 23 | 23 | |
| 24 | 24 | private let client = GitHubDeviceAuthClient() |
| 25 | 25 | private let persistenceStore = PersistenceStore() |
| 26 | 26 | private var authorizationTask: Task<Void, Never>? |
| 27 | 27 | |
| 28 | 28 | init() { |
| 29 | // Before the account list loads, fall back to whether the | |
| 30 | // single-account Keychain item exists, so an upgrading install isn't | |
| 31 | // shown a sign-in screen it doesn't need. | |
| 29 | 32 | state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut |
| 30 | Task { | |
| 31 | hasRepoAccess = await persistenceStore.load().hasRepoScope | |
| 32 | } | |
| 33 | Task { await loadAccounts() } | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | var isSignedIn: Bool { |
| @@ -37,10 +38,21 @@ final class AuthStore { | ||
| 37 | 38 | return false |
| 38 | 39 | } |
| 39 | 40 | |
| 41 | /// True when any signed-in account can list repos for the picker. | |
| 42 | var hasRepoAccess: Bool { | |
| 43 | accounts.contains(where: \.hasRepoScope) | |
| 44 | } | |
| 45 | ||
| 40 | 46 | func signIn() { |
| 41 | 47 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) |
| 42 | 48 | } |
| 43 | 49 | |
| 50 | /// Adds another identity. Same flow as signing in — GitHub decides which | |
| 51 | /// account authorizes the code. | |
| 52 | func addAccount() { | |
| 53 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) | |
| 54 | } | |
| 55 | ||
| 44 | 56 | /// Re-runs device auth with broader scope so the repo picker can list |
| 45 | 57 | /// repos. Only called explicitly from the repo picker UI, never on |
| 46 | 58 | /// the default sign-in path. |
| @@ -48,12 +60,87 @@ final class AuthStore { | ||
| 48 | 60 | beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope) |
| 49 | 61 | } |
| 50 | 62 | |
| 51 | func signOut() { | |
| 63 | /// Signs out one account, leaving the others alone. | |
| 64 | func signOut(_ account: Account) async { | |
| 65 | KeychainTokenStore.delete(account: account.keychainAccount) | |
| 66 | ||
| 67 | var persisted = await persistenceStore.load() | |
| 68 | persisted.accounts.removeAll { $0.keychainAccount == account.keychainAccount } | |
| 69 | // Its repos can no longer be fetched, so drop them too. | |
| 70 | persisted.watchedRepos.removeAll { $0.accountID == account.id } | |
| 71 | await persistenceStore.save(persisted) | |
| 72 | ||
| 73 | accounts = persisted.accounts | |
| 74 | state = accounts.isEmpty ? .signedOut : .signedIn | |
| 75 | } | |
| 76 | ||
| 77 | func signOutAll() async { | |
| 78 | for account in accounts { | |
| 79 | KeychainTokenStore.delete(account: account.keychainAccount) | |
| 80 | } | |
| 81 | // Also clear the pre-multi-account item, in case no account row | |
| 82 | // referenced it. | |
| 83 | KeychainTokenStore.delete() | |
| 84 | ||
| 85 | var persisted = await persistenceStore.load() | |
| 86 | persisted.accounts = [] | |
| 87 | persisted.watchedRepos = [] | |
| 88 | await persistenceStore.save(persisted) | |
| 89 | ||
| 52 | 90 | authorizationTask?.cancel() |
| 53 | 91 | authorizationTask = nil |
| 54 | KeychainTokenStore.delete() | |
| 92 | accounts = [] | |
| 55 | 93 | state = .signedOut |
| 56 | hasRepoAccess = false | |
| 94 | } | |
| 95 | ||
| 96 | /// Reconciles the stored account list with the Keychain, and adopts a | |
| 97 | /// token left by a single-account install. | |
| 98 | private func loadAccounts() async { | |
| 99 | var persisted = await persistenceStore.load() | |
| 100 | ||
| 101 | if persisted.accounts.isEmpty, KeychainTokenStore.load() != nil { | |
| 102 | var legacy = Account.legacy() | |
| 103 | legacy.hasRepoScope = persisted.hasRepoScope | |
| 104 | persisted.accounts = [legacy] | |
| 105 | await persistenceStore.save(persisted) | |
| 106 | } | |
| 107 | ||
| 108 | accounts = persisted.accounts | |
| 109 | state = accounts.isEmpty ? .signedOut : .signedIn | |
| 110 | ||
| 111 | await resolveMissingLogins() | |
| 112 | } | |
| 113 | ||
| 114 | /// An adopted legacy account has no login until we ask GitHub who it is. | |
| 115 | private func resolveMissingLogins() async { | |
| 116 | let unresolved = accounts.filter { $0.login.isEmpty } | |
| 117 | guard !unresolved.isEmpty else { return } | |
| 118 | ||
| 119 | var persisted = await persistenceStore.load() | |
| 120 | var changed = false | |
| 121 | ||
| 122 | for account in unresolved { | |
| 123 | guard let token = KeychainTokenStore.load(account: account.keychainAccount), | |
| 124 | let user = try? await GitHubSecurityAPIClient(token: token).fetchCurrentUser(), | |
| 125 | let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount }) | |
| 126 | else { continue } | |
| 127 | ||
| 128 | persisted.accounts[index].login = user.login | |
| 129 | // Keep the keychain item where it is; only the identity is filled in. | |
| 130 | if persisted.accounts[index].id == 0 { | |
| 131 | let oldID = persisted.accounts[index].id | |
| 132 | persisted.accounts[index].id = user.id | |
| 133 | for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == oldID { | |
| 134 | persisted.watchedRepos[repoIndex].accountID = user.id | |
| 135 | } | |
| 136 | } | |
| 137 | changed = true | |
| 138 | } | |
| 139 | ||
| 140 | if changed { | |
| 141 | await persistenceStore.save(persisted) | |
| 142 | accounts = persisted.accounts | |
| 143 | } | |
| 57 | 144 | } |
| 58 | 145 | |
| 59 | 146 | private func beginAuthorization(scope: String) { |
| @@ -71,22 +158,51 @@ final class AuthStore { | ||
| 71 | 158 | interval: deviceCode.interval, |
| 72 | 159 | expiresIn: deviceCode.expiresIn |
| 73 | 160 | ) |
| 74 | try KeychainTokenStore.save(token) | |
| 75 | ||
| 76 | let grantedRepoScope = scope.contains("repo") | |
| 77 | var persisted = await persistenceStore.load() | |
| 78 | persisted.hasRepoScope = grantedRepoScope | |
| 79 | await persistenceStore.save(persisted) | |
| 80 | hasRepoAccess = grantedRepoScope | |
| 81 | ||
| 161 | try await register(token: token, grantedRepoScope: scope.contains("repo")) | |
| 82 | 162 | state = .signedIn |
| 83 | 163 | } catch { |
| 84 | 164 | errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription |
| 85 | 165 | // A failed re-auth (e.g. requestRepoAccess while already |
| 86 | 166 | // signed in) shouldn't sign the user out of their existing |
| 87 | // valid token — only reflect reality from the Keychain. | |
| 88 | state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut | |
| 167 | // valid token — only reflect reality. | |
| 168 | state = accounts.isEmpty && KeychainTokenStore.load() == nil ? .signedOut : .signedIn | |
| 89 | 169 | } |
| 90 | 170 | } |
| 91 | 171 | } |
| 172 | ||
| 173 | /// Stores a freshly authorized token under its own Keychain item and | |
| 174 | /// records the account. Re-authorizing an account already present updates | |
| 175 | /// it in place rather than adding a duplicate. | |
| 176 | private func register(token: String, grantedRepoScope: Bool) async throws { | |
| 177 | let user = try await GitHubSecurityAPIClient(token: token).fetchCurrentUser() | |
| 178 | ||
| 179 | var persisted = await persistenceStore.load() | |
| 180 | ||
| 181 | if let index = persisted.accounts.firstIndex(where: { $0.id == user.id }) { | |
| 182 | persisted.accounts[index].login = user.login | |
| 183 | persisted.accounts[index].hasRepoScope = grantedRepoScope | |
| 184 | try KeychainTokenStore.save(token, account: persisted.accounts[index].keychainAccount) | |
| 185 | } else if let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) { | |
| 186 | // The adopted single-account entry, now identified. | |
| 187 | let keychainAccount = persisted.accounts[index].keychainAccount | |
| 188 | persisted.accounts[index] = Account( | |
| 189 | id: user.id, | |
| 190 | login: user.login, | |
| 191 | keychainAccount: keychainAccount, | |
| 192 | hasRepoScope: grantedRepoScope | |
| 193 | ) | |
| 194 | for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 { | |
| 195 | persisted.watchedRepos[repoIndex].accountID = user.id | |
| 196 | } | |
| 197 | try KeychainTokenStore.save(token, account: keychainAccount) | |
| 198 | } else { | |
| 199 | let account = Account.new(id: user.id, login: user.login, hasRepoScope: grantedRepoScope) | |
| 200 | try KeychainTokenStore.save(token, account: account.keychainAccount) | |
| 201 | persisted.accounts.append(account) | |
| 202 | } | |
| 203 | ||
| 204 | persisted.hasRepoScope = persisted.accounts.contains(where: \.hasRepoScope) | |
| 205 | await persistenceStore.save(persisted) | |
| 206 | accounts = persisted.accounts | |
| 207 | } | |
| 92 | 208 | } |
octosentry/GitHubAPIModels.swift +5
| @@ -87,6 +87,11 @@ nonisolated struct SecretScanningAlertDTO: Decodable { | ||
| 87 | 87 | } |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | nonisolated struct GitHubUserDTO: Decodable { | |
| 91 | let id: Int | |
| 92 | let login: String | |
| 93 | } | |
| 94 | ||
| 90 | 95 | nonisolated struct GitHubRepoDTO: Decodable { |
| 91 | 96 | let fullName: String |
| 92 | 97 | |
octosentry/GitHubSecurityAPIClient.swift +9
| @@ -103,6 +103,15 @@ actor GitHubSecurityAPIClient { | ||
| 103 | 103 | return dtos.map(\.fullName) |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | /// Identifies whose token this is, so accounts can be told apart and | |
| 107 | /// alerts attributed. Needs no scope beyond a valid user token. | |
| 108 | func fetchCurrentUser() async throws -> GitHubUserDTO { | |
| 109 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! | |
| 110 | components.path = "/user" | |
| 111 | let (data, _) = try await fetchData(url: components.url!) | |
| 112 | return try decode(data) | |
| 113 | } | |
| 114 | ||
| 106 | 115 | private func alertsURL(owner: String, repo: String, path: String) -> URL { |
| 107 | 116 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 108 | 117 | components.path = "/repos/\(owner)/\(repo)/\(path)" |
octosentry/KeychainTokenStore.swift +7 −4
| @@ -14,9 +14,12 @@ import Security | ||
| 14 | 14 | |
| 15 | 15 | nonisolated enum KeychainTokenStore { |
| 16 | 16 | private static let service = "net.cleberg.octosentry.github-token" |
| 17 | private static let account = "github-oauth-token" | |
| 18 | 17 | |
| 19 | static func save(_ token: String) throws { | |
| 18 | /// The item name used before octosentry supported more than one account. | |
| 19 | /// Still the name for that account's token — upgrading does not move it. | |
| 20 | static let legacyAccount = "github-oauth-token" | |
| 21 | ||
| 22 | static func save(_ token: String, account: String = legacyAccount) throws { | |
| 20 | 23 | let query: [String: Any] = [ |
| 21 | 24 | kSecClass as String: kSecClassGenericPassword, |
| 22 | 25 | kSecAttrService as String: service, |
| @@ -35,7 +38,7 @@ nonisolated enum KeychainTokenStore { | ||
| 35 | 38 | } |
| 36 | 39 | } |
| 37 | 40 | |
| 38 | static func load() -> String? { | |
| 41 | static func load(account: String = legacyAccount) -> String? { | |
| 39 | 42 | let query: [String: Any] = [ |
| 40 | 43 | kSecClass as String: kSecClassGenericPassword, |
| 41 | 44 | kSecAttrService as String: service, |
| @@ -50,7 +53,7 @@ nonisolated enum KeychainTokenStore { | ||
| 50 | 53 | return String(data: data, encoding: .utf8) |
| 51 | 54 | } |
| 52 | 55 | |
| 53 | static func delete() { | |
| 56 | static func delete(account: String = legacyAccount) { | |
| 54 | 57 | let query: [String: Any] = [ |
| 55 | 58 | kSecClass as String: kSecClassGenericPassword, |
| 56 | 59 | kSecAttrService as String: service, |
octosentry/PersistedState.swift +18 −6
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | // PersistedState.swift |
| 3 | 3 | // octosentry |
| 4 | 4 | // |
| 5 | // Everything the app remembers across launches: the repo watch list, | |
| 5 | // Everything the app remembers across launches: the signed-in accounts and | |
| 6 | // the repo watch list, | |
| 6 | 7 | // local-only seen-state per event, last-fetch timestamp per repo, the |
| 7 | 8 | // minimum severity filter, the feed sort order, local triage state, the |
| 8 | 9 | // per-repo alert IDs the notifier has already accounted for, and whether the current token |
| @@ -15,7 +16,8 @@ | ||
| 15 | 16 | import Foundation |
| 16 | 17 | |
| 17 | 18 | nonisolated struct PersistedState: Codable { |
| 18 | var watchedRepos: [String] | |
| 19 | var accounts: [Account] | |
| 20 | var watchedRepos: [WatchedRepo] | |
| 19 | 21 | var seenEventIDs: Set<String> |
| 20 | 22 | var lastFetchByRepo: [String: Date] |
| 21 | 23 | var minimumSeverity: SecurityEventSeverity |
| @@ -38,11 +40,12 @@ nonisolated struct PersistedState: Codable { | ||
| 38 | 40 | |
| 39 | 41 | enum CodingKeys: String, CodingKey { |
| 40 | 42 | case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder |
| 41 | case notifiedEventIDsByRepo, triage, history | |
| 43 | case notifiedEventIDsByRepo, triage, history, accounts | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | init( |
| 45 | watchedRepos: [String], | |
| 47 | accounts: [Account] = [], | |
| 48 | watchedRepos: [WatchedRepo], | |
| 46 | 49 | seenEventIDs: Set<String>, |
| 47 | 50 | lastFetchByRepo: [String: Date], |
| 48 | 51 | minimumSeverity: SecurityEventSeverity, |
| @@ -52,6 +55,7 @@ nonisolated struct PersistedState: Codable { | ||
| 52 | 55 | triage: AlertTriage = AlertTriage(), |
| 53 | 56 | history: AlertHistory = AlertHistory() |
| 54 | 57 | ) { |
| 58 | self.accounts = accounts | |
| 55 | 59 | self.watchedRepos = watchedRepos |
| 56 | 60 | self.seenEventIDs = seenEventIDs |
| 57 | 61 | self.lastFetchByRepo = lastFetchByRepo |
| @@ -67,7 +71,15 @@ nonisolated struct PersistedState: Codable { | ||
| 67 | 71 | // and sortOrder existed still load instead of falling back to .placeholder. |
| 68 | 72 | init(from decoder: Decoder) throws { |
| 69 | 73 | let container = try decoder.container(keyedBy: CodingKeys.self) |
| 70 | watchedRepos = try container.decode([String].self, forKey: .watchedRepos) | |
| 74 | accounts = try container.decodeIfPresent([Account].self, forKey: .accounts) ?? [] | |
| 75 | // Before multi-account, watchedRepos was a plain [String] belonging to | |
| 76 | // the one signed-in account. Decode either shape. | |
| 77 | if let repos = try? container.decode([WatchedRepo].self, forKey: .watchedRepos) { | |
| 78 | watchedRepos = repos | |
| 79 | } else { | |
| 80 | let names = try container.decode([String].self, forKey: .watchedRepos) | |
| 81 | watchedRepos = names.map { WatchedRepo(fullName: $0, accountID: 0) } | |
| 82 | } | |
| 71 | 83 | seenEventIDs = try container.decode(Set<String>.self, forKey: .seenEventIDs) |
| 72 | 84 | lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo) |
| 73 | 85 | minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity) |
| @@ -82,7 +94,7 @@ nonisolated struct PersistedState: Codable { | ||
| 82 | 94 | } |
| 83 | 95 | |
| 84 | 96 | static let placeholder = PersistedState( |
| 85 | watchedRepos: ["ccleberg/cleberg.net"], | |
| 97 | watchedRepos: [WatchedRepo(fullName: "ccleberg/cleberg.net", accountID: 0)], | |
| 86 | 98 | seenEventIDs: [], |
| 87 | 99 | lastFetchByRepo: [:], |
| 88 | 100 | minimumSeverity: .low |
octosentry/SecurityEvent.swift +3
| @@ -16,4 +16,7 @@ nonisolated struct SecurityEvent: Identifiable, Codable, Sendable { | ||
| 16 | 16 | let createdAt: Date |
| 17 | 17 | let updatedAt: Date |
| 18 | 18 | var seenLocally: Bool |
| 19 | /// Logins of the accounts whose token can see this alert. More than one | |
| 20 | /// when the same repo is watched under several identities. | |
| 21 | var accountLogins: [String] = [] | |
| 19 | 22 | } |
octosentry/SecurityEventListView.swift +91 −52
| @@ -300,6 +300,9 @@ struct SecurityEventListView: View { | ||
| 300 | 300 | ForEach(store.events) { event in |
| 301 | 301 | SecurityEventRow( |
| 302 | 302 | event: event, |
| 303 | attribution: store.showsAttribution(for: event.repoFullName) | |
| 304 | ? event.accountLogins.sorted().joined(separator: ", ") | |
| 305 | : nil, | |
| 303 | 306 | isHidden: store.triage.isHidden(event.id, now: .now), |
| 304 | 307 | snoozedUntil: store.triage.snoozedUntil(event.id, now: .now), |
| 305 | 308 | onMarkSeen: { Task { await store.markSeen(event.id) } }, |
| @@ -321,28 +324,76 @@ private struct RepoManagerView: View { | ||
| 321 | 324 | var store: SecurityEventStore |
| 322 | 325 | var authStore: AuthStore |
| 323 | 326 | @State private var newRepoText = "" |
| 324 | @State private var isBrowsingRepos = false | |
| 327 | @State private var addingToAccountID: Int? | |
| 328 | @State private var browsingAccount: Account? | |
| 325 | 329 | @State private var availableRepos: [String] = [] |
| 326 | 330 | @State private var isLoadingRepos = false |
| 327 | 331 | @State private var browseErrorMessage: String? |
| 328 | 332 | |
| 329 | 333 | var body: some View { |
| 330 | VStack(alignment: .leading, spacing: 10) { | |
| 331 | Text("Watched Repositories") | |
| 332 | .font(.subheadline.weight(.semibold)) | |
| 334 | ScrollView { | |
| 335 | VStack(alignment: .leading, spacing: 12) { | |
| 336 | ForEach(authStore.accounts) { account in | |
| 337 | accountSection(account) | |
| 338 | Divider() | |
| 339 | } | |
| 340 | ||
| 341 | Button { | |
| 342 | authStore.addAccount() | |
| 343 | } label: { | |
| 344 | Label("Add another account", systemImage: "person.badge.plus") | |
| 345 | .font(.caption) | |
| 346 | } | |
| 347 | .buttonStyle(.plain) | |
| 348 | .foregroundStyle(Color.accentColor) | |
| 349 | ||
| 350 | if let errorMessage = store.watchListErrorMessage { | |
| 351 | Text(errorMessage) | |
| 352 | .font(.caption2) | |
| 353 | .foregroundStyle(.red) | |
| 354 | } | |
| 333 | 355 | |
| 334 | if store.watchedRepos.isEmpty { | |
| 335 | Text("No repos watched yet.") | |
| 336 | .font(.callout) | |
| 356 | Divider() | |
| 357 | ||
| 358 | Button("Sign Out of All Accounts") { | |
| 359 | Task { await authStore.signOutAll() } | |
| 360 | } | |
| 361 | .buttonStyle(.plain) | |
| 362 | .foregroundStyle(.red) | |
| 363 | } | |
| 364 | .padding(12) | |
| 365 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 366 | } | |
| 367 | } | |
| 368 | ||
| 369 | @ViewBuilder | |
| 370 | private func accountSection(_ account: Account) -> some View { | |
| 371 | VStack(alignment: .leading, spacing: 8) { | |
| 372 | HStack { | |
| 373 | Text(account.displayName) | |
| 374 | .font(.subheadline.weight(.semibold)) | |
| 375 | Spacer() | |
| 376 | Button("Sign out") { | |
| 377 | Task { await authStore.signOut(account) } | |
| 378 | } | |
| 379 | .buttonStyle(.plain) | |
| 380 | .font(.caption) | |
| 381 | .foregroundStyle(.red) | |
| 382 | } | |
| 383 | ||
| 384 | let repos = store.watchedRepos.filter { $0.accountID == account.id } | |
| 385 | if repos.isEmpty { | |
| 386 | Text("No repos watched under this account.") | |
| 387 | .font(.caption) | |
| 337 | 388 | .foregroundStyle(.secondary) |
| 338 | 389 | } else { |
| 339 | ForEach(store.watchedRepos, id: \.self) { repo in | |
| 390 | ForEach(repos, id: \.self) { watched in | |
| 340 | 391 | HStack { |
| 341 | Text(repo) | |
| 392 | Text(watched.fullName) | |
| 342 | 393 | .font(.callout) |
| 343 | 394 | Spacer() |
| 344 | 395 | Button { |
| 345 | Task { await store.removeRepo(repo) } | |
| 396 | Task { await store.removeRepo(watched) } | |
| 346 | 397 | } label: { |
| 347 | 398 | Image(systemName: "minus.circle.fill") |
| 348 | 399 | .foregroundStyle(.red) |
| @@ -352,57 +403,41 @@ private struct RepoManagerView: View { | ||
| 352 | 403 | } |
| 353 | 404 | } |
| 354 | 405 | |
| 355 | Divider() | |
| 356 | ||
| 357 | if isBrowsingRepos { | |
| 358 | browsingContent | |
| 406 | if browsingAccount == account { | |
| 407 | browsingContent(account) | |
| 359 | 408 | } else { |
| 360 | 409 | HStack { |
| 361 | TextField("owner/repo", text: $newRepoText) | |
| 362 | .textFieldStyle(.roundedBorder) | |
| 363 | .onSubmit(addRepo) | |
| 364 | ||
| 365 | Button("Add", action: addRepo) | |
| 366 | .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 410 | TextField("owner/repo", text: Binding( | |
| 411 | get: { addingToAccountID == account.id ? newRepoText : "" }, | |
| 412 | set: { newRepoText = $0; addingToAccountID = account.id } | |
| 413 | )) | |
| 414 | .textFieldStyle(.roundedBorder) | |
| 415 | .onSubmit { addRepo(to: account) } | |
| 416 | ||
| 417 | Button("Add") { addRepo(to: account) } | |
| 418 | .disabled(addingToAccountID != account.id | |
| 419 | || newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 367 | 420 | } |
| 368 | 421 | |
| 369 | Button(action: startBrowsing) { | |
| 370 | Label("Browse your repos", systemImage: "list.bullet") | |
| 422 | Button { startBrowsing(account) } label: { | |
| 423 | Label("Browse repos", systemImage: "list.bullet") | |
| 371 | 424 | .font(.caption) |
| 372 | 425 | } |
| 373 | 426 | .buttonStyle(.plain) |
| 374 | 427 | .foregroundStyle(Color.accentColor) |
| 375 | 428 | } |
| 376 | ||
| 377 | if let errorMessage = store.watchListErrorMessage { | |
| 378 | Text(errorMessage) | |
| 379 | .font(.caption2) | |
| 380 | .foregroundStyle(.red) | |
| 381 | } | |
| 382 | ||
| 383 | Spacer() | |
| 384 | ||
| 385 | Divider() | |
| 386 | ||
| 387 | Button("Sign Out") { | |
| 388 | authStore.signOut() | |
| 389 | } | |
| 390 | .buttonStyle(.plain) | |
| 391 | .foregroundStyle(.red) | |
| 392 | 429 | } |
| 393 | .padding(12) | |
| 394 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 395 | 430 | } |
| 396 | 431 | |
| 397 | 432 | @ViewBuilder |
| 398 | private var browsingContent: some View { | |
| 433 | private func browsingContent(_ account: Account) -> some View { | |
| 399 | 434 | VStack(alignment: .leading, spacing: 6) { |
| 400 | 435 | HStack { |
| 401 | Text("Your Repositories") | |
| 436 | Text("Repositories") | |
| 402 | 437 | .font(.caption.weight(.semibold)) |
| 403 | 438 | Spacer() |
| 404 | 439 | Button { |
| 405 | isBrowsingRepos = false | |
| 440 | browsingAccount = nil | |
| 406 | 441 | } label: { |
| 407 | 442 | Image(systemName: "xmark.circle") |
| 408 | 443 | } |
| @@ -418,7 +453,10 @@ private struct RepoManagerView: View { | ||
| 418 | 453 | .font(.caption2) |
| 419 | 454 | .foregroundStyle(.red) |
| 420 | 455 | } else { |
| 421 | let selectableRepos = availableRepos.filter { !store.watchedRepos.contains($0) } | |
| 456 | let watched = Set( | |
| 457 | store.watchedRepos.filter { $0.accountID == account.id }.map(\.fullName) | |
| 458 | ) | |
| 459 | let selectableRepos = availableRepos.filter { !watched.contains($0) } | |
| 422 | 460 | if selectableRepos.isEmpty { |
| 423 | 461 | Text("All visible repos are already watched.") |
| 424 | 462 | .font(.caption2) |
| @@ -428,8 +466,8 @@ private struct RepoManagerView: View { | ||
| 428 | 466 | LazyVStack(alignment: .leading, spacing: 4) { |
| 429 | 467 | ForEach(selectableRepos, id: \.self) { repo in |
| 430 | 468 | Button { |
| 431 | Task { await store.addRepo(repo) } | |
| 432 | isBrowsingRepos = false | |
| 469 | Task { await store.addRepo(repo, accountID: account.id) } | |
| 470 | browsingAccount = nil | |
| 433 | 471 | } label: { |
| 434 | 472 | Text(repo) |
| 435 | 473 | .font(.callout) |
| @@ -445,17 +483,17 @@ private struct RepoManagerView: View { | ||
| 445 | 483 | } |
| 446 | 484 | } |
| 447 | 485 | |
| 448 | private func startBrowsing() { | |
| 449 | guard authStore.hasRepoAccess else { | |
| 486 | private func startBrowsing(_ account: Account) { | |
| 487 | guard account.hasRepoScope else { | |
| 450 | 488 | authStore.requestRepoAccess() |
| 451 | 489 | return |
| 452 | 490 | } |
| 453 | isBrowsingRepos = true | |
| 491 | browsingAccount = account | |
| 454 | 492 | isLoadingRepos = true |
| 455 | 493 | browseErrorMessage = nil |
| 456 | 494 | Task { |
| 457 | 495 | do { |
| 458 | availableRepos = try await store.fetchAccessibleRepos() | |
| 496 | availableRepos = try await store.fetchAccessibleRepos(for: account) | |
| 459 | 497 | } catch { |
| 460 | 498 | browseErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription |
| 461 | 499 | } |
| @@ -463,10 +501,11 @@ private struct RepoManagerView: View { | ||
| 463 | 501 | } |
| 464 | 502 | } |
| 465 | 503 | |
| 466 | private func addRepo() { | |
| 504 | private func addRepo(to account: Account) { | |
| 467 | 505 | let text = newRepoText |
| 468 | 506 | newRepoText = "" |
| 469 | Task { await store.addRepo(text) } | |
| 507 | addingToAccountID = nil | |
| 508 | Task { await store.addRepo(text, accountID: account.id) } | |
| 470 | 509 | } |
| 471 | 510 | } |
| 472 | 511 | |
octosentry/SecurityEventRow.swift +12
| @@ -8,6 +8,9 @@ import SwiftUI | ||
| 8 | 8 | |
| 9 | 9 | struct SecurityEventRow: View { |
| 10 | 10 | let event: SecurityEvent |
| 11 | /// Which account(s) this alert came through. Set only when the same repo | |
| 12 | /// is watched under more than one identity — otherwise it's noise. | |
| 13 | var attribution: String? | |
| 11 | 14 | var isHidden = false |
| 12 | 15 | var snoozedUntil: Date? |
| 13 | 16 | var onMarkSeen: () -> Void |
| @@ -45,6 +48,15 @@ struct SecurityEventRow: View { | ||
| 45 | 48 | .font(.caption) |
| 46 | 49 | .foregroundStyle(.secondary) |
| 47 | 50 | |
| 51 | if let attribution { | |
| 52 | Text(attribution) | |
| 53 | .font(.caption2) | |
| 54 | .foregroundStyle(.secondary) | |
| 55 | .padding(.horizontal, 5) | |
| 56 | .padding(.vertical, 1) | |
| 57 | .background(.secondary.opacity(0.15), in: Capsule()) | |
| 58 | } | |
| 59 | ||
| 48 | 60 | Spacer() |
| 49 | 61 | |
| 50 | 62 | if let hiddenLabel { |
octosentry/SecurityEventStore.swift +83 −21
| @@ -26,7 +26,7 @@ final class SecurityEventStore { | ||
| 26 | 26 | private(set) var minimumSeverity: SecurityEventSeverity = .low |
| 27 | 27 | private(set) var sortOrder: AlertSortOrder = .severity |
| 28 | 28 | private(set) var totalFetchedCount = 0 |
| 29 | private(set) var watchedRepos: [String] = [] | |
| 29 | private(set) var watchedRepos: [WatchedRepo] = [] | |
| 30 | 30 | private(set) var watchListErrorMessage: String? |
| 31 | 31 | |
| 32 | 32 | /// Per-session narrowing, not persisted. Setting it re-derives `events`. |
| @@ -48,6 +48,14 @@ final class SecurityEventStore { | ||
| 48 | 48 | Set(rawEvents.map(\.repoFullName)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | /// Repo names watched under more than one account — the feed shows | |
| 52 | /// attribution only for these, since it's noise everywhere else. | |
| 53 | private var reposWatchedBySeveralAccounts: Set<String> = [] | |
| 54 | ||
| 55 | func showsAttribution(for repoFullName: String) -> Bool { | |
| 56 | reposWatchedBySeveralAccounts.contains(repoFullName) | |
| 57 | } | |
| 58 | ||
| 51 | 59 | /// Alerts held back by the source/repo filter, as opposed to the |
| 52 | 60 | /// severity floor, so the empty state can say which one is hiding them. |
| 53 | 61 | var filteredOutCount: Int { |
| @@ -76,14 +84,13 @@ final class SecurityEventStore { | ||
| 76 | 84 | triage = state.triage |
| 77 | 85 | history = state.history |
| 78 | 86 | |
| 79 | guard let token = KeychainTokenStore.load() else { | |
| 87 | let accountsByID = Dictionary(uniqueKeysWithValues: state.accounts.map { ($0.id, $0) }) | |
| 88 | guard !accountsByID.isEmpty else { | |
| 80 | 89 | errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] |
| 81 | 90 | .compactMap { $0 } |
| 82 | 91 | return |
| 83 | 92 | } |
| 84 | 93 | |
| 85 | let client = GitHubSecurityAPIClient(token: token) | |
| 86 | ||
| 87 | 94 | var fetchedEvents: [SecurityEvent] = [] |
| 88 | 95 | var errors: [String] = [stateLoadFailure].compactMap { $0 } |
| 89 | 96 | var notices: [String] = [] |
| @@ -92,19 +99,30 @@ final class SecurityEventStore { | ||
| 92 | 99 | // alerts look new on the next poll. |
| 93 | 100 | var fetchedEventsByRepo: [String: [SecurityEvent]] = [:] |
| 94 | 101 | |
| 95 | for repoFullName in state.watchedRepos { | |
| 102 | for watched in state.watchedRepos { | |
| 103 | let repoFullName = watched.fullName | |
| 96 | 104 | let parts = repoFullName.split(separator: "/", maxSplits: 1) |
| 97 | 105 | guard parts.count == 2 else { continue } |
| 98 | 106 | let owner = String(parts[0]) |
| 99 | 107 | let repo = String(parts[1]) |
| 100 | 108 | |
| 101 | async let dependabot = fetchSource(label: "\(repoFullName) · Dependabot") { | |
| 109 | guard let account = accountsByID[watched.accountID], | |
| 110 | let token = KeychainTokenStore.load(account: account.keychainAccount) else { | |
| 111 | errors.append("\(repoFullName): no signed-in account can reach this repo.") | |
| 112 | continue | |
| 113 | } | |
| 114 | let client = GitHubSecurityAPIClient(token: token) | |
| 115 | let label = accountsByID.count > 1 | |
| 116 | ? "\(repoFullName) (\(account.displayName))" | |
| 117 | : repoFullName | |
| 118 | ||
| 119 | async let dependabot = fetchSource(label: "\(label) · Dependabot") { | |
| 102 | 120 | try await client.fetchDependabotAlerts(owner: owner, repo: repo) |
| 103 | 121 | } |
| 104 | async let codeScanning = fetchSource(label: "\(repoFullName) · Code scanning") { | |
| 122 | async let codeScanning = fetchSource(label: "\(label) · Code scanning") { | |
| 105 | 123 | try await client.fetchCodeScanningAlerts(owner: owner, repo: repo) |
| 106 | 124 | } |
| 107 | async let secretScanning = fetchSource(label: "\(repoFullName) · Secret scanning") { | |
| 125 | async let secretScanning = fetchSource(label: "\(label) · Secret scanning") { | |
| 108 | 126 | try await client.fetchSecretScanningAlerts(owner: owner, repo: repo) |
| 109 | 127 | } |
| 110 | 128 | |
| @@ -114,7 +132,11 @@ final class SecurityEventStore { | ||
| 114 | 132 | for outcome in outcomes { |
| 115 | 133 | switch outcome { |
| 116 | 134 | case .events(let sourceEvents): |
| 117 | repoEvents += sourceEvents | |
| 135 | repoEvents += sourceEvents.map { event in | |
| 136 | var event = event | |
| 137 | event.accountLogins = [account.displayName] | |
| 138 | return event | |
| 139 | } | |
| 118 | 140 | repoSucceeded = true |
| 119 | 141 | case .unavailable(let label): |
| 120 | 142 | notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).") |
| @@ -125,10 +147,18 @@ final class SecurityEventStore { | ||
| 125 | 147 | fetchedEvents += repoEvents |
| 126 | 148 | if repoSucceeded { |
| 127 | 149 | state.lastFetchByRepo[repoFullName] = Date() |
| 128 | fetchedEventsByRepo[repoFullName] = repoEvents | |
| 150 | fetchedEventsByRepo[repoFullName, default: []] += repoEvents | |
| 129 | 151 | } |
| 130 | 152 | } |
| 131 | 153 | |
| 154 | // The same alert reached through two identities is one alert; merge | |
| 155 | // the attributions rather than showing it twice. | |
| 156 | fetchedEvents = Self.merged(fetchedEvents) | |
| 157 | for (repoFullName, events) in fetchedEventsByRepo { | |
| 158 | fetchedEventsByRepo[repoFullName] = Self.merged(events) | |
| 159 | } | |
| 160 | reposWatchedBySeveralAccounts = Self.reposWatchedBySeveralAccounts(in: state.watchedRepos) | |
| 161 | ||
| 132 | 162 | rawEvents = fetchedEvents.map { event in |
| 133 | 163 | var event = event |
| 134 | 164 | event.seenLocally = state.seenEventIDs.contains(event.id) |
| @@ -142,7 +172,7 @@ final class SecurityEventStore { | ||
| 142 | 172 | |
| 143 | 173 | // Only prune against a complete picture: if a repo failed this round |
| 144 | 174 | // its alerts are missing, and pruning would forget they were hidden. |
| 145 | if fetchedEventsByRepo.count == state.watchedRepos.count { | |
| 175 | if fetchedEventsByRepo.count == Set(state.watchedRepos.map(\.fullName)).count { | |
| 146 | 176 | let now = Date() |
| 147 | 177 | state.triage = state.triage.pruned( |
| 148 | 178 | presentEventIDs: Set(fetchedEvents.map(\.id)), |
| @@ -164,7 +194,7 @@ final class SecurityEventStore { | ||
| 164 | 194 | ) |
| 165 | 195 | state.notifiedEventIDsByRepo = AlertDiff.updatedBaseline( |
| 166 | 196 | from: fetchedEventsByRepo, |
| 167 | watchedRepos: state.watchedRepos, | |
| 197 | watchedRepos: state.watchedRepos.map(\.fullName), | |
| 168 | 198 | previous: state.notifiedEventIDsByRepo |
| 169 | 199 | ) |
| 170 | 200 | await persistenceStore.save(state) |
| @@ -190,7 +220,7 @@ final class SecurityEventStore { | ||
| 190 | 220 | await persistenceStore.save(state) |
| 191 | 221 | } |
| 192 | 222 | |
| 193 | func addRepo(_ input: String) async { | |
| 223 | func addRepo(_ input: String, accountID: Int) async { | |
| 194 | 224 | watchListErrorMessage = nil |
| 195 | 225 | let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) |
| 196 | 226 | let parts = trimmed.split(separator: "/", omittingEmptySubsequences: true) |
| @@ -205,14 +235,15 @@ final class SecurityEventStore { | ||
| 205 | 235 | |
| 206 | 236 | var state = await persistenceStore.load() |
| 207 | 237 | // GitHub owner/repo names are case-insensitive, so treat entries |
| 208 | // that differ only in case as the same watched repo. | |
| 238 | // that differ only in case as the same watched repo. The same repo | |
| 239 | // under a different account is a separate entry on purpose. | |
| 209 | 240 | guard !state.watchedRepos.contains(where: { |
| 210 | $0.caseInsensitiveCompare(repoFullName) == .orderedSame | |
| 241 | $0.accountID == accountID && $0.fullName.caseInsensitiveCompare(repoFullName) == .orderedSame | |
| 211 | 242 | }) else { |
| 212 | 243 | watchListErrorMessage = "\(repoFullName) is already watched." |
| 213 | 244 | return |
| 214 | 245 | } |
| 215 | state.watchedRepos.append(repoFullName) | |
| 246 | state.watchedRepos.append(WatchedRepo(fullName: repoFullName, accountID: accountID)) | |
| 216 | 247 | await persistenceStore.save(state) |
| 217 | 248 | watchedRepos = state.watchedRepos |
| 218 | 249 | |
| @@ -222,8 +253,8 @@ final class SecurityEventStore { | ||
| 222 | 253 | /// Lists repos the current token can see, for the repo picker (#15). |
| 223 | 254 | /// Requires broader repo-access scope — throws if the token only has |
| 224 | 255 | /// the default security_events scope. |
| 225 | func fetchAccessibleRepos() async throws -> [String] { | |
| 226 | guard let token = KeychainTokenStore.load() else { | |
| 256 | func fetchAccessibleRepos(for account: Account) async throws -> [String] { | |
| 257 | guard let token = KeychainTokenStore.load(account: account.keychainAccount) else { | |
| 227 | 258 | throw GitHubAPIError.missingToken |
| 228 | 259 | } |
| 229 | 260 | return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos() |
| @@ -265,10 +296,12 @@ final class SecurityEventStore { | ||
| 265 | 296 | applyFilters() |
| 266 | 297 | } |
| 267 | 298 | |
| 268 | func removeRepo(_ repoFullName: String) async { | |
| 299 | func removeRepo(_ watched: WatchedRepo) async { | |
| 269 | 300 | var state = await persistenceStore.load() |
| 270 | state.watchedRepos.removeAll { $0 == repoFullName } | |
| 271 | state.lastFetchByRepo.removeValue(forKey: repoFullName) | |
| 301 | state.watchedRepos.removeAll { $0 == watched } | |
| 302 | if !state.watchedRepos.contains(where: { $0.fullName == watched.fullName }) { | |
| 303 | state.lastFetchByRepo.removeValue(forKey: watched.fullName) | |
| 304 | } | |
| 272 | 305 | await persistenceStore.save(state) |
| 273 | 306 | watchedRepos = state.watchedRepos |
| 274 | 307 | |
| @@ -298,6 +331,35 @@ final class SecurityEventStore { | ||
| 298 | 331 | events = sortOrder.sorted(filter.apply(to: admitted)) |
| 299 | 332 | } |
| 300 | 333 | |
| 334 | /// Collapses alerts that arrived through more than one account, keeping | |
| 335 | /// one row and unioning the attributions. Input order is preserved. | |
| 336 | nonisolated static func merged(_ events: [SecurityEvent]) -> [SecurityEvent] { | |
| 337 | var order: [String] = [] | |
| 338 | var byID: [String: SecurityEvent] = [:] | |
| 339 | ||
| 340 | for event in events { | |
| 341 | if var existing = byID[event.id] { | |
| 342 | for login in event.accountLogins where !existing.accountLogins.contains(login) { | |
| 343 | existing.accountLogins.append(login) | |
| 344 | } | |
| 345 | byID[event.id] = existing | |
| 346 | } else { | |
| 347 | order.append(event.id) | |
| 348 | byID[event.id] = event | |
| 349 | } | |
| 350 | } | |
| 351 | ||
| 352 | return order.compactMap { byID[$0] } | |
| 353 | } | |
| 354 | ||
| 355 | nonisolated static func reposWatchedBySeveralAccounts(in watched: [WatchedRepo]) -> Set<String> { | |
| 356 | var accountsByRepo: [String: Set<Int>] = [:] | |
| 357 | for repo in watched { | |
| 358 | accountsByRepo[repo.fullName, default: []].insert(repo.accountID) | |
| 359 | } | |
| 360 | return Set(accountsByRepo.filter { $0.value.count > 1 }.keys) | |
| 361 | } | |
| 362 | ||
| 301 | 363 | private enum SourceOutcome { |
| 302 | 364 | case events([SecurityEvent]) |
| 303 | 365 | case unavailable(label: String) |
octosentryTests/AccountTests.swift added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | // | |
| 2 | // AccountTests.swift | |
| 3 | // octosentryTests | |
| 4 | // | |
| 5 | ||
| 6 | import Foundation | |
| 7 | import Testing | |
| 8 | @testable import octosentry | |
| 9 | ||
| 10 | struct AccountTests { | |
| 11 | ||
| 12 | // An install that already has a token must keep using the Keychain item | |
| 13 | // it's in — rewriting it at upgrade time risks stranding the token. | |
| 14 | @Test func theLegacyAccountPointsAtTheExistingKeychainItem() { | |
| 15 | let account = Account.legacy() | |
| 16 | ||
| 17 | #expect(account.keychainAccount == KeychainTokenStore.legacyAccount) | |
| 18 | #expect(account.id == 0) | |
| 19 | #expect(account.login.isEmpty) | |
| 20 | } | |
| 21 | ||
| 22 | @Test func newAccountsGetTheirOwnKeychainItem() { | |
| 23 | let first = Account.new(id: 1, login: "octocat", hasRepoScope: false) | |
| 24 | let second = Account.new(id: 2, login: "hubot", hasRepoScope: true) | |
| 25 | ||
| 26 | #expect(first.keychainAccount != second.keychainAccount) | |
| 27 | #expect(first.keychainAccount != KeychainTokenStore.legacyAccount) | |
| 28 | #expect(second.hasRepoScope) | |
| 29 | } | |
| 30 | ||
| 31 | @Test func displayNameFallsBackBeforeTheLoginIsKnown() { | |
| 32 | #expect(Account.legacy().displayName == "GitHub account") | |
| 33 | #expect(Account.new(id: 1, login: "octocat", hasRepoScope: false).displayName == "octocat") | |
| 34 | } | |
| 35 | ||
| 36 | @Test func roundTripsThroughCodable() throws { | |
| 37 | let account = Account.new(id: 7, login: "octocat", hasRepoScope: true) | |
| 38 | let decoded = try JSONDecoder().decode(Account.self, from: try JSONEncoder().encode(account)) | |
| 39 | ||
| 40 | #expect(decoded == account) | |
| 41 | } | |
| 42 | ||
| 43 | @Test func watchedRepoRoundTripsThroughCodable() throws { | |
| 44 | let repo = WatchedRepo(fullName: "octocat/hello-world", accountID: 7) | |
| 45 | let decoded = try JSONDecoder().decode(WatchedRepo.self, from: try JSONEncoder().encode(repo)) | |
| 46 | ||
| 47 | #expect(decoded == repo) | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | struct MultiAccountFeedTests { | |
| 52 | ||
| 53 | private func event(_ id: String, repo: String = "octocat/hello-world", logins: [String]) -> SecurityEvent { | |
| 54 | var event = TestEvents.event(id: id, repo: repo) | |
| 55 | event.accountLogins = logins | |
| 56 | return event | |
| 57 | } | |
| 58 | ||
| 59 | // MARK: - Merging | |
| 60 | ||
| 61 | // The same alert reached through two identities is one alert. | |
| 62 | @Test func duplicateAlertsCollapseAndUnionTheirAttributions() { | |
| 63 | let merged = SecurityEventStore.merged([ | |
| 64 | event("a", logins: ["octocat"]), | |
| 65 | event("a", logins: ["hubot"]), | |
| 66 | ]) | |
| 67 | ||
| 68 | #expect(merged.count == 1) | |
| 69 | #expect(merged[0].accountLogins.sorted() == ["hubot", "octocat"]) | |
| 70 | } | |
| 71 | ||
| 72 | @Test func mergingLeavesDistinctAlertsAlone() { | |
| 73 | let merged = SecurityEventStore.merged([ | |
| 74 | event("a", logins: ["octocat"]), | |
| 75 | event("b", logins: ["octocat"]), | |
| 76 | ]) | |
| 77 | ||
| 78 | #expect(merged.map(\.id) == ["a", "b"]) | |
| 79 | } | |
| 80 | ||
| 81 | @Test func mergingPreservesInputOrder() { | |
| 82 | let merged = SecurityEventStore.merged([ | |
| 83 | event("b", logins: ["octocat"]), | |
| 84 | event("a", logins: ["hubot"]), | |
| 85 | event("b", logins: ["hubot"]), | |
| 86 | ]) | |
| 87 | ||
| 88 | #expect(merged.map(\.id) == ["b", "a"]) | |
| 89 | } | |
| 90 | ||
| 91 | @Test func mergingDoesNotRepeatTheSameLogin() { | |
| 92 | let merged = SecurityEventStore.merged([ | |
| 93 | event("a", logins: ["octocat"]), | |
| 94 | event("a", logins: ["octocat"]), | |
| 95 | ]) | |
| 96 | ||
| 97 | #expect(merged[0].accountLogins == ["octocat"]) | |
| 98 | } | |
| 99 | ||
| 100 | @Test func mergingAnEmptyFeedIsEmpty() { | |
| 101 | #expect(SecurityEventStore.merged([]).isEmpty) | |
| 102 | } | |
| 103 | ||
| 104 | // MARK: - Attribution | |
| 105 | ||
| 106 | // Attribution is noise unless a repo is genuinely reachable two ways. | |
| 107 | @Test func onlyReposWatchedUnderSeveralAccountsAreAttributed() { | |
| 108 | let watched = [ | |
| 109 | WatchedRepo(fullName: "octocat/shared", accountID: 1), | |
| 110 | WatchedRepo(fullName: "octocat/shared", accountID: 2), | |
| 111 | WatchedRepo(fullName: "octocat/solo", accountID: 1), | |
| 112 | ] | |
| 113 | ||
| 114 | let attributed = SecurityEventStore.reposWatchedBySeveralAccounts(in: watched) | |
| 115 | ||
| 116 | #expect(attributed == ["octocat/shared"]) | |
| 117 | } | |
| 118 | ||
| 119 | @Test func aRepoListedTwiceUnderOneAccountIsNotAttributed() { | |
| 120 | let watched = [ | |
| 121 | WatchedRepo(fullName: "octocat/solo", accountID: 1), | |
| 122 | WatchedRepo(fullName: "octocat/solo", accountID: 1), | |
| 123 | ] | |
| 124 | ||
| 125 | #expect(SecurityEventStore.reposWatchedBySeveralAccounts(in: watched).isEmpty) | |
| 126 | } | |
| 127 | ||
| 128 | @Test func anEmptyWatchListAttributesNothing() { | |
| 129 | #expect(SecurityEventStore.reposWatchedBySeveralAccounts(in: []).isEmpty) | |
| 130 | } | |
| 131 | } | |
octosentryTests/PersistedStateTests.swift +10 −3
| @@ -27,7 +27,11 @@ struct PersistedStateTests { | ||
| 27 | 27 | @Test func roundTripsEveryField() throws { |
| 28 | 28 | let fetchedAt = Date(timeIntervalSince1970: 1_785_000_000) |
| 29 | 29 | let original = PersistedState( |
| 30 | watchedRepos: ["octocat/hello-world", "octocat/spoon-knife"], | |
| 30 | accounts: [Account.new(id: 42, login: "octocat", hasRepoScope: true)], | |
| 31 | watchedRepos: [ | |
| 32 | WatchedRepo(fullName: "octocat/hello-world", accountID: 42), | |
| 33 | WatchedRepo(fullName: "octocat/spoon-knife", accountID: 42), | |
| 34 | ], | |
| 31 | 35 | seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"], |
| 32 | 36 | lastFetchByRepo: ["octocat/hello-world": fetchedAt], |
| 33 | 37 | minimumSeverity: .high, |
| @@ -48,6 +52,7 @@ struct PersistedStateTests { | ||
| 48 | 52 | ) |
| 49 | 53 | |
| 50 | 54 | #expect(decoded.watchedRepos == original.watchedRepos) |
| 55 | #expect(decoded.accounts == original.accounts) | |
| 51 | 56 | #expect(decoded.seenEventIDs == original.seenEventIDs) |
| 52 | 57 | #expect(decoded.lastFetchByRepo == original.lastFetchByRepo) |
| 53 | 58 | #expect(decoded.minimumSeverity == original.minimumSeverity) |
| @@ -65,7 +70,7 @@ struct PersistedStateTests { | ||
| 65 | 70 | |
| 66 | 71 | #expect(Set(object.keys) == [ |
| 67 | 72 | "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder", |
| 68 | "triage", "history", | |
| 73 | "triage", "history", "accounts", | |
| 69 | 74 | ]) |
| 70 | 75 | // notifiedEventIDsByRepo is optional and nil on the placeholder, so it |
| 71 | 76 | // encodes to nothing rather than a null. |
| @@ -85,7 +90,9 @@ struct PersistedStateTests { | ||
| 85 | 90 | |
| 86 | 91 | let state = try Self.decoder.decode(PersistedState.self, from: Data(legacy.utf8)) |
| 87 | 92 | |
| 88 | #expect(state.watchedRepos == ["octocat/hello-world"]) | |
| 93 | // The pre-multi-account shape was a plain [String]. | |
| 94 | #expect(state.watchedRepos == [WatchedRepo(fullName: "octocat/hello-world", accountID: 0)]) | |
| 95 | #expect(state.accounts.isEmpty) | |
| 89 | 96 | #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"]) |
| 90 | 97 | #expect(state.minimumSeverity == .medium) |
| 91 | 98 | #expect(state.hasRepoScope == false) |
octosentryTests/PersistenceStoreTests.swift +1 −1
| @@ -34,7 +34,7 @@ struct PersistenceStoreTests { | ||
| 34 | 34 | defer { try? FileManager.default.removeItem(at: directory) } |
| 35 | 35 | |
| 36 | 36 | let saved = PersistedState( |
| 37 | watchedRepos: ["octocat/hello-world"], | |
| 37 | watchedRepos: [WatchedRepo(fullName: "octocat/hello-world", accountID: 42)], | |
| 38 | 38 | seenEventIDs: ["dependabot-octocat/hello-world-1"], |
| 39 | 39 | lastFetchByRepo: ["octocat/hello-world": Date(timeIntervalSince1970: 1_785_000_000)], |
| 40 | 40 | minimumSeverity: .high, |