gitbayTests/SessionStoreTests.swift
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}