gitbayTests/SessionStoreTests.swift

v1.6.1
gitbay-ios/gitbayTests/SessionStoreTests.swift history · blame · raw

233 lines · 9045 bytes

  1import Foundation
  2import Testing
  3@testable import gitbay
  4
  5/// In-memory TokenStore so no test touches the host Keychain.
  6nonisolated 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
 35private let whoamiCMC = """
 36    {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
 37    """
 38private let badToken = """
 39    {"protocol_version":1,"error":"invalid or expired token"}
 40    """
 41
 42@MainActor
 43private 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
 55struct 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        await 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 aRevokedTokenOnAnyLaterRequestSignsOutCleanly() 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        let client = try #require(session.client)
159        stub.enqueue(.init(status: 401, json: badToken))
160
161        // Some screen's read, long after sign-in, meets the revocation.
162        await #expect(throws: GitbayError.self) {
163            _ = try await client.readList(["repo", "list"], of: RepoSummary.self)
164        }
165        // The sign-out hops through the main actor; wait for it rather
166        // than for a fixed interval to pass.
167        await until { session.current == nil }
168
169        #expect(session.current == nil)
170        #expect(session.client == nil)
171        #expect(store.tokenCount == 0)
172        let message = try #require(session.signedOutMessage)
173        #expect(message.contains(account.username))
174    }
175
176    @Test func aRejectedSignInDoesNotTripTheRevocationWatcher() async throws {
177        let (session, _, stub) = makeSession()
178        stub.enqueue(.init(status: 401, json: badToken))
179
180        await #expect(throws: GitbayError.self) {
181            try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong")
182        }
183        try await Task.sleep(for: .milliseconds(200))
184
185        // A bad pasted token is a sign-in error, not a revocation notice.
186        #expect(session.signedOutMessage == nil)
187    }
188
189    @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws {
190        let (session, store, stub) = makeSession()
191        stub.enqueue(.init(status: 200, json: whoamiCMC))
192        try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
193        let account = try #require(session.current)
194
195        // Some later request comes back 401.
196        session.handle(GitbayError.unauthorized("invalid or expired token"))
197
198        #expect(session.current == nil)
199        #expect(session.client == nil)
200        #expect(store.tokenCount == 0)
201        #expect(store.loadAccounts().isEmpty)
202        let message = try #require(session.signedOutMessage)
203        #expect(message.contains(account.username))
204        #expect(message.contains("no longer valid"))
205    }
206
207    @Test func nonAuthErrorsDoNotSignAnyoneOut() async throws {
208        let (session, _, stub) = makeSession()
209        stub.enqueue(.init(status: 200, json: whoamiCMC))
210        try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
211
212        session.handle(GitbayError.notFound("no such repo"))
213        session.handle(GitbayError.failure("boom"))
214        session.handle(URLError(.timedOut))
215
216        #expect(session.current != nil)
217        #expect(session.signedOutMessage == nil)
218    }
219
220    @Test func restoringWithAMissingTokenDropsTheAccount() async throws {
221        let store = MemoryTokenStore()
222        let (first, _, stub) = makeSession(store: store)
223        stub.enqueue(.init(status: 200, json: whoamiCMC))
224        try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret")
225        let account = try #require(first.current)
226        store.deleteToken(for: account.id)
227
228        let (second, _, _) = makeSession(store: store)
229
230        #expect(second.current == nil)
231        #expect(second.client == nil)
232    }
233}