gitbayTests/GitbayClientTests.swift
406 lines · 15952 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5/// Recorded envelopes, verbatim from gitbay.org v1.0.0.
6private enum Recorded {
7 static let whoami = """
8 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
9 """
10 static let repoList = """
11 {"protocol_version":1,"data":[\
12 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
13 {"path":"krz/secrets","visibility":"private","archived":true}\
14 ],"exit_code":0}
15 """
16 static let emptyList = """
17 {"protocol_version":1,"exit_code":0}
18 """
19 static let notFound = """
20 {"protocol_version":1,"error":"no such issue 99 in krz/gitbay","exit_code":3}
21 """
22 static let denied = """
23 {"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}
24 """
25 static let usage = """
26 {"protocol_version":1,"error":"usage: repo tree <owner/name> [<path>] [--ref <ref>]","exit_code":2}
27 """
28 static let writeOnReadSurface = """
29 {"protocol_version":1,"error":"repo create changes state; POST it to /api/v1/cmd"}
30 """
31 static let badToken = """
32 {"protocol_version":1,"error":"invalid or expired token"}
33 """
34 static let diff = """
35 {"protocol_version":1,"output":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n","exit_code":0}
36 """
37}
38
39nonisolated private struct WhoamiPayload: Decodable, Sendable, Equatable {
40 let username: String
41 let admin: Bool
42}
43
44nonisolated private struct RepoPayload: Decodable, Sendable, Equatable {
45 let path: String
46 let visibility: String
47 let description: String?
48 let archived: Bool?
49}
50
51private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
52 let box = StubProtocol.box()
53 let client = GitbayClient(
54 instance: try GitbayInstance(url: "https://gitbay.org"),
55 token: "test-token",
56 session: box.session()
57 )
58 return (client, box)
59}
60
61struct GitbayClientReadTests {
62
63 @Test func readDecodesEnvelopeData() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 200, json: Recorded.whoami))
66
67 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
68
69 #expect(who == WhoamiPayload(username: "cmc", admin: false))
70 let seen = try #require(stub.seen.first)
71 #expect(seen.method == "GET")
72 #expect(seen.url.path() == "/api/v1/read")
73 #expect(seen.url.query() == "argv=whoami")
74 #expect(seen.headers["Authorization"] == "Bearer test-token")
75 #expect(seen.headers["If-None-Match"] == nil)
76 }
77
78 @Test func argvArrivesAsRepeatedQueryParameters() async throws {
79 let (client, stub) = try makeClient()
80 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
81
82 _ = try await client.readList(
83 ["repo", "tree", "krz/gitbay", "internal"], of: RepoPayload.self
84 )
85
86 let seen = try #require(stub.seen.first)
87 #expect(seen.url.query() == "argv=repo&argv=tree&argv=krz/gitbay&argv=internal")
88 }
89
90 @Test func secondReadRevalidatesAndA304ServesTheCachedBody() async throws {
91 let (client, stub) = try makeClient()
92 let etag = "\"12e3e2b8914d9abfceec233dac9a7454\""
93 stub.enqueue(.init(status: 200, headers: ["ETag": etag], json: Recorded.repoList))
94 stub.enqueue(.init(status: 304, headers: ["ETag": etag]))
95
96 let first = try await client.readList(["repo", "list"], of: RepoPayload.self)
97 let second = try await client.readList(["repo", "list"], of: RepoPayload.self)
98
99 #expect(first == second)
100 #expect(first.count == 2)
101 #expect(first[0].path == "krz/gitbay")
102 let requests = stub.seen
103 #expect(requests.count == 2)
104 let firstRequest = try #require(requests.first)
105 #expect(firstRequest.headers["If-None-Match"] == nil)
106 let secondRequest = try #require(requests.dropFirst().first)
107 #expect(secondRequest.headers["If-None-Match"] == etag)
108 }
109
110 @Test func changedBodyReplacesTheCachedValidator() async throws {
111 let (client, stub) = try makeClient()
112 stub.enqueue(.init(status: 200, headers: ["ETag": "\"aa\""], json: Recorded.repoList))
113 stub.enqueue(.init(status: 200, headers: ["ETag": "\"bb\""], json: Recorded.emptyList))
114 stub.enqueue(.init(status: 304))
115
116 _ = try await client.readList(["repo", "list"], of: RepoPayload.self)
117 let refreshed = try await client.readList(["repo", "list"], of: RepoPayload.self)
118 let revalidated = try await client.readList(["repo", "list"], of: RepoPayload.self)
119
120 #expect(refreshed.isEmpty)
121 #expect(revalidated.isEmpty)
122 let third = try #require(stub.seen.dropFirst(2).first)
123 #expect(third.headers["If-None-Match"] == "\"bb\"")
124 }
125
126 @Test func absentDataDecodesAsEmptyList() async throws {
127 let (client, stub) = try makeClient()
128 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
129
130 let repos = try await client.readList(["repo", "list"], of: RepoPayload.self)
131
132 #expect(repos.isEmpty)
133 }
134
135 @Test func rawTextCommandsComeBackAsOutput() async throws {
136 let (client, stub) = try makeClient()
137 stub.enqueue(.init(status: 200, json: Recorded.diff))
138
139 let diff = try await client.readText(["mr", "diff", "krz/gitbay", "7"])
140
141 #expect(diff.hasPrefix("diff --git a/main.go"))
142 }
143}
144
145struct GitbayClientWriteTests {
146
147 @Test func writePostsArgvAndStdinAsJSON() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
150
151 try await client.run(
152 ["issue", "comment", "krz/gitbay", "35"],
153 stdin: "looks right to me"
154 )
155
156 let seen = try #require(stub.seen.first)
157 #expect(seen.method == "POST")
158 #expect(seen.url.path() == "/api/v1/cmd")
159 let body = try #require(
160 try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
161 )
162 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "35"])
163 #expect(body["stdin"] as? String == "looks right to me")
164 }
165
166 @Test func stdinIsOmittedWhenAbsent() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
169
170 try await client.run(["mr", "merge", "krz/gitbay", "7"])
171
172 let seen = try #require(stub.seen.first)
173 let body = try #require(
174 try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
175 )
176 #expect(body["stdin"] == nil)
177 }
178}
179
180struct GitbayClientFailureTests {
181
182 @Test func exitThreeIsAnEmptyStateNotAnError() async throws {
183 let (client, stub) = try makeClient()
184 stub.enqueue(.init(status: 404, json: Recorded.notFound))
185
186 await #expect {
187 _ = try await client.read(["issue", "show", "krz/gitbay", "99"], as: WhoamiPayload.self)
188 } throws: { error in
189 guard let error = error as? GitbayError else { return false }
190 return error.isEmptyState
191 && error.userFacingMessage == "no such issue 99 in krz/gitbay"
192 }
193 }
194
195 @Test func exitFourSurfacesTheServersMessageVerbatim() async throws {
196 let (client, stub) = try makeClient()
197 stub.enqueue(.init(status: 403, json: Recorded.denied))
198
199 await #expect {
200 try await client.run(["issue", "comment", "krz/gitbay", "1"], stdin: "hi")
201 } throws: { error in
202 guard case .denied(let message)? = error as? GitbayError else { return false }
203 return message == "krz/gitbay is archived and read-only"
204 }
205 }
206
207 @Test func exitTwoNeverPutsArgvInTheUserFacingMessage() async throws {
208 let (client, stub) = try makeClient()
209 stub.enqueue(.init(status: 400, json: Recorded.usage))
210
211 await #expect {
212 _ = try await client.read(["repo", "tree"], as: WhoamiPayload.self)
213 } throws: { error in
214 guard case .usage? = error as? GitbayError else { return false }
215 let shown = (error as! GitbayError).userFacingMessage
216 return !shown.contains("repo tree") && !shown.contains("usage")
217 }
218 }
219
220 @Test func gateRejectionsWithoutExitCodesMapOffTheStatus() async throws {
221 let (client, stub) = try makeClient()
222 stub.enqueue(.init(status: 401, json: Recorded.badToken))
223
224 await #expect {
225 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
226 } throws: { error in
227 (error as? GitbayError)?.requiresReauthentication == true
228 }
229 }
230
231 @Test func writeSentToTheReadSurfaceIsAnAppBug() async throws {
232 let (client, stub) = try makeClient()
233 stub.enqueue(.init(status: 400, json: Recorded.writeOnReadSurface))
234
235 await #expect {
236 _ = try await client.read(["repo", "create", "krz/new"], as: WhoamiPayload.self)
237 } throws: { error in
238 guard case .notReadable? = error as? GitbayError else { return false }
239 return true
240 }
241 }
242
243 @Test func serverFailureRetriesOnceThenSurfaces() async throws {
244 let (client, stub) = try makeClient()
245 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
246 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
247
248 await #expect {
249 _ = try await client.read(["repo", "list"], as: WhoamiPayload.self)
250 } throws: { error in
251 guard case .failure(let message)? = error as? GitbayError else { return false }
252 return message == "boom"
253 }
254 #expect(stub.seen.count == 2)
255 }
256
257 @Test func serverFailureRecoversWhenTheRetrySucceeds() async throws {
258 let (client, stub) = try makeClient()
259 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
260 stub.enqueue(.init(status: 200, json: Recorded.whoami))
261
262 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
263
264 #expect(who.username == "cmc")
265 #expect(stub.seen.count == 2)
266 }
267
268 @Test func shortRetryAfterIsHonouredThenTheCallSucceeds() async throws {
269 let (client, stub) = try makeClient()
270 stub.enqueue(.init(
271 status: 429,
272 headers: ["Retry-After": "1"],
273 json: #"{"protocol_version":1,"error":"rate limited; retry in 1s"}"#
274 ))
275 stub.enqueue(.init(status: 200, json: Recorded.whoami))
276
277 let start = ContinuousClock.now
278 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
279
280 #expect(who.username == "cmc")
281 #expect(ContinuousClock.now - start >= .seconds(1))
282 #expect(stub.seen.count == 2)
283 }
284
285 @Test func longRetryAfterSurfacesInsteadOfHanging() async throws {
286 let (client, stub) = try makeClient()
287 stub.enqueue(.init(
288 status: 429,
289 headers: ["Retry-After": "30"],
290 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#
291 ))
292
293 await #expect {
294 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
295 } throws: { error in
296 guard case .rateLimited(let wait)? = error as? GitbayError else { return false }
297 return wait == 30
298 }
299 #expect(stub.seen.count == 1)
300 }
301
302 @Test func unknownProtocolVersionIsRefused() async throws {
303 let (client, stub) = try makeClient()
304 stub.enqueue(.init(status: 200, json: #"{"protocol_version":2,"data":{},"exit_code":0}"#))
305
306 await #expect {
307 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
308 } throws: { error in
309 guard case .protocolMismatch(2)? = error as? GitbayError else { return false }
310 return true
311 }
312 }
313}
314
315struct GitbayInstanceTests {
316
317 @Test func bareHostGetsHTTPS() throws {
318 let instance = try GitbayInstance(url: "gitbay.org")
319 #expect(instance.baseURL.absoluteString == "https://gitbay.org")
320 }
321
322 @Test func pastedPathAndQueryAreStripped() throws {
323 let instance = try GitbayInstance(url: "https://gitbay.org/krz/gitbay?tab=readme")
324 #expect(instance.baseURL.absoluteString == "https://gitbay.org")
325 }
326
327 @Test func plainHTTPIsRefusedExceptLoopback() throws {
328 #expect(throws: GitbayInstance.InvalidURL.self) {
329 _ = try GitbayInstance(url: "http://gitbay.org")
330 }
331 let local = try GitbayInstance(url: "http://localhost:3000")
332 #expect(local.baseURL.absoluteString == "http://localhost:3000")
333 }
334
335 @Test func readURLEncodesArgvInOrder() throws {
336 let instance = try GitbayInstance(url: "https://gitbay.org")
337 let url = instance.readURL(argv: ["repo", "cat", "krz/gitbay", "cmd/main.go"])
338 #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go")
339 }
340
341 @Test func tokenPageURLIsSettingsTokensOnTheInstance() throws {
342 let instance = try GitbayInstance(url: "gitbay.org/krz/solar?x=1")
343 #expect(instance.tokenPageURL.absoluteString == "https://gitbay.org/settings#tokens")
344 let local = try GitbayInstance(url: "http://localhost:3000")
345 #expect(local.tokenPageURL.absoluteString == "http://localhost:3000/settings#tokens")
346 }
347
348 @Test func ownHostCheckCoversSchemeHostAndPort() throws {
349 let instance = try GitbayInstance(url: "https://gitbay.org")
350 #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!))
351 #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!))
352 #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!))
353 #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!))
354 #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/")!))
355 }
356}
357
358struct ETagStoreTests {
359
360 @Test func keysAreScopedByAccount() {
361 let a = ETagStore.key(account: "aaaa", argv: ["repo", "list"])
362 let b = ETagStore.key(account: "bbbb", argv: ["repo", "list"])
363 #expect(a != b)
364 }
365
366 @Test func joiningCannotCollideAcrossArgvBoundaries() {
367 let a = ETagStore.key(account: "x", argv: ["repo list"])
368 let b = ETagStore.key(account: "x", argv: ["repo", "list"])
369 #expect(a != b)
370 }
371
372 @Test func evictionDropsTheLeastRecentlyUsedEntry() {
373 let store = ETagStore(capacity: 2)
374 store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
375 store.store(ETagEntry(etag: "b", payload: Data()), for: "b")
376 _ = store.entry(for: "a")
377 store.store(ETagEntry(etag: "c", payload: Data()), for: "c")
378
379 #expect(store.entry(for: "a") != nil)
380 #expect(store.entry(for: "b") == nil)
381 #expect(store.entry(for: "c") != nil)
382 }
383
384 @Test func clearEmptiesTheStore() {
385 let store = ETagStore()
386 store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
387 store.clear()
388 #expect(store.count == 0)
389 }
390}
391
392struct ExpiringCredentialRefusalTests {
393
394 @Test func theRefusalSaysWhatToDoInstead() {
395 let error = GitbayError.denied(
396 "keys add creates a credential, and the one this request came with expires; use a token or key without an expiry")
397 #expect(error.userFacingMessage.contains("token without an expiry"))
398 #expect(error.userFacingMessage.contains("Settings"))
399 #expect(!error.userFacingMessage.contains("keys add"))
400 }
401
402 @Test func otherDenialsPassThrough() {
403 #expect(GitbayError.denied("you are not a maintainer of krz/gitbay").userFacingMessage
404 == "you are not a maintainer of krz/gitbay")
405 }
406}