gitbayTests/AdminTests.swift
324 lines · 14099 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15/// `dashboard` as an admin sees it, trimmed to the admin blocks, with
16/// one row in every queue that lists rows.
17private let adminDashboardJSON = """
18 {"protocol_version":1,"data":{"unread":0,\
19 "server":{"commit":"db7503f06f11"},\
20 "queues":{\
21 "webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\
22 {"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\
23 "last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\
24 "mail":{"pending":0,"retrying":1,"failed":0,"items":[\
25 {"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\
26 "last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\
27 "mirrors":{"dirty":3,"errors":1,"items":[\
28 {"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\
29 "last_error":"auth failed"}]},\
30 "builds":{"pending":1,"running":1,"items":[\
31 {"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\
32 "created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\
33 {"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\
34 "created_at":"2026-09-22T04:42:00Z","started_at":""}]},\
35 "deps":{"errors":0,"items":[]},\
36 "push":{"pending":0,"retrying":0,"failed":1,"items":[\
37 {"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\
38 "last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\
39 }},"exit_code":0}
40 """
41
42nonisolated private struct DashboardEnvelope: Decodable { let data: AdminDashboard }
43
44private func sections() throws -> [QueueSection] {
45 let dashboard = try GitbayClient.decoder()
46 .decode(DashboardEnvelope.self, from: Data(adminDashboardJSON.utf8)).data
47 let queues = try #require(dashboard.queues)
48 // A fixed stand-in for relative time keeps the text deterministic.
49 return queues.sections(when: { "at \($0)" })
50}
51
52struct AdminQueueSectionTests {
53
54 @Test func sixSectionsInTheWebsOrder() throws {
55 let all = try sections()
56 #expect(all.map(\.heading) == [
57 "Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks",
58 ])
59 #expect(all.map(\.count) == [2, 0, 0, 1, 1, 0])
60 }
61
62 @Test func countsLinesUseTheWebsWords() throws {
63 let all = try sections()
64 #expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z")
65 #expect(all[1].summary == "0 pending · 1 retrying · 0 failed")
66 #expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync")
67 #expect(all[4].summary == "1 pending · 1 running")
68 #expect(all[5].summary == nil)
69 }
70
71 @Test func rowsSayWhatTheWebsTableColumnsSay() throws {
72 let all = try sections()
73 #expect(all[0].rows.map(\.title) == ["krz/gitbay"])
74 #expect(all[0].rows[0].detail
75 == "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway")
76 #expect(all[1].rows[0].title == "a@example.com")
77 #expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout")
78 #expect(all[2].rows[0].title == "device 3")
79 #expect(all[2].rows[0].detail
80 == "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken")
81 #expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed")
82 // A pending build has started_at "" and reports since it was queued.
83 #expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"])
84 #expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z")
85 #expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z")
86 }
87
88 @Test func anEmptyQueueSaysSoInTheWebsWords() throws {
89 let deps = try #require(try sections().last)
90 #expect(deps.rows.isEmpty)
91 #expect(deps.empty == "No check has failed")
92 }
93
94 /// Everyone else's dashboard omits both blocks.
95 @Test func aNonAdminDashboardHasNoQueues() throws {
96 let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"#
97 let dashboard = try GitbayClient.decoder().decode(DashboardEnvelope.self, from: Data(json.utf8)).data
98 #expect(dashboard.queues == nil)
99 #expect(dashboard.server == nil)
100 }
101
102 @Test func anUnparseableTimestampIsShownAsIs() {
103 #expect(AdminQueues.relative("soon") == "soon")
104 }
105}
106
107private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser {
108 let json = """
109 {"username":"\(name)","state":"\(state)","admin":\(admin),\
110 "created_at":"2026-08-28T21:55:00.752Z"}
111 """
112 return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8))
113}
114
115struct AdminUserActionTests {
116
117 @Test func anAdminIsDemotedAndCanBeDisabled() throws {
118 let u = try user("cmc", state: "active", admin: true)
119 #expect(u.roleAction == .demote)
120 #expect(u.canChangeRole)
121 #expect(u.accessAction == .disable)
122 }
123
124 @Test func anActiveAccountCanBePromoted() throws {
125 let u = try user("blotter-ci", state: "active")
126 #expect(u.roleAction == .promote)
127 #expect(u.canChangeRole)
128 }
129
130 /// The web draws Promote on every non-admin row but disables it for
131 /// an account that is not active.
132 @Test func onlyAnActiveAccountCanBePromoted() throws {
133 #expect(try user("new", state: "pending").canChangeRole == false)
134 #expect(try user("gone", state: "disabled").canChangeRole == false)
135 }
136
137 @Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws {
138 #expect(try user("gone", state: "disabled").accessAction == .enable)
139 #expect(try user("new", state: "pending").accessAction == .disable)
140 }
141
142 @Test func demoteAndDisableAskForTheTypedName() {
143 #expect(AdminAction.demote.needsTypedName)
144 #expect(AdminAction.disable.needsTypedName)
145 #expect(!AdminAction.promote.needsTypedName)
146 #expect(!AdminAction.enable.needsTypedName)
147 }
148
149 @Test func eachActionIsItsAdminUserCommand() {
150 #expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"])
151 #expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"])
152 #expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"])
153 #expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"])
154 }
155
156 @Test func theFiltersAreTheWebsFive() {
157 #expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"])
158 #expect(AdminUserFilter.all.flags().isEmpty)
159 #expect(AdminUserFilter.admin.flags() == ["--state", "admin"])
160 }
161}
162
163@MainActor
164struct AdminCheckTests {
165
166 @Test func whoamisAdminFlagDecides() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json:
169 #"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#))
170 #expect(await AdminCheck.isAdmin(client))
171 #expect(stub.seen.first?.url.query() == "argv=whoami")
172 }
173
174 @Test func aNonAdminIsNot() async throws {
175 let (client, stub) = try makeClient()
176 stub.enqueue(.init(status: 200, json:
177 #"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#))
178 #expect(await AdminCheck.isAdmin(client) == false)
179 }
180
181 /// The menu just leaves the entry out when the read fails.
182 @Test func aFailedReadIsNotAnAdmin() async throws {
183 let (client, stub) = try makeClient()
184 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
185 #expect(await AdminCheck.isAdmin(client) == false)
186 }
187}
188
189@MainActor
190struct AdminOverviewViewModelTests {
191
192 @Test func readsDashboardIntoSections() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 200, json: adminDashboardJSON))
195 let model = AdminOverviewViewModel(client: client)
196
197 await model.load()
198
199 let overview = try #require(model.state.value)
200 #expect(overview.commit == "db7503f06f11")
201 #expect(overview.sections.count == 6)
202 #expect(stub.seen.first?.url.query() == "argv=dashboard")
203 }
204
205 @Test func aDashboardWithoutQueuesIsAnEmptyState() async throws {
206 let (client, stub) = try makeClient()
207 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#))
208 let model = AdminOverviewViewModel(client: client)
209
210 await model.load()
211
212 guard case .empty(let message) = model.state else {
213 Issue.record("expected .empty, got \(model.state)")
214 return
215 }
216 #expect(message == "Only instance admins can see the worker queues.")
217 }
218}
219
220private let userPageJSON = """
221 {"protocol_version":1,"data":{"items":[\
222 {"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\
223 "last_seen":"2026-09-21T07:05:26.201Z"},\
224 {"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\
225 "next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0}
226 """
227
228@MainActor
229struct AdminUsersViewModelTests {
230
231 @Test func listsEveryAccountByDefault() async throws {
232 let (client, stub) = try makeClient()
233 stub.enqueue(.init(status: 200, json: userPageJSON))
234 let model = AdminUsersViewModel(client: client)
235
236 await model.load()
237
238 let users = try #require(model.list.state.value)
239 #expect(users.map(\.username) == ["apple-review", "bhargavkk"])
240 #expect(users[0].lastSeen != nil)
241 #expect(users[1].lastSeen == nil)
242 #expect(model.list.hasMore)
243 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
244 }
245
246 @Test func loadIfNeededOnlyReadsOnce() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: userPageJSON))
249 let model = AdminUsersViewModel(client: client)
250
251 await model.loadIfNeeded()
252 await model.loadIfNeeded()
253
254 #expect(stub.seen.count == 1)
255 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
256 }
257
258 @Test func aFilterReloadsWithItsState() async throws {
259 let (client, stub) = try makeClient()
260 stub.enqueue(.init(status: 200, json: userPageJSON))
261 let model = AdminUsersViewModel(client: client)
262 await model.load()
263
264 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
265 model.filter = .disabled
266 await until {
267 if case .empty = model.list.state { return true }
268 return false
269 }
270
271 #expect(stub.seen.last?.url.query()
272 == "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50")
273 guard case .empty(let message) = model.list.state else { return }
274 #expect(message == "No account matches")
275 }
276
277 @Test func anActionSendsItsCommandAndReloads() async throws {
278 let (client, stub) = try makeClient()
279 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd"))
280 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
281 let model = AdminUsersViewModel(client: client)
282
283 await model.perform(.disable, on: "bhargavkk")
284
285 let write = try #require(stub.seen.first { $0.method == "POST" })
286 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
287 #expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"])
288 #expect(model.actionError == nil)
289 #expect(model.list.state.value?.count == 2)
290 }
291
292 /// The last-admin refusal is a real server failure (exit 1, HTTP 500),
293 /// which the client retries once before surfacing it.
294 @Test func aRefusalIsShownVerbatim() async throws {
295 let (client, stub) = try makeClient()
296 let refusal = StubProtocol.Stub(status: 500, json:
297 #"{"protocol_version":1,"error":"that is the only instance admin; promote someone else first","exit_code":1}"#,
298 match: "cmd")
299 stub.enqueue(refusal)
300 stub.enqueue(refusal)
301 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
302 let model = AdminUsersViewModel(client: client)
303
304 await model.perform(.demote, on: "cmc")
305
306 #expect(model.actionError == "that is the only instance admin; promote someone else first")
307 #expect(model.list.state.value?.count == 2)
308 }
309
310 /// A refusal must still reload the list, so a stale row does not
311 /// linger after the account changed state meanwhile.
312 @Test func aRefusedActionStillReloadsTheList() async throws {
313 let (client, stub) = try makeClient()
314 stub.enqueue(.init(status: 403, json:
315 #"{"protocol_version":1,"error":"account changed state","exit_code":4}"#, match: "cmd"))
316 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
317 let model = AdminUsersViewModel(client: client)
318
319 await model.perform(.disable, on: "bhargavkk")
320
321 #expect(model.actionError == "account changed state")
322 #expect(stub.seen.contains { $0.url.query()?.contains("argv=list") == true })
323 }
324}