Commit b3259becce
Unsigned
Layout: unified · split
gitbay/Auth/Account.swift added +20
| @@ -0,0 +1,20 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One signed-in identity: an account on an instance. gitbay is | ||
| 4 | /// self-hosted, so the pair is the unit — the same person on two | ||
| 5 | /// instances is two accounts. | ||
| 6 | nonisolated struct Account: Codable, Hashable, Sendable, Identifiable { | ||
| 7 | let instance: GitbayInstance | ||
| 8 | let username: String | ||
| 9 | |||
| 10 | /// Stable across renames of nothing — usernames are the identity on an | ||
| 11 | /// instance, and tokens are keyed by this. | ||
| 12 | var id: String { | ||
| 13 | "\(instance.baseURL.absoluteString)#\(username)" | ||
| 14 | } | ||
| 15 | |||
| 16 | /// "cmc on gitbay.org" | ||
| 17 | var label: String { | ||
| 18 | "\(username) on \(instance.baseURL.host() ?? instance.baseURL.absoluteString)" | ||
| 19 | } | ||
| 20 | } | ||
gitbay/Auth/SessionStore.swift added +135
| @@ -0,0 +1,135 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// Which accounts exist, which one is active, and the client for it. | ||
| 5 | /// | ||
| 6 | /// Signing in validates the pasted token with `whoami` before anything is | ||
| 7 | /// stored, so a stored account is always one that worked at least once. A | ||
| 8 | /// revoked token later comes back as 401 on some request; `expire` turns | ||
| 9 | /// that into a clean sign-out with a plain sentence on the sign-in screen. | ||
| 10 | @Observable | ||
| 11 | @MainActor | ||
| 12 | final class SessionStore { | ||
| 13 | |||
| 14 | private(set) var accounts: [Account] | ||
| 15 | private(set) var current: Account? | ||
| 16 | private(set) var client: GitbayClient? | ||
| 17 | |||
| 18 | /// Why the user is looking at the sign-in screen, when there is a | ||
| 19 | /// reason beyond never having signed in. | ||
| 20 | private(set) var signedOutMessage: String? | ||
| 21 | |||
| 22 | private let store: any TokenStore | ||
| 23 | private let makeClient: @Sendable (GitbayInstance, String) -> GitbayClient | ||
| 24 | |||
| 25 | /// The active account survives relaunch in UserDefaults. An account id | ||
| 26 | /// is not a secret — the token it points at stays in the Keychain. | ||
| 27 | private let defaults: UserDefaults | ||
| 28 | private var currentAccountID: String { | ||
| 29 | get { defaults.string(forKey: "currentAccountID") ?? "" } | ||
| 30 | set { defaults.set(newValue, forKey: "currentAccountID") } | ||
| 31 | } | ||
| 32 | |||
| 33 | init( | ||
| 34 | store: any TokenStore = KeychainTokenStore(), | ||
| 35 | defaults: UserDefaults = .standard, | ||
| 36 | makeClient: @escaping @Sendable (GitbayInstance, String) -> GitbayClient = { | ||
| 37 | GitbayClient(instance: $0, token: $1) | ||
| 38 | } | ||
| 39 | ) { | ||
| 40 | self.store = store | ||
| 41 | self.makeClient = makeClient | ||
| 42 | self.defaults = defaults | ||
| 43 | self.accounts = store.loadAccounts() | ||
| 44 | restore() | ||
| 45 | } | ||
| 46 | |||
| 47 | private func restore() { | ||
| 48 | let candidate = accounts.first { $0.id == currentAccountID } ?? accounts.first | ||
| 49 | guard let candidate, let token = store.token(for: candidate.id) else { | ||
| 50 | current = nil | ||
| 51 | client = nil | ||
| 52 | return | ||
| 53 | } | ||
| 54 | activate(candidate, token: token) | ||
| 55 | } | ||
| 56 | |||
| 57 | /// Validate a pasted token against an instance and store the account. | ||
| 58 | /// Throws `GitbayError` with a user-facing message on every failure. | ||
| 59 | func signIn(instanceURL: String, token: String) async throws { | ||
| 60 | let instance = try GitbayInstance(url: instanceURL) | ||
| 61 | let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 62 | guard !trimmed.isEmpty else { | ||
| 63 | throw GitbayError.unauthorized("empty token") | ||
| 64 | } | ||
| 65 | let candidate = makeClient(instance, trimmed) | ||
| 66 | let who = try await candidate.read(["whoami"], as: WhoamiResponse.self) | ||
| 67 | |||
| 68 | let account = Account(instance: instance, username: who.username) | ||
| 69 | try store.saveToken(trimmed, for: account.id) | ||
| 70 | if !accounts.contains(account) { | ||
| 71 | accounts.append(account) | ||
| 72 | try store.saveAccounts(accounts) | ||
| 73 | } | ||
| 74 | current = account | ||
| 75 | client = candidate | ||
| 76 | currentAccountID = account.id | ||
| 77 | signedOutMessage = nil | ||
| 78 | } | ||
| 79 | |||
| 80 | /// Switch to another stored account. Returns false if its token is | ||
| 81 | /// gone from the Keychain, in which case the account is dropped too. | ||
| 82 | @discardableResult | ||
| 83 | func activate(_ account: Account) -> Bool { | ||
| 84 | guard let token = store.token(for: account.id) else { | ||
| 85 | remove(account) | ||
| 86 | return false | ||
| 87 | } | ||
| 88 | activate(account, token: token) | ||
| 89 | return true | ||
| 90 | } | ||
| 91 | |||
| 92 | private func activate(_ account: Account, token: String) { | ||
| 93 | current = account | ||
| 94 | client = makeClient(account.instance, token) | ||
| 95 | currentAccountID = account.id | ||
| 96 | } | ||
| 97 | |||
| 98 | /// Forget an account: token out of the Keychain, account out of the | ||
| 99 | /// list, and if it was active, over to the next one or signed out. | ||
| 100 | func remove(_ account: Account) { | ||
| 101 | store.deleteToken(for: account.id) | ||
| 102 | accounts.removeAll { $0.id == account.id } | ||
| 103 | try? store.saveAccounts(accounts) | ||
| 104 | if current?.id == account.id { | ||
| 105 | current = nil | ||
| 106 | client = nil | ||
| 107 | currentAccountID = "" | ||
| 108 | restore() | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | /// The server said 401: the token is expired or revoked. Sign the | ||
| 113 | /// account out cleanly and say why — never crash, never loop. | ||
| 114 | func expire(_ account: Account) { | ||
| 115 | remove(account) | ||
| 116 | if current == nil { | ||
| 117 | signedOutMessage = | ||
| 118 | "The token for \(account.label) is no longer valid. Mint a new one and sign in again." | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | /// Route an error from any screen: a 401 expires the current account; | ||
| 123 | /// everything else is the caller's to show. | ||
| 124 | func handle(_ error: any Error) { | ||
| 125 | guard let error = error as? GitbayError, error.requiresReauthentication, | ||
| 126 | let current else { | ||
| 127 | return | ||
| 128 | } | ||
| 129 | expire(current) | ||
| 130 | } | ||
| 131 | |||
| 132 | nonisolated private struct WhoamiResponse: Decodable, Sendable { | ||
| 133 | let username: String | ||
| 134 | } | ||
| 135 | } | ||
gitbay/Auth/TokenStore.swift added +98
| @@ -0,0 +1,98 @@ | |||
| 1 | import Foundation | ||
| 2 | @preconcurrency import Security | ||
| 3 | |||
| 4 | /// Persistence for accounts and their tokens. The Keychain implementation | ||
| 5 | /// is the real one; tests use an in-memory stand-in so no test touches the | ||
| 6 | /// host Keychain. | ||
| 7 | nonisolated protocol TokenStore: Sendable { | ||
| 8 | func saveToken(_ token: String, for accountID: String) throws | ||
| 9 | func token(for accountID: String) -> String? | ||
| 10 | func deleteToken(for accountID: String) | ||
| 11 | func saveAccounts(_ accounts: [Account]) throws | ||
| 12 | func loadAccounts() -> [Account] | ||
| 13 | } | ||
| 14 | |||
| 15 | enum TokenStoreError: LocalizedError, Sendable { | ||
| 16 | case keychain(OSStatus) | ||
| 17 | |||
| 18 | var errorDescription: String? { | ||
| 19 | switch self { | ||
| 20 | case .keychain(let status): | ||
| 21 | "Could not store the token securely (\(status))." | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Tokens and the account list live in the Keychain and nowhere else — | ||
| 27 | /// nothing git tracks, nothing in UserDefaults, no iCloud sync | ||
| 28 | /// (ThisDeviceOnly): a token names this phone, not the account. | ||
| 29 | nonisolated struct KeychainTokenStore: TokenStore { | ||
| 30 | |||
| 31 | private static let service = "org.gitbay.gitbay" | ||
| 32 | private static let accountsKey = "accounts" | ||
| 33 | |||
| 34 | func saveToken(_ token: String, for accountID: String) throws { | ||
| 35 | try save(Data(token.utf8), account: "token." + accountID) | ||
| 36 | } | ||
| 37 | |||
| 38 | func token(for accountID: String) -> String? { | ||
| 39 | load(account: "token." + accountID).flatMap { String(data: $0, encoding: .utf8) } | ||
| 40 | } | ||
| 41 | |||
| 42 | func deleteToken(for accountID: String) { | ||
| 43 | delete(account: "token." + accountID) | ||
| 44 | } | ||
| 45 | |||
| 46 | func saveAccounts(_ accounts: [Account]) throws { | ||
| 47 | try save(JSONEncoder().encode(accounts), account: Self.accountsKey) | ||
| 48 | } | ||
| 49 | |||
| 50 | func loadAccounts() -> [Account] { | ||
| 51 | guard let data = load(account: Self.accountsKey), | ||
| 52 | let accounts = try? JSONDecoder().decode([Account].self, from: data) else { | ||
| 53 | return [] | ||
| 54 | } | ||
| 55 | return accounts | ||
| 56 | } | ||
| 57 | |||
| 58 | // MARK: - SecItem | ||
| 59 | |||
| 60 | private func save(_ data: Data, account: String) throws { | ||
| 61 | delete(account: account) | ||
| 62 | let query: [String: Any] = [ | ||
| 63 | kSecClass as String: kSecClassGenericPassword, | ||
| 64 | kSecAttrService as String: Self.service, | ||
| 65 | kSecAttrAccount as String: account, | ||
| 66 | kSecValueData as String: data, | ||
| 67 | kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, | ||
| 68 | ] | ||
| 69 | let status = SecItemAdd(query as CFDictionary, nil) | ||
| 70 | guard status == errSecSuccess else { | ||
| 71 | throw TokenStoreError.keychain(status) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | private func load(account: String) -> Data? { | ||
| 76 | let query: [String: Any] = [ | ||
| 77 | kSecClass as String: kSecClassGenericPassword, | ||
| 78 | kSecAttrService as String: Self.service, | ||
| 79 | kSecAttrAccount as String: account, | ||
| 80 | kSecReturnData as String: true, | ||
| 81 | kSecMatchLimit as String: kSecMatchLimitOne, | ||
| 82 | ] | ||
| 83 | var result: AnyObject? | ||
| 84 | guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { | ||
| 85 | return nil | ||
| 86 | } | ||
| 87 | return result as? Data | ||
| 88 | } | ||
| 89 | |||
| 90 | private func delete(account: String) { | ||
| 91 | let query: [String: Any] = [ | ||
| 92 | kSecClass as String: kSecClassGenericPassword, | ||
| 93 | kSecAttrService as String: Self.service, | ||
| 94 | kSecAttrAccount as String: account, | ||
| 95 | ] | ||
| 96 | SecItemDelete(query as CFDictionary) | ||
| 97 | } | ||
| 98 | } | ||
gitbay/ContentView.swift +47 −13
| @@ -1,24 +1,58 @@ | |||
| 1 | // | ||
| 2 | // ContentView.swift | ||
| 3 | // gitbay | ||
| 4 | // | ||
| 5 | // Created by cmc on 2026-08-26. | ||
| 6 | // | ||
| 7 | |||
| 8 | import SwiftUI | 1 | import SwiftUI |
| 9 | 2 | ||
| 10 | struct ContentView: View { | 3 | struct ContentView: View { |
| 4 | |||
| 5 | @Environment(SessionStore.self) private var session | ||
| 6 | |||
| 7 | var body: some View { | ||
| 8 | NavigationStack { | ||
| 9 | if let account = session.current { | ||
| 10 | SignedInPlaceholderView(account: account) | ||
| 11 | } else { | ||
| 12 | SignInView() | ||
| 13 | } | ||
| 14 | } | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Stands in until the repo list lands; proves sign-in, account switching | ||
| 19 | /// and sign-out end to end. | ||
| 20 | private struct SignedInPlaceholderView: View { | ||
| 21 | |||
| 22 | @Environment(SessionStore.self) private var session | ||
| 23 | let account: Account | ||
| 24 | |||
| 11 | var body: some View { | 25 | var body: some View { |
| 12 | VStack { | 26 | List { |
| 13 | Image(systemName: "globe") | 27 | Section("Signed in") { |
| 14 | .imageScale(.large) | 28 | LabeledContent("Account", value: account.username) |
| 15 | .foregroundStyle(.tint) | 29 | LabeledContent( |
| 16 | Text("Hello, world!") | 30 | "Instance", |
| 31 | value: account.instance.baseURL.host() ?? account.instance.baseURL.absoluteString | ||
| 32 | ) | ||
| 33 | } | ||
| 34 | |||
| 35 | if session.accounts.count > 1 { | ||
| 36 | Section("Switch account") { | ||
| 37 | ForEach(session.accounts.filter { $0.id != account.id }) { other in | ||
| 38 | Button(other.label) { | ||
| 39 | session.activate(other) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | Section { | ||
| 46 | Button("Sign Out", role: .destructive) { | ||
| 47 | session.remove(account) | ||
| 48 | } | ||
| 49 | } | ||
| 17 | } | 50 | } |
| 18 | .padding() | 51 | .navigationTitle("gitbay") |
| 19 | } | 52 | } |
| 20 | } | 53 | } |
| 21 | 54 | ||
| 22 | #Preview { | 55 | #Preview { |
| 23 | ContentView() | 56 | ContentView() |
| 57 | .environment(SessionStore()) | ||
| 24 | } | 58 | } |
gitbay/Views/SignInView.swift added +108
| @@ -0,0 +1,108 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// Paste a token, name the instance, done. There is no browser flow and no | ||
| 4 | /// password anywhere in the system: tokens are minted over SSH on a | ||
| 5 | /// machine that has it. | ||
| 6 | struct SignInView: View { | ||
| 7 | |||
| 8 | @Environment(SessionStore.self) private var session | ||
| 9 | |||
| 10 | @State private var instanceURL = "gitbay.org" | ||
| 11 | @State private var token = "" | ||
| 12 | @State private var errorMessage: String? | ||
| 13 | @State private var signingIn = false | ||
| 14 | @FocusState private var tokenFieldFocused: Bool | ||
| 15 | |||
| 16 | var body: some View { | ||
| 17 | Form { | ||
| 18 | if let notice = session.signedOutMessage { | ||
| 19 | Section { | ||
| 20 | Label(notice, systemImage: "key.slash") | ||
| 21 | .foregroundStyle(.secondary) | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | Section("Instance") { | ||
| 26 | TextField("gitbay.org", text: $instanceURL) | ||
| 27 | .textContentType(.URL) | ||
| 28 | .keyboardType(.URL) | ||
| 29 | .autocorrectionDisabled() | ||
| 30 | .textInputAutocapitalization(.never) | ||
| 31 | } | ||
| 32 | |||
| 33 | Section { | ||
| 34 | SecureField("Paste a token", text: $token) | ||
| 35 | .autocorrectionDisabled() | ||
| 36 | .textInputAutocapitalization(.never) | ||
| 37 | .focused($tokenFieldFocused) | ||
| 38 | } header: { | ||
| 39 | Text("Token") | ||
| 40 | } footer: { | ||
| 41 | VStack(alignment: .leading, spacing: 8) { | ||
| 42 | Text("Mint one over SSH on a machine that has your key:") | ||
| 43 | Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d") | ||
| 44 | .font(.caption.monospaced()) | ||
| 45 | .textSelection(.enabled) | ||
| 46 | Text("`--scope read` also works, but a read-only token cannot comment or merge.") | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | if let errorMessage { | ||
| 51 | Section { | ||
| 52 | Label(errorMessage, systemImage: "exclamationmark.triangle") | ||
| 53 | .foregroundStyle(.red) | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | Section { | ||
| 58 | Button { | ||
| 59 | signIn() | ||
| 60 | } label: { | ||
| 61 | if signingIn { | ||
| 62 | ProgressView() | ||
| 63 | .frame(maxWidth: .infinity) | ||
| 64 | } else { | ||
| 65 | Text("Sign In") | ||
| 66 | .frame(maxWidth: .infinity) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | .disabled(signingIn || token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | .navigationTitle("Sign in to gitbay") | ||
| 73 | .onAppear { tokenFieldFocused = true } | ||
| 74 | } | ||
| 75 | |||
| 76 | private func signIn() { | ||
| 77 | signingIn = true | ||
| 78 | errorMessage = nil | ||
| 79 | Task { | ||
| 80 | defer { signingIn = false } | ||
| 81 | do { | ||
| 82 | try await session.signIn(instanceURL: instanceURL, token: token) | ||
| 83 | token = "" | ||
| 84 | } catch let error as GitbayError { | ||
| 85 | errorMessage = signInMessage(for: error) | ||
| 86 | } catch let error as GitbayInstance.InvalidURL { | ||
| 87 | errorMessage = error.errorDescription | ||
| 88 | } catch { | ||
| 89 | errorMessage = "Something went wrong. Please try again." | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | /// Sign-in is the one place a 401 means "that token", not "your session". | ||
| 95 | private func signInMessage(for error: GitbayError) -> String { | ||
| 96 | if error.requiresReauthentication { | ||
| 97 | return "That token was not accepted. Check it was pasted whole and has not expired." | ||
| 98 | } | ||
| 99 | return error.userFacingMessage | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | #Preview { | ||
| 104 | NavigationStack { | ||
| 105 | SignInView() | ||
| 106 | } | ||
| 107 | .environment(SessionStore()) | ||
| 108 | } | ||
gitbay/gitbayApp.swift +4 −7
| @@ -1,17 +1,14 @@ | |||
| 1 | // | ||
| 2 | // gitbayApp.swift | ||
| 3 | // gitbay | ||
| 4 | // | ||
| 5 | // Created by cmc on 2026-08-26. | ||
| 6 | // | ||
| 7 | |||
| 8 | import SwiftUI | 1 | import SwiftUI |
| 9 | 2 | ||
| 10 | @main | 3 | @main |
| 11 | struct gitbayApp: App { | 4 | struct gitbayApp: App { |
| 5 | |||
| 6 | @State private var session = SessionStore() | ||
| 7 | |||
| 12 | var body: some Scene { | 8 | var body: some Scene { |
| 13 | WindowGroup { | 9 | WindowGroup { |
| 14 | ContentView() | 10 | ContentView() |
| 11 | .environment(session) | ||
| 15 | } | 12 | } |
| 16 | } | 13 | } |
| 17 | } | 14 | } |
gitbayTests/SessionStoreTests.swift added +197
| @@ -0,0 +1,197 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | /// In-memory TokenStore so no test touches the host Keychain. | ||
| 6 | nonisolated final class MemoryTokenStore: TokenStore, @unchecked Sendable { | ||
| 7 | |||
| 8 | private let state = Mutex<(tokens: [String: String], accounts: [Account])>((tokens: [:], accounts: [])) | ||
| 9 | |||
| 10 | func saveToken(_ token: String, for accountID: String) throws { | ||
| 11 | state.withLock { $0.tokens[accountID] = token } | ||
| 12 | } | ||
| 13 | |||
| 14 | func token(for accountID: String) -> String? { | ||
| 15 | state.withLock { $0.tokens[accountID] } | ||
| 16 | } | ||
| 17 | |||
| 18 | func deleteToken(for accountID: String) { | ||
| 19 | state.withLock { _ = $0.tokens.removeValue(forKey: accountID) } | ||
| 20 | } | ||
| 21 | |||
| 22 | func saveAccounts(_ accounts: [Account]) throws { | ||
| 23 | state.withLock { $0.accounts = accounts } | ||
| 24 | } | ||
| 25 | |||
| 26 | func loadAccounts() -> [Account] { | ||
| 27 | state.withLock { $0.accounts } | ||
| 28 | } | ||
| 29 | |||
| 30 | var tokenCount: Int { | ||
| 31 | state.withLock { $0.tokens.count } | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | private let whoamiCMC = """ | ||
| 36 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | ||
| 37 | """ | ||
| 38 | private let badToken = """ | ||
| 39 | {"protocol_version":1,"error":"invalid or expired token"} | ||
| 40 | """ | ||
| 41 | |||
| 42 | @MainActor | ||
| 43 | private func makeSession( | ||
| 44 | store: MemoryTokenStore = MemoryTokenStore() | ||
| 45 | ) -> (SessionStore, MemoryTokenStore, StubProtocol.Box) { | ||
| 46 | let box = StubProtocol.box() | ||
| 47 | let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! | ||
| 48 | let session = SessionStore(store: store, defaults: defaults) { instance, token in | ||
| 49 | GitbayClient(instance: instance, token: token, session: box.session()) | ||
| 50 | } | ||
| 51 | return (session, store, box) | ||
| 52 | } | ||
| 53 | |||
| 54 | @MainActor | ||
| 55 | struct SessionStoreTests { | ||
| 56 | |||
| 57 | @Test func signInValidatesWithWhoamiThenStores() async throws { | ||
| 58 | let (session, store, stub) = makeSession() | ||
| 59 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 60 | |||
| 61 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") | ||
| 62 | |||
| 63 | #expect(session.current?.username == "cmc") | ||
| 64 | #expect(session.client != nil) | ||
| 65 | #expect(store.loadAccounts().count == 1) | ||
| 66 | let account = try #require(session.current) | ||
| 67 | #expect(store.token(for: account.id) == "gb_secret") | ||
| 68 | let seen = try #require(stub.seen.first) | ||
| 69 | #expect(seen.url.query() == "argv=whoami") | ||
| 70 | } | ||
| 71 | |||
| 72 | @Test func rejectedTokenStoresNothing() async throws { | ||
| 73 | let (session, store, stub) = makeSession() | ||
| 74 | stub.enqueue(.init(status: 401, json: badToken)) | ||
| 75 | |||
| 76 | await #expect(throws: GitbayError.self) { | ||
| 77 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong") | ||
| 78 | } | ||
| 79 | |||
| 80 | #expect(session.current == nil) | ||
| 81 | #expect(store.loadAccounts().isEmpty) | ||
| 82 | #expect(store.tokenCount == 0) | ||
| 83 | } | ||
| 84 | |||
| 85 | @Test func whitespaceAroundThePastedTokenIsTrimmed() async throws { | ||
| 86 | let (session, store, stub) = makeSession() | ||
| 87 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 88 | |||
| 89 | try await session.signIn(instanceURL: "gitbay.org", token: " gb_secret\n") | ||
| 90 | |||
| 91 | let account = try #require(session.current) | ||
| 92 | #expect(store.token(for: account.id) == "gb_secret") | ||
| 93 | let seen = try #require(stub.seen.first) | ||
| 94 | #expect(seen.headers["Authorization"] == "Bearer gb_secret") | ||
| 95 | } | ||
| 96 | |||
| 97 | @Test func signingInTwiceOnTheSameAccountDoesNotDuplicateIt() async throws { | ||
| 98 | let (session, store, stub) = makeSession() | ||
| 99 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 100 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 101 | |||
| 102 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_old") | ||
| 103 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_new") | ||
| 104 | |||
| 105 | #expect(store.loadAccounts().count == 1) | ||
| 106 | let account = try #require(session.current) | ||
| 107 | #expect(store.token(for: account.id) == "gb_new") | ||
| 108 | } | ||
| 109 | |||
| 110 | @Test func accountsOnTwoInstancesCoexist() async throws { | ||
| 111 | let (session, store, stub) = makeSession() | ||
| 112 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 113 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 114 | |||
| 115 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_one") | ||
| 116 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") | ||
| 117 | |||
| 118 | #expect(session.accounts.count == 2) | ||
| 119 | #expect(session.current?.instance.baseURL.host() == "forge.example") | ||
| 120 | let first = try #require(session.accounts.first) | ||
| 121 | #expect(session.activate(first)) | ||
| 122 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") | ||
| 123 | } | ||
| 124 | |||
| 125 | @Test func removingTheActiveAccountFallsBackToAnother() async throws { | ||
| 126 | let (session, _, stub) = makeSession() | ||
| 127 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 128 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 129 | |||
| 130 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_one") | ||
| 131 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") | ||
| 132 | let active = try #require(session.current) | ||
| 133 | |||
| 134 | session.remove(active) | ||
| 135 | |||
| 136 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") | ||
| 137 | #expect(session.accounts.count == 1) | ||
| 138 | } | ||
| 139 | |||
| 140 | @Test func sessionRestoresFromTheStoreAcrossLaunches() async throws { | ||
| 141 | let store = MemoryTokenStore() | ||
| 142 | let (first, _, stub) = makeSession(store: store) | ||
| 143 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 144 | try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret") | ||
| 145 | |||
| 146 | // A second SessionStore over the same TokenStore is a relaunch. | ||
| 147 | let (second, _, _) = makeSession(store: store) | ||
| 148 | |||
| 149 | #expect(second.current?.username == "cmc") | ||
| 150 | #expect(second.client != nil) | ||
| 151 | } | ||
| 152 | |||
| 153 | @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws { | ||
| 154 | let (session, store, stub) = makeSession() | ||
| 155 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 156 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") | ||
| 157 | let account = try #require(session.current) | ||
| 158 | |||
| 159 | // Some later request comes back 401. | ||
| 160 | session.handle(GitbayError.unauthorized("invalid or expired token")) | ||
| 161 | |||
| 162 | #expect(session.current == nil) | ||
| 163 | #expect(session.client == nil) | ||
| 164 | #expect(store.tokenCount == 0) | ||
| 165 | #expect(store.loadAccounts().isEmpty) | ||
| 166 | let message = try #require(session.signedOutMessage) | ||
| 167 | #expect(message.contains(account.username)) | ||
| 168 | #expect(message.contains("no longer valid")) | ||
| 169 | } | ||
| 170 | |||
| 171 | @Test func nonAuthErrorsDoNotSignAnyoneOut() async throws { | ||
| 172 | let (session, _, stub) = makeSession() | ||
| 173 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 174 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") | ||
| 175 | |||
| 176 | session.handle(GitbayError.notFound("no such repo")) | ||
| 177 | session.handle(GitbayError.failure("boom")) | ||
| 178 | session.handle(URLError(.timedOut)) | ||
| 179 | |||
| 180 | #expect(session.current != nil) | ||
| 181 | #expect(session.signedOutMessage == nil) | ||
| 182 | } | ||
| 183 | |||
| 184 | @Test func restoringWithAMissingTokenDropsTheAccount() async throws { | ||
| 185 | let store = MemoryTokenStore() | ||
| 186 | let (first, _, stub) = makeSession(store: store) | ||
| 187 | stub.enqueue(.init(status: 200, json: whoamiCMC)) | ||
| 188 | try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret") | ||
| 189 | let account = try #require(first.current) | ||
| 190 | store.deleteToken(for: account.id) | ||
| 191 | |||
| 192 | let (second, _, _) = makeSession(store: store) | ||
| 193 | |||
| 194 | #expect(second.current == nil) | ||
| 195 | #expect(second.client == nil) | ||
| 196 | } | ||
| 197 | } | ||