import Foundation import Testing @testable import gitbay /// In-memory TokenStore so no test touches the host Keychain. nonisolated final class MemoryTokenStore: TokenStore, @unchecked Sendable { private let state = Mutex<(tokens: [String: String], accounts: [Account])>((tokens: [:], accounts: [])) func saveToken(_ token: String, for accountID: String) throws { state.withLock { $0.tokens[accountID] = token } } func token(for accountID: String) -> String? { state.withLock { $0.tokens[accountID] } } func deleteToken(for accountID: String) { state.withLock { _ = $0.tokens.removeValue(forKey: accountID) } } func saveAccounts(_ accounts: [Account]) throws { state.withLock { $0.accounts = accounts } } func loadAccounts() -> [Account] { state.withLock { $0.accounts } } var tokenCount: Int { state.withLock { $0.tokens.count } } } private let whoamiCMC = """ {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} """ private let badToken = """ {"protocol_version":1,"error":"invalid or expired token"} """ @MainActor private func makeSession( store: MemoryTokenStore = MemoryTokenStore() ) -> (SessionStore, MemoryTokenStore, StubProtocol.Box) { let box = StubProtocol.box() let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! let session = SessionStore(store: store, defaults: defaults) { instance, token in GitbayClient(instance: instance, token: token, session: box.session()) } return (session, store, box) } @MainActor struct SessionStoreTests { @Test func signInValidatesWithWhoamiThenStores() async throws { let (session, store, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") #expect(session.current?.username == "cmc") #expect(session.client != nil) #expect(store.loadAccounts().count == 1) let account = try #require(session.current) #expect(store.token(for: account.id) == "gb_secret") let seen = try #require(stub.seen.first) #expect(seen.url.query() == "argv=whoami") } @Test func rejectedTokenStoresNothing() async throws { let (session, store, stub) = makeSession() stub.enqueue(.init(status: 401, json: badToken)) await #expect(throws: GitbayError.self) { try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong") } #expect(session.current == nil) #expect(store.loadAccounts().isEmpty) #expect(store.tokenCount == 0) } @Test func whitespaceAroundThePastedTokenIsTrimmed() async throws { let (session, store, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: " gb_secret\n") let account = try #require(session.current) #expect(store.token(for: account.id) == "gb_secret") let seen = try #require(stub.seen.first) #expect(seen.headers["Authorization"] == "Bearer gb_secret") } @Test func signingInTwiceOnTheSameAccountDoesNotDuplicateIt() async throws { let (session, store, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: "gb_old") try await session.signIn(instanceURL: "gitbay.org", token: "gb_new") #expect(store.loadAccounts().count == 1) let account = try #require(session.current) #expect(store.token(for: account.id) == "gb_new") } @Test func accountsOnTwoInstancesCoexist() async throws { let (session, store, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: "gb_one") try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") #expect(session.accounts.count == 2) #expect(session.current?.instance.baseURL.host() == "forge.example") let first = try #require(session.accounts.first) #expect(session.activate(first)) #expect(session.current?.instance.baseURL.host() == "gitbay.org") } @Test func removingTheActiveAccountFallsBackToAnother() async throws { let (session, _, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: "gb_one") try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") let active = try #require(session.current) session.remove(active) #expect(session.current?.instance.baseURL.host() == "gitbay.org") #expect(session.accounts.count == 1) } @Test func sessionRestoresFromTheStoreAcrossLaunches() async throws { let store = MemoryTokenStore() let (first, _, stub) = makeSession(store: store) stub.enqueue(.init(status: 200, json: whoamiCMC)) try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret") // A second SessionStore over the same TokenStore is a relaunch. let (second, _, _) = makeSession(store: store) #expect(second.current?.username == "cmc") #expect(second.client != nil) } @Test func aRevokedTokenOnAnyLaterRequestSignsOutCleanly() async throws { let (session, store, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") let account = try #require(session.current) let client = try #require(session.client) stub.enqueue(.init(status: 401, json: badToken)) // Some screen's read, long after sign-in, meets the revocation. await #expect(throws: GitbayError.self) { _ = try await client.readList(["repo", "list"], of: RepoSummary.self) } // The sign-out hops through the main actor; give it a beat. try await Task.sleep(for: .milliseconds(200)) #expect(session.current == nil) #expect(session.client == nil) #expect(store.tokenCount == 0) let message = try #require(session.signedOutMessage) #expect(message.contains(account.username)) } @Test func aRejectedSignInDoesNotTripTheRevocationWatcher() async throws { let (session, _, stub) = makeSession() stub.enqueue(.init(status: 401, json: badToken)) await #expect(throws: GitbayError.self) { try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong") } try await Task.sleep(for: .milliseconds(200)) // A bad pasted token is a sign-in error, not a revocation notice. #expect(session.signedOutMessage == nil) } @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws { let (session, store, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") let account = try #require(session.current) // Some later request comes back 401. session.handle(GitbayError.unauthorized("invalid or expired token")) #expect(session.current == nil) #expect(session.client == nil) #expect(store.tokenCount == 0) #expect(store.loadAccounts().isEmpty) let message = try #require(session.signedOutMessage) #expect(message.contains(account.username)) #expect(message.contains("no longer valid")) } @Test func nonAuthErrorsDoNotSignAnyoneOut() async throws { let (session, _, stub) = makeSession() stub.enqueue(.init(status: 200, json: whoamiCMC)) try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") session.handle(GitbayError.notFound("no such repo")) session.handle(GitbayError.failure("boom")) session.handle(URLError(.timedOut)) #expect(session.current != nil) #expect(session.signedOutMessage == nil) } @Test func restoringWithAMissingTokenDropsTheAccount() async throws { let store = MemoryTokenStore() let (first, _, stub) = makeSession(store: store) stub.enqueue(.init(status: 200, json: whoamiCMC)) try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret") let account = try #require(first.current) store.deleteToken(for: account.id) let (second, _, _) = makeSession(store: store) #expect(second.current == nil) #expect(second.client == nil) } }