diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e209c5..efaeacfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - macOS: prevent token check/refresh from hanging; add timeouts and diagnostics logging. - macOS: detect auth failures (401/refresh errors) and log out cleanly with a clearer message. - macOS: preserve keychain access groups in signed builds so app + CLI share tokens (#16, thanks @jj3ny). +- macOS: add Personal Access Token authentication with persistence + logout fixes (#21, thanks @kkiermasz). - macOS: keep the menu open after pin/unpin/hide/move actions (#25, thanks @bahag-chaurasiak). - macOS: make Local Projects scan depth configurable (default 4) (#11, thanks @shunkakinoki). - macOS: allow importing GitHub CLI tokens with host matching and no refresh loop (#24, thanks @bahag-chaurasiak). diff --git a/Sources/RepoBar/App/AppState+Auth.swift b/Sources/RepoBar/App/AppState+Auth.swift index c3480fc4..8c91c169 100644 --- a/Sources/RepoBar/App/AppState+Auth.swift +++ b/Sources/RepoBar/App/AppState+Auth.swift @@ -9,6 +9,8 @@ extension AppState { await self.github.setAPIHost(self.defaultAPIHost) self.session.settings.githubHost = self.defaultGitHubHost self.session.settings.enterpriseHost = nil + self.session.settings.authMethod = .oauth + self.persistSettings() do { try await self.auth.login( @@ -30,4 +32,45 @@ extension AppState { self.session.lastError = error.userFacingMessage } } + + /// Authenticates with a Personal Access Token. + func loginWithPAT(_ pat: String, host: URL) async { + self.session.account = .loggingIn + self.session.lastError = nil + let apiHost = host.host == "github.com" + ? URL(string: "https://api.github.com")! + : host.appendingPathComponent("api/v3") + await self.github.setAPIHost(apiHost) + self.session.settings.githubHost = host + if host.host?.lowercased() == "github.com" { + self.session.settings.enterpriseHost = nil + } else { + self.session.settings.enterpriseHost = host + } + + do { + let user = try await self.patAuth.authenticate(pat: pat, host: host) + self.session.settings.authMethod = .pat + self.session.hasStoredTokens = true + self.session.account = .loggedIn(user) + self.session.lastError = nil + self.persistSettings() + await self.refresh() + } catch { + self.session.account = .loggedOut + self.session.settings.authMethod = .oauth + self.persistSettings() + self.session.lastError = error.localizedDescription + } + } + + /// Logs out the current user, clearing tokens based on the current auth method. + func logoutCurrentMethod() async { + await self.auth.logout() + await self.patAuth.logout() + self.session.account = .loggedOut + self.session.hasStoredTokens = false + self.session.settings.authMethod = .oauth + self.persistSettings() + } } diff --git a/Sources/RepoBar/App/AppState+Refresh.swift b/Sources/RepoBar/App/AppState+Refresh.swift index 67f24380..368e0b8b 100644 --- a/Sources/RepoBar/App/AppState+Refresh.swift +++ b/Sources/RepoBar/App/AppState+Refresh.swift @@ -49,7 +49,7 @@ extension AppState { if Task.isCancelled { return } let now = Date() self.updateHeatmapRange(now: now) - if self.auth.loadTokens() == nil { + if self.auth.loadTokens() == nil && self.patAuth.loadPAT() == nil { let localSnapshot = await self.snapshotForLoggedOutState(localSettings: localSettings) await self.applyLoggedOutState(localSnapshot: localSnapshot, lastError: nil) return diff --git a/Sources/RepoBar/App/AppState.swift b/Sources/RepoBar/App/AppState.swift index 747f0216..de67b756 100644 --- a/Sources/RepoBar/App/AppState.swift +++ b/Sources/RepoBar/App/AppState.swift @@ -9,6 +9,7 @@ import RepoBarCore final class AppState { var session = Session() let auth = OAuthCoordinator() + let patAuth = PATAuthenticator() let github = GitHubClient() let refreshScheduler = RefreshScheduler() let settingsStore = SettingsStore() @@ -39,17 +40,32 @@ final class AppState { verbosity: self.session.settings.loggingVerbosity, fileLoggingEnabled: self.session.settings.fileLoggingEnabled ) - let storedTokens = self.auth.loadTokens() - self.session.hasStoredTokens = (storedTokens != nil) + let storedOAuthTokens = self.auth.loadTokens() + let storedPAT = self.patAuth.loadPAT() + self.session.hasStoredTokens = (storedOAuthTokens != nil) || (storedPAT != nil) + let inferredAuthMethod: AuthMethod = storedPAT != nil ? .pat : .oauth + if self.session.settings.authMethod != inferredAuthMethod { + self.session.settings.authMethod = inferredAuthMethod + self.settingsStore.save(self.session.settings) + } + // Capture tokenStore separately for Sendable compliance + let tokenStore = TokenStore.shared Task { await self.github.setTokenProvider { @Sendable [weak self] () async throws -> OAuthTokens? in - try? await self?.auth.refreshIfNeeded() + guard let self else { return nil } + let authMethod = await MainActor.run { self.session.settings.authMethod } + if authMethod == .pat { + if let pat = try? tokenStore.loadPAT() { + return OAuthTokens(accessToken: pat, refreshToken: "", expiresAt: nil) + } + } + return try? await self.auth.refreshIfNeeded() } } self.tokenRefreshTask = Task { [weak self] in guard let self else { return } while !Task.isCancelled { - if self.auth.loadTokens() != nil { + if self.session.settings.authMethod == .oauth, self.auth.loadTokens() != nil { _ = try? await self.auth.refreshIfNeeded() } try? await Task.sleep(for: .seconds(self.tokenRefreshInterval)) diff --git a/Sources/RepoBar/Auth/PATAuthenticator.swift b/Sources/RepoBar/Auth/PATAuthenticator.swift new file mode 100644 index 00000000..f41860ca --- /dev/null +++ b/Sources/RepoBar/Auth/PATAuthenticator.swift @@ -0,0 +1,122 @@ +import Foundation +import OSLog +import RepoBarCore + +public enum PATAuthError: Error, LocalizedError { + case invalidToken + case forbidden(String) + case networkError(Error) + case invalidResponse + + public var errorDescription: String? { + switch self { + case .invalidToken: + "Invalid token" + case let .forbidden(message): + message + case let .networkError(error): + error.localizedDescription + case .invalidResponse: + "Invalid response from server" + } + } +} + +/// Handles Personal Access Token authentication as an alternative to OAuth. +/// PATs can be authorized for SAML SSO organizations in GitHub settings. +@MainActor +public final class PATAuthenticator { + private let tokenStore: TokenStore + private let signposter = OSSignposter(subsystem: "com.steipete.repobar", category: "pat-auth") + private var cachedPAT: String? + private var hasLoadedPAT = false + private let session: URLSession + + public init( + tokenStore: TokenStore = .shared, + session: URLSession = .shared + ) { + self.tokenStore = tokenStore + self.session = session + } + + /// Validates PAT via GET /user, stores on success, returns UserIdentity. + public func authenticate(pat: String, host: URL) async throws -> UserIdentity { + let signpost = self.signposter.beginInterval("authenticate") + defer { self.signposter.endInterval("authenticate", signpost) } + + let apiHost = Self.apiHost(for: host) + let userURL = apiHost.appendingPathComponent("user") + + var request = URLRequest(url: userURL) + request.setValue("Bearer \(pat)", forHTTPHeaderField: "Authorization") + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await self.session.data(for: request) + } catch { + throw PATAuthError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw PATAuthError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw PATAuthError.invalidToken + case 403: + throw PATAuthError.forbidden("Access forbidden. Token may lack required scopes (repo, read:org)") + default: + throw PATAuthError.invalidResponse + } + + struct UserResponse: Decodable { + let login: String + } + + let user: UserResponse + do { + user = try JSONDecoder().decode(UserResponse.self, from: data) + } catch { + throw PATAuthError.invalidResponse + } + + try self.tokenStore.savePAT(pat) + self.cachedPAT = pat + self.hasLoadedPAT = true + await DiagnosticsLogger.shared.message("PAT login succeeded; token stored.") + + return UserIdentity(username: user.login, host: host) + } + + /// Loads the stored PAT from Keychain. + public func loadPAT() -> String? { + if self.hasLoadedPAT { return self.cachedPAT } + self.hasLoadedPAT = true + let pat = try? self.tokenStore.loadPAT() + self.cachedPAT = pat + return pat + } + + /// Clears the stored PAT. + public func logout() async { + self.tokenStore.clearPAT() + self.cachedPAT = nil + self.hasLoadedPAT = false + await DiagnosticsLogger.shared.message("PAT cleared.") + } + + /// Converts a GitHub host URL to its API endpoint. + private static func apiHost(for host: URL) -> URL { + let hostString = host.host ?? "github.com" + if hostString == "github.com" { + return URL(string: "https://api.github.com")! + } + // Enterprise: use /api/v3 path + return host.appendingPathComponent("api/v3") + } +} diff --git a/Sources/RepoBar/Settings/AccountSettingsView.swift b/Sources/RepoBar/Settings/AccountSettingsView.swift index f53b2e0a..39db84d6 100644 --- a/Sources/RepoBar/Settings/AccountSettingsView.swift +++ b/Sources/RepoBar/Settings/AccountSettingsView.swift @@ -9,6 +9,9 @@ struct AccountSettingsView: View { @State private var clientSecret = "" @State private var enterpriseHost = "" @State private var hostMode: HostMode = .githubCom + @State private var authMethod: AuthMethod = .oauth + @State private var patInput = "" + @State private var isValidatingPAT = false @State private var validationError: String? @State private var tokenValidation: TokenValidationState = .unknown private let enterpriseFieldMinWidth: CGFloat = 260 @@ -44,9 +47,9 @@ struct AccountSettingsView: View { Spacer() Button("Log out") { Task { - await self.appState.auth.logout() - self.session.account = .loggedOut - self.session.hasStoredTokens = false + await self.appState.logoutCurrentMethod() + self.authMethod = .oauth + self.patInput = "" } } .buttonStyle(.bordered) @@ -68,54 +71,95 @@ struct AccountSettingsView: View { Task { await self.validateToken() } } .disabled(self.tokenValidation == .checking) - Button("Refresh token") { - Task { await self.refreshToken() } + if self.session.settings.authMethod == .oauth { + Button("Refresh token") { + Task { await self.refreshToken() } + } + .disabled(self.tokenValidation == .checking) } - .disabled(self.tokenValidation == .checking) } .buttonStyle(.bordered) } .padding(.vertical, 4) default: - if self.hostMode == .enterprise { - LabeledContent("Enterprise Base URL") { - TextField("https://ghe.example.com", text: self.$enterpriseHost) - .frame(minWidth: self.enterpriseFieldMinWidth) - .layoutPriority(1) + Picker("Authentication", selection: self.$authMethod) { + ForEach(AuthMethod.allCases, id: \.self) { method in + Text(method.label).tag(method) } - LabeledContent("Client ID") { - TextField("", text: self.$clientID) - .frame(minWidth: self.enterpriseFieldMinWidth) - .layoutPriority(1) - } - LabeledContent("Client Secret") { - SecureField("", text: self.$clientSecret) + } + .pickerStyle(.segmented) + + if self.authMethod == .pat { + LabeledContent("Token") { + SecureField("ghp_...", text: self.$patInput) .frame(minWidth: self.enterpriseFieldMinWidth) .layoutPriority(1) } - Text("Create an OAuth App in your enterprise server. Callback URL: http://127.0.0.1:53682/callback") + Text("Recommended for SAML SSO organizations. Required scopes: repo, read:org") .font(.caption) .foregroundStyle(.secondary) - } else { - Text("Uses the built-in GitHub.com OAuth app.") + Link("Create a token on GitHub", destination: self.createTokenURL()) .font(.caption) - .foregroundStyle(.secondary) - } - HStack(spacing: 8) { - if self.session.account == .loggingIn { - ProgressView() - .controlSize(.small) - .frame(width: self.spinnerSize, height: self.spinnerSize) + if self.hostMode == .enterprise { + LabeledContent("Enterprise Base URL") { + TextField("https://ghe.example.com", text: self.$enterpriseHost) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + } + HStack(spacing: 8) { + if self.isValidatingPAT { + ProgressView() + .controlSize(.small) + .frame(width: self.spinnerSize, height: self.spinnerSize) + } + Button(self.isValidatingPAT ? "Signing in…" : "Sign in with Token") { + self.loginWithPAT() + } + .disabled(self.patInput.isEmpty || self.isValidatingPAT) + .buttonStyle(.borderedProminent) + } + } else { + if self.hostMode == .enterprise { + LabeledContent("Enterprise Base URL") { + TextField("https://ghe.example.com", text: self.$enterpriseHost) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + LabeledContent("Client ID") { + TextField("", text: self.$clientID) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + LabeledContent("Client Secret") { + SecureField("", text: self.$clientSecret) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + Text("Create an OAuth App in your enterprise server. Callback URL: http://127.0.0.1:53682/callback") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Uses the built-in GitHub.com OAuth app.") + .font(.caption) + .foregroundStyle(.secondary) } - Button(self.session.account == .loggingIn ? "Signing in…" : self.hostMode == .enterprise ? "Sign in to Enterprise" : "Sign in to GitHub.com") { - self.login() + HStack(spacing: 8) { + if self.session.account == .loggingIn { + ProgressView() + .controlSize(.small) + .frame(width: self.spinnerSize, height: self.spinnerSize) + } + Button(self.session.account == .loggingIn ? "Signing in…" : self.hostMode == .enterprise ? "Sign in to Enterprise" : "Sign in to GitHub.com") { + self.login() + } + .disabled(self.session.account == .loggingIn) + .buttonStyle(.borderedProminent) } - .disabled(self.session.account == .loggingIn) - .buttonStyle(.borderedProminent) + Text("Uses browser-based OAuth. Tokens are stored in the system Keychain.") + .font(.caption) + .foregroundStyle(.secondary) } - Text("Uses browser-based OAuth. Tokens are stored in the system Keychain.") - .font(.caption) - .foregroundStyle(.secondary) } } @@ -148,6 +192,7 @@ struct AccountSettingsView: View { self.clientSecret = RepoBarAuthDefaults.clientSecret } } + self.authMethod = self.session.settings.authMethod } .task(id: self.session.account) { guard case .loggedIn = self.session.account else { @@ -199,6 +244,8 @@ struct AccountSettingsView: View { host: self.session.settings.enterpriseHost ?? self.session.settings.githubHost, loopbackPort: self.session.settings.loopbackPort ) + self.session.settings.authMethod = .oauth + self.appState.persistSettings() self.session.hasStoredTokens = true if let user = try? await appState.github.currentUser() { self.session.account = .loggedIn(user) @@ -214,6 +261,41 @@ struct AccountSettingsView: View { } } + private func loginWithPAT() { + Task { @MainActor in + self.isValidatingPAT = true + self.validationError = nil + + let host: URL + if self.hostMode == .enterprise { + guard let enterpriseURL = self.normalizedEnterpriseHost() else { + self.validationError = "Enterprise Base URL must be a valid https:// URL with a trusted certificate." + self.isValidatingPAT = false + return + } + self.session.settings.enterpriseHost = enterpriseURL + host = enterpriseURL + } else { + self.session.settings.enterpriseHost = nil + host = URL(string: "https://github.com")! + } + + await self.appState.loginWithPAT(self.patInput, host: host) + self.isValidatingPAT = false + + if case .loggedIn = self.session.account { + self.patInput = "" + } + } + } + + private func createTokenURL() -> URL { + let baseHost = self.hostMode == .enterprise + ? (self.normalizedEnterpriseHost()?.absoluteString ?? "https://github.com") + : "https://github.com" + return URL(string: "\(baseHost)/settings/tokens/new?scopes=repo,read:org&description=RepoBar")! + } + private func normalizedEnterpriseHost() -> URL? { guard !self.enterpriseHost.isEmpty else { return nil } guard var components = URLComponents(string: enterpriseHost) else { return nil } diff --git a/Sources/RepoBarCore/Auth/TokenStore.swift b/Sources/RepoBarCore/Auth/TokenStore.swift index 6c65f386..70085a42 100644 --- a/Sources/RepoBarCore/Auth/TokenStore.swift +++ b/Sources/RepoBarCore/Auth/TokenStore.swift @@ -66,6 +66,23 @@ public struct TokenStore: Sendable { public func clear() { self.clear(account: "default") self.clear(account: "client") + self.clearPAT() + } + + // MARK: - PAT Storage + + public func savePAT(_ token: String) throws { + let data = Data(token.utf8) + try self.save(data: data, account: "pat") + } + + public func loadPAT() throws -> String? { + guard let data = try self.loadData(account: "pat") else { return nil } + return String(data: data, encoding: .utf8) + } + + public func clearPAT() { + self.clear(account: "pat") } } diff --git a/Sources/RepoBarCore/Settings/UserSettings.swift b/Sources/RepoBarCore/Settings/UserSettings.swift index e67579cf..ca641f9e 100644 --- a/Sources/RepoBarCore/Settings/UserSettings.swift +++ b/Sources/RepoBarCore/Settings/UserSettings.swift @@ -15,10 +15,23 @@ public struct UserSettings: Equatable, Codable { public var githubHost: URL = .init(string: "https://github.com")! public var enterpriseHost: URL? public var loopbackPort: Int = 53682 + public var authMethod: AuthMethod = .oauth public init() {} } +public enum AuthMethod: String, CaseIterable, Equatable, Codable, Sendable { + case oauth + case pat + + public var label: String { + switch self { + case .oauth: "OAuth" + case .pat: "Personal Access Token" + } + } +} + public struct HeatmapSettings: Equatable, Codable { public var display: HeatmapDisplay = .inline public var span: HeatmapSpan = .twelveMonths diff --git a/Tests/RepoBarTests/PATAuthenticatorTests.swift b/Tests/RepoBarTests/PATAuthenticatorTests.swift new file mode 100644 index 00000000..ee573df5 --- /dev/null +++ b/Tests/RepoBarTests/PATAuthenticatorTests.swift @@ -0,0 +1,253 @@ +import Foundation +@testable import RepoBar +@testable import RepoBarCore +import Testing + +struct PATAuthenticatorTests { + @Test + @MainActor + func validatePATSuccess() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + #expect(request.url?.path == "/user") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer ghp_testtoken") + + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + let data = Data(""" + {"login":"testuser"} + """.utf8) + return (data, response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + let user = try await authenticator.authenticate( + pat: "ghp_testtoken", + host: URL(string: "https://github.com")! + ) + + #expect(user.username == "testuser") + #expect(user.host.absoluteString == "https://github.com") + #expect(try store.loadPAT() == "ghp_testtoken") + } + + @Test + @MainActor + func validatePATInvalidToken() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + do { + _ = try await authenticator.authenticate( + pat: "invalid_token", + host: URL(string: "https://github.com")! + ) + Issue.record("Expected invalidToken error") + } catch let error as PATAuthError { + guard case .invalidToken = error else { + Issue.record("Expected PATAuthError.invalidToken, got \(error)") + return + } + } + + #expect(try store.loadPAT() == nil) + } + + @Test + @MainActor + func validatePATForbidden() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 403, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + do { + _ = try await authenticator.authenticate( + pat: "token_without_scopes", + host: URL(string: "https://github.com")! + ) + Issue.record("Expected forbidden error") + } catch let error as PATAuthError { + guard case .forbidden = error else { + Issue.record("Expected PATAuthError.forbidden, got \(error)") + return + } + } + + #expect(try store.loadPAT() == nil) + } + + @Test + @MainActor + func logoutClearsPAT() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + try store.savePAT("ghp_testtoken") + + let authenticator = PATAuthenticator(tokenStore: store) + + // Load the PAT first + let loadedBefore = authenticator.loadPAT() + #expect(loadedBefore == "ghp_testtoken") + + await authenticator.logout() + + let loadedAfter = authenticator.loadPAT() + #expect(loadedAfter == nil) + } + + @Test + @MainActor + func loadPATReturnsNilWhenNotStored() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let authenticator = PATAuthenticator(tokenStore: store) + + let loaded = authenticator.loadPAT() + #expect(loaded == nil) + } + + @Test + @MainActor + func enterpriseHostUsesAPIPath() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + // Enterprise should use /api/v3/user path + #expect(request.url?.path == "/api/v3/user") + + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + let data = Data(""" + {"login":"enterpriseuser"} + """.utf8) + return (data, response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + let user = try await authenticator.authenticate( + pat: "ghp_enterprisetoken", + host: URL(string: "https://ghe.example.com")! + ) + + #expect(user.username == "enterpriseuser") + } +} + +private extension PATAuthenticatorTests { + static func sessionConfiguration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return config + } + + static func taggedSession(_ session: URLSession, handlerID: String) -> URLSession { + // Create a new session with the handler ID embedded + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + config.httpAdditionalHeaders = ["X-Handler-ID": handlerID] + return URLSession(configuration: config) + } + + // swiftlint:disable static_over_final_class + final class MockURLProtocol: URLProtocol { + private static let handlersLock = NSLock() + private nonisolated(unsafe) static var handlers: [String: @Sendable (URLRequest) throws -> (Data, URLResponse)] = [:] + + static func register( + handlerID: String, + handler: @escaping @Sendable (URLRequest) throws -> (Data, URLResponse) + ) { + self.handlersLock.lock() + self.handlers[handlerID] = handler + self.handlersLock.unlock() + } + + static func unregister(handlerID: String) { + self.handlersLock.lock() + self.handlers[handlerID] = nil + self.handlersLock.unlock() + } + + override class func canInit(with request: URLRequest) -> Bool { + request.value(forHTTPHeaderField: "X-Handler-ID") != nil + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard + let handlerID = request.value(forHTTPHeaderField: "X-Handler-ID"), + let handler = Self.handler(for: handlerID) + else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + + do { + let (data, response) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + private static func handler(for handlerID: String) -> (@Sendable (URLRequest) throws -> (Data, URLResponse))? { + self.handlersLock.lock() + defer { handlersLock.unlock() } + return self.handlers[handlerID] + } + } + // swiftlint:enable static_over_final_class +} diff --git a/Tests/RepoBarTests/TokenStorePATTests.swift b/Tests/RepoBarTests/TokenStorePATTests.swift new file mode 100644 index 00000000..03d8b4ba --- /dev/null +++ b/Tests/RepoBarTests/TokenStorePATTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import RepoBarCore +import Testing + +struct TokenStorePATTests { + @Test + func savePATAndLoad() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let pat = "ghp_test123456789" + try store.savePAT(pat) + + let loaded = try store.loadPAT() + #expect(loaded == pat) + } + + @Test + func clearRemovesPAT() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let pat = "ghp_test123456789" + try store.savePAT(pat) + + store.clearPAT() + + let loaded = try store.loadPAT() + #expect(loaded == nil) + } + + @Test + func loadPATWhenNoneStored() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let loaded = try store.loadPAT() + #expect(loaded == nil) + } + + @Test + func clearAlsoClearsPAT() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let pat = "ghp_test123456789" + try store.savePAT(pat) + + // clear() should also clear PAT + store.clear() + + let loaded = try store.loadPAT() + #expect(loaded == nil) + } + + @Test + func savePATOverwritesPrevious() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + try store.savePAT("ghp_first") + try store.savePAT("ghp_second") + + let loaded = try store.loadPAT() + #expect(loaded == "ghp_second") + } +}