gitbayTests/AdminTests.swift

v1.10.0
gitbay-ios/gitbayTests/AdminTests.swift history · blame · raw

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}