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 {
114 return true 114 return true
115 } 115 }
116 guard let n = Int64(text), n >= 0 else { return false } 116 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)]
118 return true 120 return true
119 } 121 }
120 guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil } 122 guard add("--repos", repos), add("--bytes", megabytes, scale: 1_000_000) else { return nil }
@@ -132,6 +134,8 @@ final class AdminAccountViewModel {
132 nonisolated struct Loaded: Sendable, Hashable { 134 nonisolated struct Loaded: Sendable, Hashable {
133 let detail: AdminAccountDetail 135 let detail: AdminAccountDetail
134 let limits: OwnerLimits? 136 let limits: OwnerLimits?
137 /// Why the caps could not be read, when they could not.
138 let limitsError: String?
135 } 139 }
136 140
137 private(set) var state: LoadState<Loaded> = .loading 141 private(set) var state: LoadState<Loaded> = .loading
@@ -150,14 +154,26 @@ final class AdminAccountViewModel {
150 do { 154 do {
151 async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self) 155 async let detail = client.read(["admin", "user", "show", username], as: AdminAccountDetail.self)
152 // Not a read-only command (it also sets caps), so it goes as a 156 // 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. 157 // command even to read; with no flags it changes nothing. A
154 async let limits = try? client.run(["admin", "user", "limits", username], as: OwnerLimits.self) 158 // failed read says why under the account, not as its absence.
155 state = .loaded(Loaded(detail: try await detail, limits: await limits)) 159 async let limits = readLimits()
160 let (caps, why) = await limits
161 state = .loaded(Loaded(detail: try await detail, limits: caps, limitsError: why))
156 } catch { 162 } catch {
157 state = .from(error) 163 state = .from(error)
158 } 164 }
159 } 165 }
160 166
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
161 /// Returns whether the caps changed; a field that is not a number or 177 /// Returns whether the caps changed; a field that is not a number or
162 /// `default` is refused here, before the server is asked. 178 /// `default` is refused here, before the server is asked.
163 func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool { 179 func setLimits(repos: String, megabytes: String, orgs: String) async -> Bool {
@@ -172,15 +188,25 @@ final class AdminAccountViewModel {
172 /// `admin user delete <username> --yes`, for an account that anchors 188 /// `admin user delete <username> --yes`, for an account that anchors
173 /// nothing; the server's refusal names what it still holds. 189 /// nothing; the server's refusal names what it still holds.
174 func delete() async -> Bool { 190 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
176 } 195 }
177 196
197 private var notFoundOnRetry = false
198
178 private func perform(_ argv: [String], reload: Bool = true) async -> Bool { 199 private func perform(_ argv: [String], reload: Bool = true) async -> Bool {
179 working = true 200 working = true
180 actionError = nil 201 actionError = nil
181 defer { working = false } 202 defer { working = false }
203 notFoundOnRetry = false
182 do { 204 do {
183 try await client.run(argv) 205 try await client.run(argv)
206 } catch GitbayError.notFound(let message) where argv.contains("delete") {
207 notFoundOnRetry = true
208 actionError = message
209 return false
184 } catch let error as GitbayError { 210 } catch let error as GitbayError {
185 actionError = error.messageForTypedInput 211 actionError = error.messageForTypedInput
186 return false 212 return false
gitbay/Views/Admin/AdminAccountView.swift +8 −3
@@ -26,7 +26,11 @@ struct AdminAccountView: View {
26 } 26 }
27 } else if let loaded = model.state.value { 27 } else if let loaded = model.state.value {
28 header(loaded.detail) 28 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 }
30 keysSection(loaded.detail) 34 keysSection(loaded.detail)
31 emailsSection(loaded.detail) 35 emailsSection(loaded.detail)
32 if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) } 36 if !loaded.detail.orgs.isEmpty { orgsSection(loaded.detail) }
@@ -42,7 +46,7 @@ struct AdminAccountView: View {
42 .disabled(model.working) 46 .disabled(model.working)
43 .accessibilityIdentifier("admin-account-delete") 47 .accessibilityIdentifier("admin-account-delete")
44 } footer: { 48 } 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.")
46 } 50 }
47 } 51 }
48 } 52 }
@@ -94,7 +98,8 @@ struct AdminAccountView: View {
94 LabeledContent("Repositories", value: String(d.repos)) 98 LabeledContent("Repositories", value: String(d.repos))
95 LabeledContent("Web sessions", value: String(d.webSessions)) 99 LabeledContent("Web sessions", value: String(d.webSessions))
96 if let after = d.deleteAfter { 100 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)
98 .foregroundStyle(Color.gbBad) 103 .foregroundStyle(Color.gbBad)
99 } 104 }
100 } 105 }
gitbay/Views/Admin/AdminUsersView.swift +2 −1
@@ -183,7 +183,8 @@ private struct CreateAccountSheet: View {
183 183
184 @State private var username = "" 184 @State private var username = ""
185 @State private var email = "" 185 @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
187 @State private var admin = false 188 @State private var admin = false
188 @State private var key = "" 189 @State private var key = ""
189 @State private var sent = false 190 @State private var sent = false
gitbayTests/AdminAccountTests.swift +23
@@ -67,6 +67,8 @@ struct AdminAccountTests {
67 #expect(OwnerLimits.flags(repos: "x", megabytes: "", orgs: nil) == nil) 67 #expect(OwnerLimits.flags(repos: "x", megabytes: "", orgs: nil) == nil)
68 #expect(OwnerLimits.flags(repos: "-1", megabytes: "", orgs: nil) == nil) 68 #expect(OwnerLimits.flags(repos: "-1", megabytes: "", orgs: nil) == nil)
69 #expect(OwnerLimits.flags(repos: "", megabytes: "", orgs: "") == []) 69 #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)
70 } 72 }
71 73
72 @Test func settingLimitsSendsTheChangedCaps() async throws { 74 @Test func settingLimitsSendsTheChangedCaps() async throws {
@@ -141,4 +143,25 @@ struct AdminCreateInviteTests {
141 #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]]) 143 #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]])
142 #expect(note?.contains("abc123") == true) 144 #expect(note?.contains("abc123") == true)
143 } 145 }
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 }
144} 167}