a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayTests/SessionStoreTests.swift

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

232 lines · 8997 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        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; give it a beat.
166        try await Task.sleep(for: .milliseconds(200))
167
168        #expect(session.current == nil)
169        #expect(session.client == nil)
170        #expect(store.tokenCount == 0)
171        let message = try #require(session.signedOutMessage)
172        #expect(message.contains(account.username))
173    }
174
175    @Test func aRejectedSignInDoesNotTripTheRevocationWatcher() async throws {
176        let (session, _, stub) = makeSession()
177        stub.enqueue(.init(status: 401, json: badToken))
178
179        await #expect(throws: GitbayError.self) {
180            try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong")
181        }
182        try await Task.sleep(for: .milliseconds(200))
183
184        // A bad pasted token is a sign-in error, not a revocation notice.
185        #expect(session.signedOutMessage == nil)
186    }
187
188    @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws {
189        let (session, store, stub) = makeSession()
190        stub.enqueue(.init(status: 200, json: whoamiCMC))
191        try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
192        let account = try #require(session.current)
193
194        // Some later request comes back 401.
195        session.handle(GitbayError.unauthorized("invalid or expired token"))
196
197        #expect(session.current == nil)
198        #expect(session.client == nil)
199        #expect(store.tokenCount == 0)
200        #expect(store.loadAccounts().isEmpty)
201        let message = try #require(session.signedOutMessage)
202        #expect(message.contains(account.username))
203        #expect(message.contains("no longer valid"))
204    }
205
206    @Test func nonAuthErrorsDoNotSignAnyoneOut() async throws {
207        let (session, _, stub) = makeSession()
208        stub.enqueue(.init(status: 200, json: whoamiCMC))
209        try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
210
211        session.handle(GitbayError.notFound("no such repo"))
212        session.handle(GitbayError.failure("boom"))
213        session.handle(URLError(.timedOut))
214
215        #expect(session.current != nil)
216        #expect(session.signedOutMessage == nil)
217    }
218
219    @Test func restoringWithAMissingTokenDropsTheAccount() async throws {
220        let store = MemoryTokenStore()
221        let (first, _, stub) = makeSession(store: store)
222        stub.enqueue(.init(status: 200, json: whoamiCMC))
223        try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret")
224        let account = try #require(first.current)
225        store.deleteToken(for: account.id)
226
227        let (second, _, _) = makeSession(store: store)
228
229        #expect(second.current == nil)
230        #expect(second.client == nil)
231    }
232}