import Foundation import Testing @testable import gitbay private let whoami = """ {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} """ @MainActor private func makeSession() -> (SessionStore, StubProtocol.Box) { let box = StubProtocol.box() let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")! let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in GitbayClient(instance: instance, token: token, session: box.session()) } return (session, box) } /// Signs two accounts in, on different instances. @MainActor private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { let (session, box) = makeSession() box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://dev.local", token: "t2") return (session, box) } @Test @MainActor func sessionVendsAClientForAnyStoredAccount() async throws { let (session, _) = try await twoAccounts() let inactive = try #require(session.accounts.first { $0.id != session.current?.id }) #expect(session.client(for: inactive) != nil) // Vending does not change which account is active. #expect(session.current?.id != inactive.id) } @Test @MainActor func sessionVendsNothingForAnUnknownAccount() async throws { let (session, _) = try await twoAccounts() let stranger = Account(instance: try GitbayInstance(url: "https://nowhere.example"), username: "x") #expect(session.client(for: stranger) == nil) } // The hook runs before the token is discarded: deregistering a device // needs the credential remove() is about to delete. @Test @MainActor func removeAwaitsTheHookBeforeDiscardingTheToken() async throws { let (session, _) = try await twoAccounts() let victim = try #require(session.accounts.first) var sawTokenDuringHook: Bool? // [weak session] because this closure is stored ON session; the // production wiring in gitbayApp captures the registrar weakly for // the same reason. session.willRemoveAccount = { [weak session] account in sawTokenDuringHook = session?.client(for: account) != nil } await session.remove(victim) #expect(sawTokenDuringHook == true) #expect(session.client(for: victim) == nil) } // The counterpart to the removal hook: an account added while the app // runs has no push device row until something registers it, and // nothing else does before the next cold launch. @Test @MainActor func signInRunsTheAddHook() async throws { let (session, box) = makeSession() var added: [String] = [] session.didAddAccount = { added.append($0.id) } box.enqueue(.init(status: 200, json: whoami)) try await session.signIn(instanceURL: "https://gitbay.org", token: "t1") #expect(added == [try #require(session.current).id]) } // A sign-in that stored nothing must not announce an account. @Test @MainActor func aFailedSignInDoesNotRunTheAddHook() async throws { let (session, box) = makeSession() var hookRan = false session.didAddAccount = { _ in hookRan = true } box.enqueue(.init(status: 401, json: #"{"protocol_version":1,"error":"nope","exit_code":4}"#)) await #expect(throws: (any Error).self) { try await session.signIn(instanceURL: "https://gitbay.org", token: "bad") } #expect(hookRan == false) #expect(session.accounts.isEmpty) } // expire is reached only after the server has already rejected the // account's token with a 401 (revocation, or handle's // requiresReauthentication path), so a deregistration call carrying // that token would fail the same way. It deliberately does not run // the hook; the stale device row is cleared from the web settings // page instead. See the comment on expire(_:) in SessionStore. @Test @MainActor func expireDoesNotRunTheRemovalHook() async throws { let (session, _) = try await twoAccounts() let account = try #require(session.current) var hookRan = false session.willRemoveAccount = { _ in hookRan = true } session.handle(GitbayError.unauthorized("invalid or expired token")) #expect(hookRan == false) #expect(session.accounts.contains(account) == false) }