Commit e55d2e00ca

e55d2e00ca345d84a0fd600eea27dcae42ceecc0

parent: 98784709d7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 08:43 UTC

Admin accounts: no crash on a large cap; deletes and limits say what happened

A storage cap too large for bytes is refused instead of overflowing.
A delete whose retry finds the account gone is reported done, and a
failed limits read says why where the limits would be. New accounts'
addresses start unverified, the delete footer names what blocks it, and
a pending self-deletion shows as a date.

Ref #36

Layout: unified · split

gitbay/Admin/AdminAccount.swift +31 −5
@@ -114,7 +114,9 @@ nonisolated struct OwnerLimits: Decodable, Sendable, Hashable {
114114 return true
115115 }
116116 guard let n = Int64(text), n >= 0 else { return false }
117 flags += [flag, String(n * scale)]
117 let (scaled, overflow) = n.multipliedReportingOverflow(by: scale)
118 guard !overflow else { return false }
119 flags += [flag, String(scaled)]
118120 return true
119121 }
120122 guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil }
@@ -132,6 +134,8 @@ final class AdminAccountViewModel {
132134 nonisolated struct Loaded: Sendable, Hashable {
133135 let detail: AdminAccountDetail
134136 let limits: OwnerLimits?
137 /// Why the caps could not be read, when they could not.
138 let limitsError: String?
135139 }
136140
137141 private(set) var state: LoadState<Loaded> = .loading
@@ -150,14 +154,26 @@ final class AdminAccountViewModel {
150154 do {
151155 async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self)
152156 // Not a read-only command (it also sets caps), so it goes as a
153 // command even to read; with no flags it changes nothing.
154 async let limits = try? client.run(["admin", "user", "limits", username], as: OwnerLimits.self)
155 state = .loaded(Loaded(detail: try await detail, limits: await limits))
157 // command even to read; with no flags it changes nothing. A
158 // failed read says why under the account, not as its absence.
159 async let limits = readLimits()
160 let (caps, why) = await limits
161 state = .loaded(Loaded(detail: try await detail, limits: caps, limitsError: why))
156162 } catch {
157163 state = .from(error)
158164 }
159165 }
160166
167 private func readLimits() async -> (OwnerLimits?, String?) {
168 do {
169 return (try await client.run(["admin", "user", "limits", username], as: OwnerLimits.self), nil)
170 } catch let error as GitbayError {
171 return (nil, error.userFacingMessage)
172 } catch {
173 return (nil, GitbayError.transport(error).userFacingMessage)
174 }
175 }
176
161177 /// Returns whether the caps changed; a field that is not a number or
162178 /// `default` is refused here, before the server is asked.
163179 func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool {
@@ -172,15 +188,25 @@ final class AdminAccountViewModel {
172188 /// `admin user delete <username> --yes`, for an account that anchors
173189 /// nothing; the server's refusal names what it still holds.
174190 func delete() async -> Bool {
175 await perform(["admin", "user", "delete", username, "--yes"], reload: false)
191 if await perform(["admin", "user", "delete", username, "--yes"], reload: false) { return true }
192 // A failure is retried once; a delete that went through and then
193 // failed answers the retry with not found, and the account is gone.
194 return notFoundOnRetry
176195 }
177196
197 private var notFoundOnRetry = false
198
178199 private func perform(_ argv: [String], reload: Bool = true) async -> Bool {
179200 working = true
180201 actionError = nil
181202 defer { working = false }
203 notFoundOnRetry = false
182204 do {
183205 try await client.run(argv)
206 } catch GitbayError.notFound(let message) where argv.contains("delete") {
207 notFoundOnRetry = true
208 actionError = message
209 return false
184210 } catch let error as GitbayError {
185211 actionError = error.messageForTypedInput
186212 return false
gitbay/Views/Admin/AdminAccountView.swift +8 −3
@@ -26,7 +26,11 @@ struct AdminAccountView: View {
2626 }
2727 } else if let loaded = model.state.value {
2828 header(loaded.detail)
29 if let limits = loaded.limits { limitsSection(limits) }
29 if let limits = loaded.limits {
30 limitsSection(limits)
31 } else if let why = loaded.limitsError {
32 Section("Limits") { GBNotice(why, .gbWarn) }
33 }
3034 keysSection(loaded.detail)
3135 emailsSection(loaded.detail)
3236 if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) }
@@ -42,7 +46,7 @@ struct AdminAccountView: View {
4246 .disabled(model.working)
4347 .accessibilityIdentifier("admin-account-delete")
4448 } footer: {
45 Text("Only an account that anchors nothing: no repositories, organizations or authored threads. The refusal names what it still holds.")
49 Text("Only an account that anchors nothing: no repositories, no organization it is the only admin of, and no issues, comments or reviews. The refusal names what it still holds.")
4650 }
4751 }
4852 }
@@ -94,7 +98,8 @@ struct AdminAccountView: View {
9498 LabeledContent("Repositories", value: String(d.repos))
9599 LabeledContent("Web sessions", value: String(d.webSessions))
96100 if let after = d.deleteAfter {
97 LabeledContent("Deletes after", value: after)
101 LabeledContent("Deletes after", value: (try? Date(after, strategy: .iso8601))
102 .map { $0.formatted(date: .abbreviated, time: .shortened) } ?? after)
98103 .foregroundStyle(Color.gbBad)
99104 }
100105 }
gitbay/Views/Admin/AdminUsersView.swift +2 −1
@@ -183,7 +183,8 @@ private struct CreateAccountSheet: View {
183183
184184 @State private var username = ""
185185 @State private var email = ""
186 @State private var verified = true
186 /// Off: a mistyped address marked verified would take sign-in links.
187 @State private var verified = false
187188 @State private var admin = false
188189 @State private var key = ""
189190 @State private var sent = false
gitbayTests/AdminAccountTests.swift +23
@@ -67,6 +67,8 @@ struct AdminAccountTests {
6767 #expect(OwnerLimits.flags(repos: "x", megabytes: "", orgs: nil) == nil)
6868 #expect(OwnerLimits.flags(repos: "-1", megabytes: "", orgs: nil) == nil)
6969 #expect(OwnerLimits.flags(repos: "", megabytes: "", orgs: "") == [])
70 // A cap too large to hold in bytes is refused, not a crash.
71 #expect(OwnerLimits.flags(repos: "", megabytes: "10000000000000", orgs: nil) == nil)
7072 }
7173
7274 @Test func settingLimitsSendsTheChangedCaps() async throws {
@@ -141,4 +143,25 @@ struct AdminCreateInviteTests {
141143 #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]])
142144 #expect(note?.contains("abc123") == true)
143145 }
146
147 @Test func aLimitsReadThatFailsSaysWhy() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
150 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"a read-scoped token cannot run that","exit_code":4}"#, match: "cmd"))
151 let model = AdminAccountViewModel(client: client, username: "other")
152
153 await model.load()
154
155 #expect(model.state.value?.limits == nil)
156 #expect(model.state.value?.limitsError == "a read-scoped token cannot run that")
157 }
158
159 @Test func aDeleteWhoseRetryFindsNothingIsDone() async throws {
160 let (client, stub) = try makeClient()
161 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"cleanup failed","exit_code":1}"#, match: "cmd"))
162 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no user \"other\"","exit_code":3}"#, match: "cmd"))
163 let model = AdminAccountViewModel(client: client, username: "other")
164
165 #expect(await model.delete())
166 }
144167}