Commit ccfd0dafd5
Unsigned
Layout: unified · split
octosentry/Account.swift +39 −6
| @@ -20,9 +20,37 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable { | ||
| 20 | 20 | var login: String |
| 21 | 21 | var keychainAccount: String |
| 22 | 22 | var hasRepoScope: Bool |
| 23 | /// Which GitHub this account lives on. Absent in files written before | |
| 24 | /// Enterprise support, which means github.com. | |
| 25 | var host: GitHubHost = .dotCom | |
| 26 | ||
| 27 | enum CodingKeys: String, CodingKey { | |
| 28 | case id, login, keychainAccount, hasRepoScope, host | |
| 29 | } | |
| 30 | ||
| 31 | init(id: Int, login: String, keychainAccount: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) { | |
| 32 | self.id = id | |
| 33 | self.login = login | |
| 34 | self.keychainAccount = keychainAccount | |
| 35 | self.hasRepoScope = hasRepoScope | |
| 36 | self.host = host | |
| 37 | } | |
| 38 | ||
| 39 | // Synthesized decoding ignores property defaults, so an account written | |
| 40 | // before Enterprise support would fail to decode and take the whole | |
| 41 | // state file down with it. | |
| 42 | init(from decoder: Decoder) throws { | |
| 43 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 44 | id = try container.decode(Int.self, forKey: .id) | |
| 45 | login = try container.decode(String.self, forKey: .login) | |
| 46 | keychainAccount = try container.decode(String.self, forKey: .keychainAccount) | |
| 47 | hasRepoScope = try container.decode(Bool.self, forKey: .hasRepoScope) | |
| 48 | host = try container.decodeIfPresent(GitHubHost.self, forKey: .host) ?? .dotCom | |
| 49 | } | |
| 23 | 50 | |
| 24 | 51 | var displayName: String { |
| 25 | login.isEmpty ? "GitHub account" : login | |
| 52 | let name = login.isEmpty ? "GitHub account" : login | |
| 53 | return host.isDotCom ? name : "\(name) @ \(host.displayName)" | |
| 26 | 54 | } |
| 27 | 55 | |
| 28 | 56 | /// The account an upgrading install already has a token for. |
| @@ -31,16 +59,21 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable { | ||
| 31 | 59 | id: 0, |
| 32 | 60 | login: "", |
| 33 | 61 | keychainAccount: KeychainTokenStore.legacyAccount, |
| 34 | hasRepoScope: false | |
| 62 | hasRepoScope: false, | |
| 63 | host: .dotCom | |
| 35 | 64 | ) |
| 36 | 65 | } |
| 37 | 66 | |
| 38 | static func new(id: Int, login: String, hasRepoScope: Bool) -> Account { | |
| 39 | Account( | |
| 67 | static func new(id: Int, login: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) -> Account { | |
| 68 | // Ids are only unique within an instance, so a GHES account's | |
| 69 | // Keychain item is namespaced by host too. | |
| 70 | let suffix = host.isDotCom ? "\(id)" : "\(host.displayName)-\(id)" | |
| 71 | return Account( | |
| 40 | 72 | id: id, |
| 41 | 73 | login: login, |
| 42 | keychainAccount: "account-\(id)", | |
| 43 | hasRepoScope: hasRepoScope | |
| 74 | keychainAccount: "account-\(suffix)", | |
| 75 | hasRepoScope: hasRepoScope, | |
| 76 | host: host | |
| 44 | 77 | ) |
| 45 | 78 | } |
| 46 | 79 | } |
octosentry/AuthStore.swift +25 −18
| @@ -21,7 +21,6 @@ final class AuthStore { | ||
| 21 | 21 | private(set) var errorMessage: String? |
| 22 | 22 | private(set) var accounts: [Account] = [] |
| 23 | 23 | |
| 24 | private let client = GitHubDeviceAuthClient() | |
| 25 | 24 | private let persistenceStore = PersistenceStore() |
| 26 | 25 | private var authorizationTask: Task<Void, Never>? |
| 27 | 26 | |
| @@ -43,21 +42,22 @@ final class AuthStore { | ||
| 43 | 42 | accounts.contains(where: \.hasRepoScope) |
| 44 | 43 | } |
| 45 | 44 | |
| 46 | func signIn() { | |
| 47 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) | |
| 45 | func signIn(host: GitHubHost = .dotCom) { | |
| 46 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host) | |
| 48 | 47 | } |
| 49 | 48 | |
| 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) | |
| 49 | /// Adds another identity, optionally on a GitHub Enterprise Server | |
| 50 | /// instance. Same flow either way — GitHub decides which account | |
| 51 | /// authorizes the code. | |
| 52 | func addAccount(host: GitHubHost = .dotCom) { | |
| 53 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host) | |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | 56 | /// Re-runs device auth with broader scope so the repo picker can list |
| 57 | 57 | /// repos. Only called explicitly from the repo picker UI, never on |
| 58 | 58 | /// the default sign-in path. |
| 59 | func requestRepoAccess() { | |
| 60 | beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope) | |
| 59 | func requestRepoAccess(host: GitHubHost = .dotCom) { | |
| 60 | beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope, host: host) | |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | 63 | /// Signs out one account, leaving the others alone. |
| @@ -121,7 +121,7 @@ final class AuthStore { | ||
| 121 | 121 | |
| 122 | 122 | for account in unresolved { |
| 123 | 123 | guard let token = KeychainTokenStore.load(account: account.keychainAccount), |
| 124 | let user = try? await GitHubSecurityAPIClient(token: token).fetchCurrentUser(), | |
| 124 | let user = try? await GitHubSecurityAPIClient(token: token, host: account.host).fetchCurrentUser(), | |
| 125 | 125 | let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount }) |
| 126 | 126 | else { continue } |
| 127 | 127 | |
| @@ -143,13 +143,14 @@ final class AuthStore { | ||
| 143 | 143 | } |
| 144 | 144 | } |
| 145 | 145 | |
| 146 | private func beginAuthorization(scope: String) { | |
| 146 | private func beginAuthorization(scope: String, host: GitHubHost) { | |
| 147 | 147 | guard authorizationTask == nil else { return } |
| 148 | 148 | errorMessage = nil |
| 149 | 149 | |
| 150 | 150 | authorizationTask = Task { |
| 151 | 151 | defer { authorizationTask = nil } |
| 152 | 152 | do { |
| 153 | let client = GitHubDeviceAuthClient(host: host) | |
| 153 | 154 | let deviceCode = try await client.requestDeviceCode(scope: scope) |
| 154 | 155 | state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) |
| 155 | 156 | |
| @@ -158,7 +159,7 @@ final class AuthStore { | ||
| 158 | 159 | interval: deviceCode.interval, |
| 159 | 160 | expiresIn: deviceCode.expiresIn |
| 160 | 161 | ) |
| 161 | try await register(token: token, grantedRepoScope: scope.contains("repo")) | |
| 162 | try await register(token: token, grantedRepoScope: scope.contains("repo"), host: host) | |
| 162 | 163 | state = .signedIn |
| 163 | 164 | } catch { |
| 164 | 165 | errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription |
| @@ -173,30 +174,36 @@ final class AuthStore { | ||
| 173 | 174 | /// Stores a freshly authorized token under its own Keychain item and |
| 174 | 175 | /// records the account. Re-authorizing an account already present updates |
| 175 | 176 | /// 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() | |
| 177 | private func register(token: String, grantedRepoScope: Bool, host: GitHubHost) async throws { | |
| 178 | let user = try await GitHubSecurityAPIClient(token: token, host: host).fetchCurrentUser() | |
| 178 | 179 | |
| 179 | 180 | var persisted = await persistenceStore.load() |
| 180 | 181 | |
| 181 | if let index = persisted.accounts.firstIndex(where: { $0.id == user.id }) { | |
| 182 | if let index = persisted.accounts.firstIndex(where: { $0.id == user.id && $0.host == host }) { | |
| 182 | 183 | persisted.accounts[index].login = user.login |
| 183 | 184 | persisted.accounts[index].hasRepoScope = grantedRepoScope |
| 184 | 185 | try KeychainTokenStore.save(token, account: persisted.accounts[index].keychainAccount) |
| 185 | } else if let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) { | |
| 186 | } else if host.isDotCom, let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) { | |
| 186 | 187 | // The adopted single-account entry, now identified. |
| 187 | 188 | let keychainAccount = persisted.accounts[index].keychainAccount |
| 188 | 189 | persisted.accounts[index] = Account( |
| 189 | 190 | id: user.id, |
| 190 | 191 | login: user.login, |
| 191 | 192 | keychainAccount: keychainAccount, |
| 192 | hasRepoScope: grantedRepoScope | |
| 193 | hasRepoScope: grantedRepoScope, | |
| 194 | host: host | |
| 193 | 195 | ) |
| 194 | 196 | for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 { |
| 195 | 197 | persisted.watchedRepos[repoIndex].accountID = user.id |
| 196 | 198 | } |
| 197 | 199 | try KeychainTokenStore.save(token, account: keychainAccount) |
| 198 | 200 | } else { |
| 199 | let account = Account.new(id: user.id, login: user.login, hasRepoScope: grantedRepoScope) | |
| 201 | let account = Account.new( | |
| 202 | id: user.id, | |
| 203 | login: user.login, | |
| 204 | hasRepoScope: grantedRepoScope, | |
| 205 | host: host | |
| 206 | ) | |
| 200 | 207 | try KeychainTokenStore.save(token, account: account.keychainAccount) |
| 201 | 208 | persisted.accounts.append(account) |
| 202 | 209 | } |
octosentry/GitHubDeviceAuthClient.swift +10 −5
| @@ -12,8 +12,12 @@ import Foundation | ||
| 12 | 12 | |
| 13 | 13 | actor GitHubDeviceAuthClient { |
| 14 | 14 | // Public client identifier for the "octosentry" OAuth App (Device Flow enabled). |
| 15 | // Not a secret — safe to embed in source. | |
| 16 | private let clientID = "Ov23li6tqaTghDc4IJYv" | |
| 15 | // Not a secret — safe to embed in source. A GitHub Enterprise Server | |
| 16 | // instance needs its own admin-registered app instead (#20). | |
| 17 | static let dotComClientID = "Ov23li6tqaTghDc4IJYv" | |
| 18 | ||
| 19 | private let host: GitHubHost | |
| 20 | private var clientID: String { host.clientID ?? Self.dotComClientID } | |
| 17 | 21 | |
| 18 | 22 | // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert |
| 19 | 23 | // access. Classic OAuth scopes have no read-only variant (unlike fine-grained |
| @@ -26,13 +30,14 @@ actor GitHubDeviceAuthClient { | ||
| 26 | 30 | |
| 27 | 31 | private let session: URLSession |
| 28 | 32 | |
| 29 | init(session: URLSession = .shared) { | |
| 33 | init(host: GitHubHost = .dotCom, session: URLSession = .shared) { | |
| 34 | self.host = host | |
| 30 | 35 | self.session = session |
| 31 | 36 | } |
| 32 | 37 | |
| 33 | 38 | func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse { |
| 34 | 39 | let data = try await post( |
| 35 | url: URL(string: "https://github.com/login/device/code")!, | |
| 40 | url: host.deviceCodeURL, | |
| 36 | 41 | parameters: ["client_id": clientID, "scope": scope] |
| 37 | 42 | ) |
| 38 | 43 | do { |
| @@ -52,7 +57,7 @@ actor GitHubDeviceAuthClient { | ||
| 52 | 57 | try Task.checkCancellation() |
| 53 | 58 | |
| 54 | 59 | let data = try await post( |
| 55 | url: URL(string: "https://github.com/login/oauth/access_token")!, | |
| 60 | url: host.accessTokenURL, | |
| 56 | 61 | parameters: [ |
| 57 | 62 | "client_id": clientID, |
| 58 | 63 | "device_code": deviceCode, |
octosentry/GitHubHost.swift added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | // | |
| 2 | // GitHubHost.swift | |
| 3 | // octosentry | |
| 4 | // | |
| 5 | // Which GitHub a token talks to. github.com and GitHub Enterprise Server | |
| 6 | // differ in more than a hostname: GHES puts the REST API under /api/v3 on | |
| 7 | // the same host rather than on a separate api. domain, and its device flow | |
| 8 | // needs an OAuth app registered by an instance admin, so octosentry's own | |
| 9 | // client ID doesn't apply. | |
| 10 | // | |
| 11 | // Host is a property of an account (#19), not a global setting — someone | |
| 12 | // can reasonably watch repos on github.com and on their employer's GHES at | |
| 13 | // the same time. | |
| 14 | // | |
| 15 | ||
| 16 | import Foundation | |
| 17 | ||
| 18 | nonisolated struct GitHubHost: Codable, Equatable, Hashable { | |
| 19 | /// nil means github.com. Otherwise the GHES web host, without a scheme. | |
| 20 | var host: String? | |
| 21 | /// Required for GHES; github.com uses octosentry's registered app. | |
| 22 | var clientID: String? | |
| 23 | ||
| 24 | static let dotCom = GitHubHost(host: nil, clientID: nil) | |
| 25 | ||
| 26 | /// Accepts what a user is likely to paste — with or without a scheme, | |
| 27 | /// with or without a trailing slash or path. | |
| 28 | init(host: String?, clientID: String?) { | |
| 29 | self.host = host.flatMap(Self.normalize) | |
| 30 | let trimmedClientID = clientID?.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 31 | self.clientID = (trimmedClientID?.isEmpty == false) ? trimmedClientID : nil | |
| 32 | } | |
| 33 | ||
| 34 | private static func normalize(_ raw: String) -> String? { | |
| 35 | var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| 36 | for prefix in ["https://", "http://"] where value.hasPrefix(prefix) { | |
| 37 | value.removeFirst(prefix.count) | |
| 38 | } | |
| 39 | if let slash = value.firstIndex(of: "/") { | |
| 40 | value = String(value[value.startIndex..<slash]) | |
| 41 | } | |
| 42 | guard !value.isEmpty, value != "github.com" else { return nil } | |
| 43 | return value | |
| 44 | } | |
| 45 | ||
| 46 | var isDotCom: Bool { host == nil } | |
| 47 | ||
| 48 | var displayName: String { host ?? "github.com" } | |
| 49 | ||
| 50 | /// GHES serves the REST API from the same host under /api/v3. | |
| 51 | var apiBaseURL: URL { | |
| 52 | guard let host else { return URL(string: "https://api.github.com")! } | |
| 53 | return URL(string: "https://\(host)/api/v3")! | |
| 54 | } | |
| 55 | ||
| 56 | var webBaseURL: URL { | |
| 57 | URL(string: "https://\(host ?? "github.com")")! | |
| 58 | } | |
| 59 | ||
| 60 | var deviceCodeURL: URL { | |
| 61 | webBaseURL.appendingPathComponent("login/device/code") | |
| 62 | } | |
| 63 | ||
| 64 | var accessTokenURL: URL { | |
| 65 | webBaseURL.appendingPathComponent("login/oauth/access_token") | |
| 66 | } | |
| 67 | } | |
octosentry/GitHubSecurityAPIClient.swift +6 −5
| @@ -13,7 +13,7 @@ import Foundation | ||
| 13 | 13 | actor GitHubSecurityAPIClient { |
| 14 | 14 | private let token: String |
| 15 | 15 | private let session: URLSession |
| 16 | private let baseURL = URL(string: "https://api.github.com")! | |
| 16 | private let baseURL: URL | |
| 17 | 17 | |
| 18 | 18 | private static let decoder: JSONDecoder = { |
| 19 | 19 | let decoder = JSONDecoder() |
| @@ -21,8 +21,9 @@ actor GitHubSecurityAPIClient { | ||
| 21 | 21 | return decoder |
| 22 | 22 | }() |
| 23 | 23 | |
| 24 | init(token: String, session: URLSession = .shared) { | |
| 24 | init(token: String, host: GitHubHost = .dotCom, session: URLSession = .shared) { | |
| 25 | 25 | self.token = token |
| 26 | self.baseURL = host.apiBaseURL | |
| 26 | 27 | self.session = session |
| 27 | 28 | } |
| 28 | 29 | |
| @@ -94,7 +95,7 @@ actor GitHubSecurityAPIClient { | ||
| 94 | 95 | /// sign-in scope). Used by the repo picker (#15). |
| 95 | 96 | func fetchAccessibleRepos() async throws -> [String] { |
| 96 | 97 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 97 | components.path = "/user/repos" | |
| 98 | components.path = baseURL.path + "/user/repos" | |
| 98 | 99 | components.queryItems = [ |
| 99 | 100 | URLQueryItem(name: "per_page", value: "100"), |
| 100 | 101 | URLQueryItem(name: "sort", value: "full_name"), |
| @@ -107,14 +108,14 @@ actor GitHubSecurityAPIClient { | ||
| 107 | 108 | /// alerts attributed. Needs no scope beyond a valid user token. |
| 108 | 109 | func fetchCurrentUser() async throws -> GitHubUserDTO { |
| 109 | 110 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 110 | components.path = "/user" | |
| 111 | components.path = baseURL.path + "/user" | |
| 111 | 112 | let (data, _) = try await fetchData(url: components.url!) |
| 112 | 113 | return try decode(data) |
| 113 | 114 | } |
| 114 | 115 | |
| 115 | 116 | private func alertsURL(owner: String, repo: String, path: String) -> URL { |
| 116 | 117 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 117 | components.path = "/repos/\(owner)/\(repo)/\(path)" | |
| 118 | components.path = baseURL.path + "/repos/\(owner)/\(repo)/\(path)" | |
| 118 | 119 | components.queryItems = [ |
| 119 | 120 | URLQueryItem(name: "state", value: "open"), |
| 120 | 121 | URLQueryItem(name: "per_page", value: "100"), |
octosentry/SecurityEventListView.swift +49 −1
| @@ -347,6 +347,8 @@ private struct RepoManagerView: View { | ||
| 347 | 347 | .buttonStyle(.plain) |
| 348 | 348 | .foregroundStyle(Color.accentColor) |
| 349 | 349 | |
| 350 | EnterpriseSignInView(authStore: authStore) | |
| 351 | ||
| 350 | 352 | if let errorMessage = store.watchListErrorMessage { |
| 351 | 353 | Text(errorMessage) |
| 352 | 354 | .font(.caption2) |
| @@ -485,7 +487,7 @@ private struct RepoManagerView: View { | ||
| 485 | 487 | |
| 486 | 488 | private func startBrowsing(_ account: Account) { |
| 487 | 489 | guard account.hasRepoScope else { |
| 488 | authStore.requestRepoAccess() | |
| 490 | authStore.requestRepoAccess(host: account.host) | |
| 489 | 491 | return |
| 490 | 492 | } |
| 491 | 493 | browsingAccount = account |
| @@ -509,6 +511,52 @@ private struct RepoManagerView: View { | ||
| 509 | 511 | } |
| 510 | 512 | } |
| 511 | 513 | |
| 514 | /// Signing in to a GitHub Enterprise Server instance. Collapsed by default — | |
| 515 | /// most people are on github.com, and GHES needs details they have to get | |
| 516 | /// from an instance admin. | |
| 517 | private struct EnterpriseSignInView: View { | |
| 518 | var authStore: AuthStore | |
| 519 | @State private var isExpanded = false | |
| 520 | @State private var hostText = "" | |
| 521 | @State private var clientIDText = "" | |
| 522 | ||
| 523 | private var host: GitHubHost { | |
| 524 | GitHubHost(host: hostText, clientID: clientIDText) | |
| 525 | } | |
| 526 | ||
| 527 | var body: some View { | |
| 528 | VStack(alignment: .leading, spacing: 6) { | |
| 529 | Button { | |
| 530 | isExpanded.toggle() | |
| 531 | } label: { | |
| 532 | Label("Add a GitHub Enterprise account", systemImage: "building.2") | |
| 533 | .font(.caption) | |
| 534 | } | |
| 535 | .buttonStyle(.plain) | |
| 536 | .foregroundStyle(Color.accentColor) | |
| 537 | ||
| 538 | if isExpanded { | |
| 539 | TextField("github.example.com", text: $hostText) | |
| 540 | .textFieldStyle(.roundedBorder) | |
| 541 | TextField("OAuth app client ID", text: $clientIDText) | |
| 542 | .textFieldStyle(.roundedBorder) | |
| 543 | ||
| 544 | Text("Device flow on Enterprise Server needs an OAuth app registered on the instance. Ask an admin for its client ID.") | |
| 545 | .font(.caption2) | |
| 546 | .foregroundStyle(.secondary) | |
| 547 | ||
| 548 | Button("Sign In") { | |
| 549 | authStore.addAccount(host: host) | |
| 550 | isExpanded = false | |
| 551 | hostText = "" | |
| 552 | clientIDText = "" | |
| 553 | } | |
| 554 | .disabled(host.isDotCom || host.clientID == nil) | |
| 555 | } | |
| 556 | } | |
| 557 | } | |
| 558 | } | |
| 559 | ||
| 512 | 560 | private struct FilterLabel: View { |
| 513 | 561 | let title: String |
| 514 | 562 | let count: Int |
octosentry/SecurityEventStore.swift +2 −2
| @@ -111,7 +111,7 @@ final class SecurityEventStore { | ||
| 111 | 111 | errors.append("\(repoFullName): no signed-in account can reach this repo.") |
| 112 | 112 | continue |
| 113 | 113 | } |
| 114 | let client = GitHubSecurityAPIClient(token: token) | |
| 114 | let client = GitHubSecurityAPIClient(token: token, host: account.host) | |
| 115 | 115 | let label = accountsByID.count > 1 |
| 116 | 116 | ? "\(repoFullName) (\(account.displayName))" |
| 117 | 117 | : repoFullName |
| @@ -257,7 +257,7 @@ final class SecurityEventStore { | ||
| 257 | 257 | guard let token = KeychainTokenStore.load(account: account.keychainAccount) else { |
| 258 | 258 | throw GitHubAPIError.missingToken |
| 259 | 259 | } |
| 260 | return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos() | |
| 260 | return try await GitHubSecurityAPIClient(token: token, host: account.host).fetchAccessibleRepos() | |
| 261 | 261 | } |
| 262 | 262 | |
| 263 | 263 | /// Local-only triage state (spec §11) — no API write, no scope beyond |
octosentryTests/GitHubHostTests.swift added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | // | |
| 2 | // GitHubHostTests.swift | |
| 3 | // octosentryTests | |
| 4 | // | |
| 5 | ||
| 6 | import Foundation | |
| 7 | import Testing | |
| 8 | @testable import octosentry | |
| 9 | ||
| 10 | struct GitHubHostTests { | |
| 11 | ||
| 12 | // MARK: - github.com | |
| 13 | ||
| 14 | @Test func theDefaultIsGitHubDotCom() { | |
| 15 | #expect(GitHubHost.dotCom.isDotCom) | |
| 16 | #expect(GitHubHost.dotCom.displayName == "github.com") | |
| 17 | #expect(GitHubHost.dotCom.apiBaseURL.absoluteString == "https://api.github.com") | |
| 18 | #expect(GitHubHost.dotCom.webBaseURL.absoluteString == "https://github.com") | |
| 19 | } | |
| 20 | ||
| 21 | @Test func dotComKeepsItsExistingAuthEndpoints() { | |
| 22 | #expect(GitHubHost.dotCom.deviceCodeURL.absoluteString == "https://github.com/login/device/code") | |
| 23 | #expect(GitHubHost.dotCom.accessTokenURL.absoluteString == "https://github.com/login/oauth/access_token") | |
| 24 | } | |
| 25 | ||
| 26 | // Naming github.com explicitly is still github.com, not an enterprise host. | |
| 27 | @Test func namingGitHubDotComExplicitlyIsNotEnterprise() { | |
| 28 | #expect(GitHubHost(host: "github.com", clientID: "abc").isDotCom) | |
| 29 | #expect(GitHubHost(host: "https://github.com", clientID: "abc").isDotCom) | |
| 30 | } | |
| 31 | ||
| 32 | // MARK: - Enterprise Server | |
| 33 | ||
| 34 | // GHES serves the REST API from the same host under /api/v3, not from a | |
| 35 | // separate api. domain. | |
| 36 | @Test func enterpriseAPILivesUnderApiV3OnTheSameHost() { | |
| 37 | let host = GitHubHost(host: "github.example.com", clientID: "abc") | |
| 38 | ||
| 39 | #expect(host.apiBaseURL.absoluteString == "https://github.example.com/api/v3") | |
| 40 | #expect(host.webBaseURL.absoluteString == "https://github.example.com") | |
| 41 | #expect(!host.isDotCom) | |
| 42 | } | |
| 43 | ||
| 44 | @Test func enterpriseAuthEndpointsAreOnTheInstance() { | |
| 45 | let host = GitHubHost(host: "github.example.com", clientID: "abc") | |
| 46 | ||
| 47 | #expect(host.deviceCodeURL.absoluteString == "https://github.example.com/login/device/code") | |
| 48 | #expect(host.accessTokenURL.absoluteString == "https://github.example.com/login/oauth/access_token") | |
| 49 | } | |
| 50 | ||
| 51 | // MARK: - Normalizing what a user pastes | |
| 52 | ||
| 53 | @Test func aPastedURLIsReducedToItsHost() { | |
| 54 | for input in [ | |
| 55 | "https://github.example.com", | |
| 56 | "http://github.example.com", | |
| 57 | "github.example.com/", | |
| 58 | "https://github.example.com/some/path", | |
| 59 | " GitHub.Example.com ", | |
| 60 | ] { | |
| 61 | #expect(GitHubHost(host: input, clientID: "abc").host == "github.example.com", "input: \(input)") | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | @Test func anEmptyHostMeansGitHubDotCom() { | |
| 66 | #expect(GitHubHost(host: "", clientID: nil).isDotCom) | |
| 67 | #expect(GitHubHost(host: " ", clientID: nil).isDotCom) | |
| 68 | #expect(GitHubHost(host: nil, clientID: nil).isDotCom) | |
| 69 | } | |
| 70 | ||
| 71 | @Test func anEmptyClientIDIsTreatedAsAbsent() { | |
| 72 | #expect(GitHubHost(host: "github.example.com", clientID: "").clientID == nil) | |
| 73 | #expect(GitHubHost(host: "github.example.com", clientID: " ").clientID == nil) | |
| 74 | #expect(GitHubHost(host: "github.example.com", clientID: " abc ").clientID == "abc") | |
| 75 | } | |
| 76 | ||
| 77 | // MARK: - Persistence | |
| 78 | ||
| 79 | @Test func roundTripsThroughCodable() throws { | |
| 80 | for host in [GitHubHost.dotCom, GitHubHost(host: "github.example.com", clientID: "abc")] { | |
| 81 | let decoded = try JSONDecoder().decode(GitHubHost.self, from: try JSONEncoder().encode(host)) | |
| 82 | #expect(decoded == host) | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | // An account written before Enterprise support has no host field. | |
| 87 | @Test func anAccountWithoutAHostFieldDecodesAsGitHubDotCom() throws { | |
| 88 | let json = """ | |
| 89 | {"id": 7, "login": "octocat", "keychainAccount": "account-7", "hasRepoScope": false} | |
| 90 | """ | |
| 91 | ||
| 92 | let account = try JSONDecoder().decode(Account.self, from: Data(json.utf8)) | |
| 93 | ||
| 94 | #expect(account.host == .dotCom) | |
| 95 | #expect(account.displayName == "octocat") | |
| 96 | } | |
| 97 | ||
| 98 | // MARK: - Accounts | |
| 99 | ||
| 100 | // Ids are only unique within an instance, so two accounts that happen to | |
| 101 | // share an id on different hosts must not share a Keychain item. | |
| 102 | @Test func sameIdOnDifferentHostsGetsDifferentKeychainItems() { | |
| 103 | let dotCom = Account.new(id: 7, login: "octocat", hasRepoScope: false) | |
| 104 | let enterprise = Account.new( | |
| 105 | id: 7, | |
| 106 | login: "octocat", | |
| 107 | hasRepoScope: false, | |
| 108 | host: GitHubHost(host: "github.example.com", clientID: "abc") | |
| 109 | ) | |
| 110 | ||
| 111 | #expect(dotCom.keychainAccount != enterprise.keychainAccount) | |
| 112 | } | |
| 113 | ||
| 114 | @Test func enterpriseAccountsAreLabelledWithTheirHost() { | |
| 115 | let account = Account.new( | |
| 116 | id: 7, | |
| 117 | login: "octocat", | |
| 118 | hasRepoScope: false, | |
| 119 | host: GitHubHost(host: "github.example.com", clientID: "abc") | |
| 120 | ) | |
| 121 | ||
| 122 | #expect(account.displayName == "octocat @ github.example.com") | |
| 123 | } | |
| 124 | } | |