Commit 80e6955a77
Verified · cmc
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 | 1 | import SwiftUI |
| 9 | 2 | |
| 10 | 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 | 25 | var body: some View { |
| 12 | VStack { | |
| 13 | Image(systemName: "globe") | |
| 14 | .imageScale(.large) | |
| 15 | .foregroundStyle(.tint) | |
| 16 | Text("Hello, world!") | |
| 26 | List { | |
| 27 | Section("Signed in") { | |
| 28 | LabeledContent("Account", value: account.username) | |
| 29 | LabeledContent( | |
| 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 | 55 | #Preview { |
| 23 | 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 | 1 | import SwiftUI |
| 9 | 2 | |
| 10 | 3 | @main |
| 11 | 4 | struct gitbayApp: App { |
| 5 | ||
| 6 | @State private var session = SessionStore() | |
| 7 | ||
| 12 | 8 | var body: some Scene { |
| 13 | 9 | WindowGroup { |
| 14 | 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 | } | |