krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit ccfd0dafd5

ccfd0dafd5eebc40310860131c354053731618aa

parent: bee5f10a92

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 21:33 UTC
committer: <noreply@github.com>

Support GitHub Enterprise Server (#34)

The API base URL is configurable per account, defaulting to github.com.
Enterprise Server differs structurally, not just by hostname: REST lives
at https://HOST/api/v3 on the same host, and device flow needs an OAuth
app registered on the instance, so the client ID travels with the host.

Host is a property of an account rather than a global setting, so
github.com and an employer's instance can be watched at once. Keychain
items for Enterprise accounts are namespaced by host, since user ids
are only unique within an instance.

Account gets an explicit decoder: synthesized decoding ignores property
defaults, so an account written before this change would fail to decode
and take the whole state file with it.

Deep-links needed no change — rows already open each alert's html_url
from the API response, which Enterprise returns pointing at its own
host.

Closes #20

Layout: unified · split

octosentry/Account.swift +39 −6
@@ -20,9 +20,37 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable {
20 var login: String 20 var login: String
21 var keychainAccount: String 21 var keychainAccount: String
22 var hasRepoScope: Bool 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 var displayName: String { 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 /// The account an upgrading install already has a token for. 56 /// The account an upgrading install already has a token for.
@@ -31,16 +59,21 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable {
31 id: 0, 59 id: 0,
32 login: "", 60 login: "",
33 keychainAccount: KeychainTokenStore.legacyAccount, 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 { 67 static func new(id: Int, login: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) -> Account {
39 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 id: id, 72 id: id,
41 login: login, 73 login: login,
42 keychainAccount: "account-\(id)", 74 keychainAccount: "account-\(suffix)",
43 hasRepoScope: hasRepoScope 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 private(set) var errorMessage: String? 21 private(set) var errorMessage: String?
22 private(set) var accounts: [Account] = [] 22 private(set) var accounts: [Account] = []
23 23
24 private let client = GitHubDeviceAuthClient()
25 private let persistenceStore = PersistenceStore() 24 private let persistenceStore = PersistenceStore()
26 private var authorizationTask: Task<Void, Never>? 25 private var authorizationTask: Task<Void, Never>?
27 26
@@ -43,21 +42,22 @@ final class AuthStore {
43 accounts.contains(where: \.hasRepoScope) 42 accounts.contains(where: \.hasRepoScope)
44 } 43 }
45 44
46 func signIn() { 45 func signIn(host: GitHubHost = .dotCom) {
47 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) 46 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host)
48 } 47 }
49 48
50 /// Adds another identity. Same flow as signing in — GitHub decides which 49 /// Adds another identity, optionally on a GitHub Enterprise Server
51 /// account authorizes the code. 50 /// instance. Same flow either way — GitHub decides which account
52 func addAccount() { 51 /// authorizes the code.
53 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) 52 func addAccount(host: GitHubHost = .dotCom) {
53 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host)
54 } 54 }
55 55
56 /// Re-runs device auth with broader scope so the repo picker can list 56 /// Re-runs device auth with broader scope so the repo picker can list
57 /// repos. Only called explicitly from the repo picker UI, never on 57 /// repos. Only called explicitly from the repo picker UI, never on
58 /// the default sign-in path. 58 /// the default sign-in path.
59 func requestRepoAccess() { 59 func requestRepoAccess(host: GitHubHost = .dotCom) {
60 beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope) 60 beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope, host: host)
61 } 61 }
62 62
63 /// Signs out one account, leaving the others alone. 63 /// Signs out one account, leaving the others alone.
@@ -121,7 +121,7 @@ final class AuthStore {
121 121
122 for account in unresolved { 122 for account in unresolved {
123 guard let token = KeychainTokenStore.load(account: account.keychainAccount), 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 let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount }) 125 let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount })
126 else { continue } 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 guard authorizationTask == nil else { return } 147 guard authorizationTask == nil else { return }
148 errorMessage = nil 148 errorMessage = nil
149 149
150 authorizationTask = Task { 150 authorizationTask = Task {
151 defer { authorizationTask = nil } 151 defer { authorizationTask = nil }
152 do { 152 do {
153 let client = GitHubDeviceAuthClient(host: host)
153 let deviceCode = try await client.requestDeviceCode(scope: scope) 154 let deviceCode = try await client.requestDeviceCode(scope: scope)
154 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) 155 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
155 156
@@ -158,7 +159,7 @@ final class AuthStore {
158 interval: deviceCode.interval, 159 interval: deviceCode.interval,
159 expiresIn: deviceCode.expiresIn 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 state = .signedIn 163 state = .signedIn
163 } catch { 164 } catch {
164 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription 165 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
@@ -173,30 +174,36 @@ final class AuthStore {
173 /// Stores a freshly authorized token under its own Keychain item and 174 /// Stores a freshly authorized token under its own Keychain item and
174 /// records the account. Re-authorizing an account already present updates 175 /// records the account. Re-authorizing an account already present updates
175 /// it in place rather than adding a duplicate. 176 /// it in place rather than adding a duplicate.
176 private func register(token: String, grantedRepoScope: Bool) async throws { 177 private func register(token: String, grantedRepoScope: Bool, host: GitHubHost) async throws {
177 let user = try await GitHubSecurityAPIClient(token: token).fetchCurrentUser() 178 let user = try await GitHubSecurityAPIClient(token: token, host: host).fetchCurrentUser()
178 179
179 var persisted = await persistenceStore.load() 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 persisted.accounts[index].login = user.login 183 persisted.accounts[index].login = user.login
183 persisted.accounts[index].hasRepoScope = grantedRepoScope 184 persisted.accounts[index].hasRepoScope = grantedRepoScope
184 try KeychainTokenStore.save(token, account: persisted.accounts[index].keychainAccount) 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 // The adopted single-account entry, now identified. 187 // The adopted single-account entry, now identified.
187 let keychainAccount = persisted.accounts[index].keychainAccount 188 let keychainAccount = persisted.accounts[index].keychainAccount
188 persisted.accounts[index] = Account( 189 persisted.accounts[index] = Account(
189 id: user.id, 190 id: user.id,
190 login: user.login, 191 login: user.login,
191 keychainAccount: keychainAccount, 192 keychainAccount: keychainAccount,
192 hasRepoScope: grantedRepoScope 193 hasRepoScope: grantedRepoScope,
194 host: host
193 ) 195 )
194 for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 { 196 for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 {
195 persisted.watchedRepos[repoIndex].accountID = user.id 197 persisted.watchedRepos[repoIndex].accountID = user.id
196 } 198 }
197 try KeychainTokenStore.save(token, account: keychainAccount) 199 try KeychainTokenStore.save(token, account: keychainAccount)
198 } else { 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 try KeychainTokenStore.save(token, account: account.keychainAccount) 207 try KeychainTokenStore.save(token, account: account.keychainAccount)
201 persisted.accounts.append(account) 208 persisted.accounts.append(account)
202 } 209 }
octosentry/GitHubDeviceAuthClient.swift +10 −5
@@ -12,8 +12,12 @@ import Foundation
12 12
13actor GitHubDeviceAuthClient { 13actor GitHubDeviceAuthClient {
14 // Public client identifier for the "octosentry" OAuth App (Device Flow enabled). 14 // Public client identifier for the "octosentry" OAuth App (Device Flow enabled).
15 // Not a secret — safe to embed in source. 15 // Not a secret — safe to embed in source. A GitHub Enterprise Server
16 private let clientID = "Ov23li6tqaTghDc4IJYv" 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 // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert 22 // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert
19 // access. Classic OAuth scopes have no read-only variant (unlike fine-grained 23 // access. Classic OAuth scopes have no read-only variant (unlike fine-grained
@@ -26,13 +30,14 @@ actor GitHubDeviceAuthClient {
26 30
27 private let session: URLSession 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 self.session = session 35 self.session = session
31 } 36 }
32 37
33 func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse { 38 func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse {
34 let data = try await post( 39 let data = try await post(
35 url: URL(string: "https://github.com/login/device/code")!, 40 url: host.deviceCodeURL,
36 parameters: ["client_id": clientID, "scope": scope] 41 parameters: ["client_id": clientID, "scope": scope]
37 ) 42 )
38 do { 43 do {
@@ -52,7 +57,7 @@ actor GitHubDeviceAuthClient {
52 try Task.checkCancellation() 57 try Task.checkCancellation()
53 58
54 let data = try await post( 59 let data = try await post(
55 url: URL(string: "https://github.com/login/oauth/access_token")!, 60 url: host.accessTokenURL,
56 parameters: [ 61 parameters: [
57 "client_id": clientID, 62 "client_id": clientID,
58 "device_code": deviceCode, 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
16import Foundation
17
18nonisolated 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
13actor GitHubSecurityAPIClient { 13actor GitHubSecurityAPIClient {
14 private let token: String 14 private let token: String
15 private let session: URLSession 15 private let session: URLSession
16 private let baseURL = URL(string: "https://api.github.com")! 16 private let baseURL: URL
17 17
18 private static let decoder: JSONDecoder = { 18 private static let decoder: JSONDecoder = {
19 let decoder = JSONDecoder() 19 let decoder = JSONDecoder()
@@ -21,8 +21,9 @@ actor GitHubSecurityAPIClient {
21 return decoder 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 self.token = token 25 self.token = token
26 self.baseURL = host.apiBaseURL
26 self.session = session 27 self.session = session
27 } 28 }
28 29
@@ -94,7 +95,7 @@ actor GitHubSecurityAPIClient {
94 /// sign-in scope). Used by the repo picker (#15). 95 /// sign-in scope). Used by the repo picker (#15).
95 func fetchAccessibleRepos() async throws -> [String] { 96 func fetchAccessibleRepos() async throws -> [String] {
96 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! 97 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
97 components.path = "/user/repos" 98 components.path = baseURL.path + "/user/repos"
98 components.queryItems = [ 99 components.queryItems = [
99 URLQueryItem(name: "per_page", value: "100"), 100 URLQueryItem(name: "per_page", value: "100"),
100 URLQueryItem(name: "sort", value: "full_name"), 101 URLQueryItem(name: "sort", value: "full_name"),
@@ -107,14 +108,14 @@ actor GitHubSecurityAPIClient {
107 /// alerts attributed. Needs no scope beyond a valid user token. 108 /// alerts attributed. Needs no scope beyond a valid user token.
108 func fetchCurrentUser() async throws -> GitHubUserDTO { 109 func fetchCurrentUser() async throws -> GitHubUserDTO {
109 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! 110 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
110 components.path = "/user" 111 components.path = baseURL.path + "/user"
111 let (data, _) = try await fetchData(url: components.url!) 112 let (data, _) = try await fetchData(url: components.url!)
112 return try decode(data) 113 return try decode(data)
113 } 114 }
114 115
115 private func alertsURL(owner: String, repo: String, path: String) -> URL { 116 private func alertsURL(owner: String, repo: String, path: String) -> URL {
116 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! 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 components.queryItems = [ 119 components.queryItems = [
119 URLQueryItem(name: "state", value: "open"), 120 URLQueryItem(name: "state", value: "open"),
120 URLQueryItem(name: "per_page", value: "100"), 121 URLQueryItem(name: "per_page", value: "100"),
octosentry/SecurityEventListView.swift +49 −1
@@ -347,6 +347,8 @@ private struct RepoManagerView: View {
347 .buttonStyle(.plain) 347 .buttonStyle(.plain)
348 .foregroundStyle(Color.accentColor) 348 .foregroundStyle(Color.accentColor)
349 349
350 EnterpriseSignInView(authStore: authStore)
351
350 if let errorMessage = store.watchListErrorMessage { 352 if let errorMessage = store.watchListErrorMessage {
351 Text(errorMessage) 353 Text(errorMessage)
352 .font(.caption2) 354 .font(.caption2)
@@ -485,7 +487,7 @@ private struct RepoManagerView: View {
485 487
486 private func startBrowsing(_ account: Account) { 488 private func startBrowsing(_ account: Account) {
487 guard account.hasRepoScope else { 489 guard account.hasRepoScope else {
488 authStore.requestRepoAccess() 490 authStore.requestRepoAccess(host: account.host)
489 return 491 return
490 } 492 }
491 browsingAccount = account 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.
517private 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
512private struct FilterLabel: View { 560private struct FilterLabel: View {
513 let title: String 561 let title: String
514 let count: Int 562 let count: Int
octosentry/SecurityEventStore.swift +2 −2
@@ -111,7 +111,7 @@ final class SecurityEventStore {
111 errors.append("\(repoFullName): no signed-in account can reach this repo.") 111 errors.append("\(repoFullName): no signed-in account can reach this repo.")
112 continue 112 continue
113 } 113 }
114 let client = GitHubSecurityAPIClient(token: token) 114 let client = GitHubSecurityAPIClient(token: token, host: account.host)
115 let label = accountsByID.count > 1 115 let label = accountsByID.count > 1
116 ? "\(repoFullName) (\(account.displayName))" 116 ? "\(repoFullName) (\(account.displayName))"
117 : repoFullName 117 : repoFullName
@@ -257,7 +257,7 @@ final class SecurityEventStore {
257 guard let token = KeychainTokenStore.load(account: account.keychainAccount) else { 257 guard let token = KeychainTokenStore.load(account: account.keychainAccount) else {
258 throw GitHubAPIError.missingToken 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 /// Local-only triage state (spec §11) — no API write, no scope beyond 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
6import Foundation
7import Testing
8@testable import octosentry
9
10struct 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}