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