Repository settings: mirrors and custom domains !183

merged merged by cmc on 2026-10-16 03:33 UTC · krz/gitbay-ios:repo-mirrors-domains into main

6 files changed, +771 −0

Layout: unified · split

gitbay/ContentView.swift +4
@@ -331,6 +331,10 @@ private struct RouteDestinations: ViewModifier {
331 BuildSecretsView(client: client, repo: repo) 331 BuildSecretsView(client: client, repo: repo)
332 case .deployKeys(let repo): 332 case .deployKeys(let repo):
333 DeployKeysView(client: client, repo: repo) 333 DeployKeysView(client: client, repo: repo)
334 case .mirrors(let repo):
335 MirrorsView(client: client, repo: repo)
336 case .pageDomains(let repo):
337 PageDomainsView(client: client, repo: repo)
334 case .wiki(let repo): 338 case .wiki(let repo):
335 WikiView(client: client, repo: repo) 339 WikiView(client: client, repo: repo)
336 case .wikiPage(let repo, let page): 340 case .wikiPage(let repo, let page):
gitbay/Repos/RepoMirrorsDomains.swift added +199
@@ -0,0 +1,199 @@
1import Foundation
2import Observation
3
4/// One row of `repo mirror list`.
5nonisolated struct Mirror: Decodable, Sendable, Hashable, Identifiable {
6 let id: Int64
7 /// `push` or `pull`.
8 let direction: String
9 let url: String
10 var username: String? = nil
11 /// Waiting for the worker's next sync.
12 let pending: Bool
13 var lastSync: Date? = nil
14 var lastError: String? = nil
15
16 var isPull: Bool { direction == "pull" }
17
18 enum CodingKeys: String, CodingKey {
19 case id, direction, url, username, pending
20 case lastSync = "last_sync"
21 case lastError = "last_error"
22 }
23}
24
25/// `repo mirror list`, `add`, `remove` and `sync`.
26@Observable
27@MainActor
28final class MirrorsViewModel {
29
30 private(set) var state: LoadState<[Mirror]> = .loading
31 private(set) var actionError: String?
32 private(set) var working = false
33
34 private let client: GitbayClient
35 let repoPath: String
36
37 init(client: GitbayClient, repoPath: String) {
38 self.client = client
39 self.repoPath = repoPath
40 }
41
42 func load() async {
43 do {
44 let mirrors = try await client.readList(["repo", "mirror", "list", repoPath], of: Mirror.self)
45 state = mirrors.isEmpty ? .empty("No mirrors yet.") : .loaded(mirrors)
46 } catch {
47 state = .from(error)
48 }
49 }
50
51 /// `repo mirror add <repo> <url> --direction push|pull [--username <u>]
52 /// [--token-stdin]`. The token travels on stdin, never in argv.
53 func add(url: String, pull: Bool, username: String, token: String) async -> Bool {
54 let url = url.trimmingCharacters(in: .whitespaces)
55 guard !url.isEmpty else { return false }
56 var argv = ["repo", "mirror", "add", repoPath, url, "--direction", pull ? "pull" : "push"]
57 let username = username.trimmingCharacters(in: .whitespaces)
58 if !username.isEmpty { argv += ["--username", username] }
59 let token = token.trimmingCharacters(in: .whitespacesAndNewlines)
60 if !token.isEmpty { argv.append("--token-stdin") }
61 return await perform(argv, stdin: token.isEmpty ? nil : token + "\n")
62 }
63
64 /// Asks the worker to sync every mirror of the repository now.
65 func sync() async -> Bool {
66 await perform(["repo", "mirror", "sync", repoPath])
67 }
68
69 func remove(id: Int64) async {
70 await perform(["repo", "mirror", "remove", repoPath, String(id)])
71 }
72
73 @discardableResult
74 private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
75 working = true
76 actionError = nil
77 defer { working = false }
78 do {
79 try await client.run(argv, stdin: stdin)
80 await load()
81 return true
82 } catch let error as GitbayError {
83 actionError = error.messageForTypedInput
84 } catch {
85 actionError = GitbayError.transport(error).userFacingMessage
86 }
87 // A refusal may mean the list changed: a claim that expired is
88 // removed by the verify that finds it so.
89 await load()
90 return false
91 }
92}
93
94/// One row of `repo domain list`.
95nonisolated struct PageDomain: Decodable, Sendable, Hashable, Identifiable {
96 let domain: String
97 /// `pending` until the DNS challenge is verified, `expired` once the
98 /// claim lapsed unverified.
99 let state: String
100 var verifiedAt: Date? = nil
101 var id: String { domain }
102
103 var isVerified: Bool { state == "verified" }
104 /// Waiting on its DNS record; an `expired` claim is neither.
105 var isPending: Bool { state == "pending" }
106
107 enum CodingKeys: String, CodingKey {
108 case domain, state
109 case verifiedAt = "verified_at"
110 }
111}
112
113/// The TXT record that proves a claim, shown once when it is made.
114nonisolated struct DomainChallenge: Decodable, Sendable, Hashable {
115 let domain: String
116 let name: String
117 let value: String
118
119 enum CodingKeys: String, CodingKey {
120 case domain
121 case name = "challenge_name"
122 case value = "challenge_value"
123 }
124}
125
126/// `repo domain list`, `add`, `verify` and `remove`: custom domains that
127/// serve the repository's pages once a DNS TXT record proves the claim.
128@Observable
129@MainActor
130final class PageDomainsViewModel {
131
132 private(set) var state: LoadState<[PageDomain]> = .loading
133 private(set) var actionError: String?
134 private(set) var working = false
135
136 private let client: GitbayClient
137 let repoPath: String
138
139 init(client: GitbayClient, repoPath: String) {
140 self.client = client
141 self.repoPath = repoPath
142 }
143
144 func load() async {
145 do {
146 let domains = try await client.readList(["repo", "domain", "list", repoPath], of: PageDomain.self)
147 state = domains.isEmpty ? .empty("No custom domains yet.") : .loaded(domains)
148 } catch {
149 state = .from(error)
150 }
151 }
152
153 /// Claims a domain; the record to create comes back, and only now.
154 func add(_ domain: String) async -> DomainChallenge? {
155 let domain = domain.trimmingCharacters(in: .whitespaces)
156 guard !domain.isEmpty else { return nil }
157 working = true
158 actionError = nil
159 defer { working = false }
160 do {
161 let challenge = try await client.run(["repo", "domain", "add", repoPath, domain], as: DomainChallenge.self)
162 await load()
163 return challenge
164 } catch let error as GitbayError {
165 actionError = error.messageForTypedInput
166 } catch {
167 actionError = GitbayError.transport(error).userFacingMessage
168 }
169 return nil
170 }
171
172 func verify(_ domain: String) async -> Bool {
173 await perform(["repo", "domain", "verify", repoPath, domain])
174 }
175
176 func remove(_ domain: String) async {
177 await perform(["repo", "domain", "remove", repoPath, domain])
178 }
179
180 @discardableResult
181 private func perform(_ argv: [String]) async -> Bool {
182 working = true
183 actionError = nil
184 defer { working = false }
185 do {
186 try await client.run(argv)
187 await load()
188 return true
189 } catch let error as GitbayError {
190 actionError = error.messageForTypedInput
191 } catch {
192 actionError = GitbayError.transport(error).userFacingMessage
193 }
194 // A refusal may mean the list changed: a claim that expired is
195 // removed by the verify that finds it so.
196 await load()
197 return false
198 }
199}
gitbay/Views/Repos/RepoMirrorDomainViews.swift added +383
@@ -0,0 +1,383 @@
1import SwiftUI
2
3/// A repository's mirrors: where each pushes to or pulls from, how its
4/// last sync went, a way to sync now, add and remove.
5struct MirrorsView: View {
6
7 @State private var model: MirrorsViewModel
8 @State private var adding = false
9 @State private var removing: Mirror?
10 @State private var scheduled = false
11
12 init(client: GitbayClient, repo: String) {
13 _model = State(initialValue: MirrorsViewModel(client: client, repoPath: repo))
14 }
15
16 var body: some View {
17 List {
18 if let error = model.actionError, !adding {
19 Section { GBNotice(error, .gbWarn) }
20 } else if scheduled {
21 Section { GBNotice("Sync scheduled. Pull to refresh for each mirror's result.", .gbOK) }
22 }
23 Section {
24 ForEach(model.state.value ?? []) { mirror in
25 row(mirror)
26 .swipeActions {
27 Button("Remove", role: .destructive) {
28 scheduled = false
29 removing = mirror
30 }
31 .disabled(model.working)
32 }
33 }
34 } footer: {
35 if model.state.value != nil {
36 Text("A push mirror sends every push here to the remote. A pull mirror fetches from it, and pushes here are refused.")
37 }
38 }
39 if model.state.value != nil {
40 Section {
41 Button {
42 Task { scheduled = await model.sync() }
43 } label: {
44 Label("Sync now", systemImage: "arrow.triangle.2.circlepath")
45 .font(.gbSans(.subheadline))
46 }
47 .disabled(model.working)
48 .accessibilityIdentifier("mirror-sync-button")
49 }
50 }
51 }
52 .overlay { LoadStateOverlay(state: model.state) }
53 .navigationTitle("Mirrors")
54 .navigationBarTitleDisplayMode(.inline)
55 .toolbar {
56 ToolbarItem(placement: .topBarTrailing) {
57 Button {
58 scheduled = false
59 adding = true
60 } label: { Image(systemName: "plus") }
61 .accessibilityLabel("Add Mirror")
62 .disabled(model.working)
63 .accessibilityIdentifier("mirror-add-button")
64 }
65 }
66 .sheet(isPresented: $adding) {
67 MirrorAddSheet(model: model) { adding = false }
68 }
69 .confirmationDialog(
70 "Remove the mirror \(removing?.isPull == true ? "from" : "to") \(removing?.url ?? "")?",
71 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
72 titleVisibility: .visible
73 ) {
74 Button("Remove", role: .destructive) {
75 if let mirror = removing { Task { await model.remove(id: mirror.id) } }
76 removing = nil
77 }
78 Button("Cancel", role: .cancel) {}
79 }
80 .task { await model.load() }
81 .refreshable {
82 scheduled = false
83 await model.load()
84 }
85 }
86
87 private func row(_ mirror: Mirror) -> some View {
88 VStack(alignment: .leading, spacing: 3) {
89 Text(mirror.url)
90 .font(.gbMono(.caption))
91 .lineLimit(2)
92 HStack(spacing: 6) {
93 GBChip(mirror.isPull ? "pull" : "push", .secondary)
94 if let username = mirror.username {
95 Text("as \(username)")
96 }
97 if mirror.pending {
98 Text("sync pending")
99 } else if let synced = mirror.lastSync {
100 Text("synced \(synced.formatted(.relative(presentation: .named)))")
101 }
102 }
103 .font(.gbSans(.caption2))
104 .foregroundStyle(.secondary)
105 if let error = mirror.lastError, !error.isEmpty {
106 Text(error)
107 .font(.gbSans(.caption2))
108 .foregroundStyle(Color.gbBad)
109 }
110 }
111 .padding(.vertical, 2)
112 .accessibilityElement(children: .combine)
113 }
114}
115
116private struct MirrorAddSheet: View {
117 let model: MirrorsViewModel
118 let onDone: () -> Void
119
120 @State private var url = ""
121 @State private var pull = false
122 @State private var username = ""
123 @State private var token = ""
124 @State private var attempted = false
125
126 var body: some View {
127 NavigationStack {
128 Form {
129 Section {
130 TextField("https://…", text: $url)
131 .autocorrectionDisabled()
132 .textInputAutocapitalization(.never)
133 .keyboardType(.URL)
134 .accessibilityIdentifier("mirror-url")
135 Picker("Direction", selection: $pull) {
136 Text("Push").tag(false)
137 Text("Pull").tag(true)
138 }
139 .pickerStyle(.segmented)
140 .accessibilityIdentifier("mirror-direction")
141 } footer: {
142 Text(pull
143 ? "Pulls from the remote. Pushes to this repository are refused while the mirror exists."
144 : "Pushes here are sent on to the remote.")
145 }
146 Section {
147 TextField("Username (optional)", text: $username)
148 .autocorrectionDisabled()
149 .textInputAutocapitalization(.never)
150 SecureField("Token (optional)", text: $token)
151 .autocorrectionDisabled()
152 .textInputAutocapitalization(.never)
153 } footer: {
154 Text("For a remote that needs credentials. The token is stored for syncs and not shown again.")
155 }
156 if attempted, let error = model.actionError {
157 Section { GBNotice(error, .gbWarn) }
158 }
159 }
160 .navigationTitle("Add Mirror")
161 .navigationBarTitleDisplayMode(.inline)
162 .toolbar {
163 ToolbarItem(placement: .cancellationAction) {
164 Button("Cancel") { onDone() }.disabled(model.working)
165 }
166 ToolbarItem(placement: .confirmationAction) {
167 if model.working {
168 ProgressView()
169 } else {
170 Button("Add") {
171 Task {
172 attempted = true
173 guard await model.add(url: url, pull: pull, username: username, token: token) else { return }
174 // A secure field that still holds text as the sheet
175 // goes makes iOS offer to save it as a password.
176 token = ""
177 await Task.yield()
178 onDone()
179 }
180 }
181 .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
182 .accessibilityIdentifier("mirror-submit")
183 }
184 }
185 }
186 .interactiveDismissDisabled(model.working)
187 }
188 }
189}
190
191/// A repository's custom pages domains: each claim, verified or waiting
192/// on its DNS record, with verify and remove.
193struct PageDomainsView: View {
194
195 @State private var model: PageDomainsViewModel
196 @State private var adding = false
197 @State private var removing: PageDomain?
198 @State private var verified: String?
199
200 init(client: GitbayClient, repo: String) {
201 _model = State(initialValue: PageDomainsViewModel(client: client, repoPath: repo))
202 }
203
204 var body: some View {
205 List {
206 if let error = model.actionError, !adding {
207 Section { GBNotice(error, .gbWarn) }
208 } else if let verified {
209 Section {
210 GBNotice("\(verified) is verified. Point its A and AAAA records at this server.", .gbOK)
211 }
212 }
213 Section {
214 ForEach(model.state.value ?? []) { claim in
215 VStack(alignment: .leading, spacing: 3) {
216 Text(claim.domain).font(.gbMono(.subheadline))
217 HStack(spacing: 6) {
218 GBChip(claim.state, claim.isVerified ? Color.gbOK : claim.isPending ? Color.gbWarn : Color.secondary)
219 if let at = claim.verifiedAt {
220 Text("since \(at.formatted(date: .abbreviated, time: .omitted))")
221 .font(.gbSans(.caption2))
222 .foregroundStyle(.secondary)
223 }
224 }
225 }
226 .padding(.vertical, 2)
227 .accessibilityElement(children: .combine)
228 .swipeActions {
229 Button("Remove", role: .destructive) {
230 verified = nil
231 removing = claim
232 }
233 .disabled(model.working)
234 if claim.isPending {
235 Button("Verify") { verify(claim.domain) }
236 .tint(.gbAccent)
237 .disabled(model.working)
238 }
239 }
240 }
241 } footer: {
242 if model.state.value != nil {
243 Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it; an expired one has to be claimed again.")
244 }
245 }
246 }
247 .overlay { LoadStateOverlay(state: model.state) }
248 .navigationTitle("Custom Domains")
249 .navigationBarTitleDisplayMode(.inline)
250 .toolbar {
251 ToolbarItem(placement: .topBarTrailing) {
252 Button {
253 verified = nil
254 adding = true
255 } label: { Image(systemName: "plus") }
256 .accessibilityLabel("Claim Domain")
257 .disabled(model.working)
258 .accessibilityIdentifier("domain-add-button")
259 }
260 }
261 .sheet(isPresented: $adding) {
262 DomainClaimSheet(model: model) { domain in
263 adding = false
264 if let domain { verified = domain }
265 }
266 }
267 .confirmationDialog(
268 "Remove \(removing?.domain ?? "")? It stops serving this repository's pages.",
269 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
270 titleVisibility: .visible
271 ) {
272 Button("Remove", role: .destructive) {
273 if let claim = removing { Task { await model.remove(claim.domain) } }
274 removing = nil
275 }
276 Button("Cancel", role: .cancel) {}
277 }
278 .task { await model.load() }
279 .refreshable {
280 verified = nil
281 await model.load()
282 }
283 }
284
285 private func verify(_ domain: String) {
286 verified = nil
287 Task { if await model.verify(domain) { verified = domain } }
288 }
289}
290
291/// Claims a domain, then shows the TXT record to create and a way to
292/// verify it once it is in place. The record is shown only here.
293private struct DomainClaimSheet: View {
294 let model: PageDomainsViewModel
295 /// The domain when it was verified from here, else nil.
296 let onDone: (String?) -> Void
297
298 @State private var domain = ""
299 @State private var challenge: DomainChallenge?
300 @State private var attempted = false
301
302 var body: some View {
303 NavigationStack {
304 Form {
305 if let challenge {
306 Section {
307 record("Name", challenge.name, id: "domain-copy-name")
308 LabeledContent("Type", value: "TXT")
309 record("Value", challenge.value, id: "domain-copy-value")
310 } header: {
311 Text("Create this DNS record")
312 } footer: {
313 Text("This record is shown only now. Create it, then verify, here or later from the list. An unverified claim expires after a few days.")
314 }
315 } else {
316 Section {
317 TextField("docs.example.com", text: $domain)
318 .autocorrectionDisabled()
319 .textInputAutocapitalization(.never)
320 .keyboardType(.URL)
321 .accessibilityIdentifier("domain-name")
322 } footer: {
323 Text("A domain you control. Claiming it gives you a DNS record that proves it.")
324 }
325 }
326 if attempted, let error = model.actionError {
327 Section { GBNotice(error, .gbWarn) }
328 }
329 }
330 .navigationTitle(challenge == nil ? "Claim Domain" : challenge!.domain)
331 .navigationBarTitleDisplayMode(.inline)
332 .toolbar {
333 ToolbarItem(placement: .cancellationAction) {
334 Button(challenge == nil ? "Cancel" : "Done") { onDone(nil) }
335 .disabled(model.working)
336 .accessibilityIdentifier("domain-done")
337 }
338 ToolbarItem(placement: .confirmationAction) {
339 if model.working {
340 ProgressView()
341 } else if let challenge {
342 Button("Verify") {
343 Task {
344 attempted = true
345 if await model.verify(challenge.domain) { onDone(challenge.domain) }
346 }
347 }
348 .accessibilityIdentifier("domain-verify")
349 } else {
350 Button("Claim") {
351 Task {
352 attempted = true
353 challenge = await model.add(domain)
354 }
355 }
356 .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty)
357 .accessibilityIdentifier("domain-submit")
358 }
359 }
360 }
361 // The record cannot be fetched again, so it closes only by Done.
362 .interactiveDismissDisabled(model.working || challenge != nil)
363 }
364 }
365
366 private func record(_ label: String, _ text: String, id: String) -> some View {
367 HStack(alignment: .firstTextBaseline) {
368 VStack(alignment: .leading, spacing: 2) {
369 Text(label).font(.gbSans(.caption)).foregroundStyle(.secondary)
370 Text(text).font(.gbMono(.caption)).textSelection(.enabled)
371 }
372 Spacer()
373 Button {
374 UIPasteboard.general.string = text
375 } label: {
376 Image(systemName: "doc.on.doc")
377 }
378 .buttonStyle(.borderless)
379 .accessibilityLabel("Copy \(label)")
380 .accessibilityIdentifier(id)
381 }
382 }
383}
gitbay/Views/Repos/RepoRoute.swift +2
@@ -27,6 +27,8 @@ nonisolated enum RepoRoute: Hashable {
27 case webhookDeliveries(repo: String) 27 case webhookDeliveries(repo: String)
28 case buildSecrets(repo: String) 28 case buildSecrets(repo: String)
29 case deployKeys(repo: String) 29 case deployKeys(repo: String)
30 case mirrors(repo: String)
31 case pageDomains(repo: String)
30 case profile(String) 32 case profile(String)
31 case account 33 case account
32 case tokens 34 case tokens
gitbay/Views/Repos/RepoSettingsView.swift +10
@@ -561,6 +561,16 @@ struct RepoSettingsView: View {
561 .font(.gbSans(.subheadline)) 561 .font(.gbSans(.subheadline))
562 } 562 }
563 .accessibilityIdentifier("settings-deploy-keys-link") 563 .accessibilityIdentifier("settings-deploy-keys-link")
564 NavigationLink(value: RepoRoute.mirrors(repo: model.repoPath)) {
565 Label("Mirrors", systemImage: "arrow.triangle.branch")
566 .font(.gbSans(.subheadline))
567 }
568 .accessibilityIdentifier("settings-mirrors-link")
569 NavigationLink(value: RepoRoute.pageDomains(repo: model.repoPath)) {
570 Label("Custom domains", systemImage: "globe")
571 .font(.gbSans(.subheadline))
572 }
573 .accessibilityIdentifier("settings-domains-link")
564 } 574 }
565 } 575 }
566 576
gitbayTests/RepoMirrorDomainTests.swift added +173
@@ -0,0 +1,173 @@
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
15private func writes(_ stub: StubProtocol.Box) throws -> [[String: Any]] {
16 try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map {
17 try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])
18 }
19}
20
21private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
22private let mirrorsJSON = """
23 {"protocol_version":1,"data":[\
24 {"id":1,"direction":"push","url":"https://github.com/krz/gitbay.git","username":"bot","pending":true},\
25 {"id":2,"direction":"pull","url":"https://example.test/up.git","pending":false,\
26 "last_sync":"2026-10-15T10:00:00.123Z","last_error":"authentication failed"}\
27 ],"exit_code":0}
28 """
29private let domainsJSON = """
30 {"protocol_version":1,"data":[\
31 {"domain":"docs.example.test","state":"pending"},\
32 {"domain":"www.example.test","state":"verified","verified_at":"2026-10-01T09:00:00Z"},\
33 {"domain":"old.example.test","state":"expired"}\
34 ],"exit_code":0}
35 """
36
37@MainActor
38struct MirrorsViewModelTests {
39
40 @Test func listsMirrorsWithTheirSyncState() async throws {
41 let (client, stub) = try makeClient()
42 stub.enqueue(.init(status: 200, json: mirrorsJSON))
43 let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay")
44 await model.load()
45
46 let mirrors = try #require(model.state.value)
47 #expect(mirrors.map(\.id) == [1, 2])
48 #expect(mirrors[0].pending && mirrors[0].lastSync == nil && mirrors[0].username == "bot")
49 #expect(mirrors[1].isPull && mirrors[1].lastSync != nil && mirrors[1].lastError == "authentication failed")
50 #expect(stub.seen.first?.url.query() == "argv=repo&argv=mirror&argv=list&argv=krz/gitbay")
51 }
52
53 @Test func addSendsTheTokenOnStdinOnlyWhenGiven() async throws {
54 let (client, stub) = try makeClient()
55 for _ in 0..<2 {
56 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":3},"exit_code":0}"#, match: "cmd"))
57 stub.enqueue(.init(status: 200, json: mirrorsJSON, match: "argv=list"))
58 }
59 let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay")
60
61 #expect(await model.add(url: " https://example.test/a.git ", pull: false, username: "", token: ""))
62 #expect(await model.add(url: "https://example.test/b.git", pull: true, username: "bot", token: "t0k"))
63
64 let sent = try writes(stub)
65 #expect(sent.map { $0["argv"] as? [String] } == [
66 ["repo", "mirror", "add", "krz/gitbay", "https://example.test/a.git", "--direction", "push"],
67 ["repo", "mirror", "add", "krz/gitbay", "https://example.test/b.git", "--direction", "pull",
68 "--username", "bot", "--token-stdin"],
69 ])
70 #expect(sent[0]["stdin"] == nil)
71 #expect(sent[1]["stdin"] as? String == "t0k\n")
72 }
73
74 @Test func aRefusedURLSaysWhy() async throws {
75 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"that mirror already exists","exit_code":2}"#))
77 let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay")
78
79 #expect(await model.add(url: "https://example.test/a.git", pull: false, username: "", token: "") == false)
80 #expect(model.actionError == "that mirror already exists")
81 }
82
83 @Test func syncAndRemove() async throws {
84 let (client, stub) = try makeClient()
85 for _ in 0..<2 {
86 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
87 stub.enqueue(.init(status: 200, json: mirrorsJSON, match: "argv=list"))
88 }
89 let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay")
90
91 #expect(await model.sync())
92 await model.remove(id: 2)
93
94 #expect(try writes(stub).map { $0["argv"] as? [String] } == [
95 ["repo", "mirror", "sync", "krz/gitbay"],
96 ["repo", "mirror", "remove", "krz/gitbay", "2"],
97 ])
98 }
99}
100
101@MainActor
102struct PageDomainsViewModelTests {
103
104 @Test func listsClaimsWithTheirState() async throws {
105 let (client, stub) = try makeClient()
106 stub.enqueue(.init(status: 200, json: domainsJSON))
107 let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay")
108 await model.load()
109
110 let domains = try #require(model.state.value)
111 #expect(domains.map(\.domain) == ["docs.example.test", "www.example.test", "old.example.test"])
112 #expect(domains[0].isPending && domains[0].verifiedAt == nil)
113 #expect(domains[1].isVerified && domains[1].verifiedAt != nil)
114 #expect(!domains[2].isPending && !domains[2].isVerified)
115 }
116
117 @Test func addReturnsTheDNSChallenge() async throws {
118 let (client, stub) = try makeClient()
119 stub.enqueue(.init(status: 200, json: """
120 {"protocol_version":1,"data":{"challenge_name":"_gitbay-challenge.docs.example.test",\
121 "challenge_value":"gitbay-domain-verify=abc","domain":"docs.example.test","state":"pending"},"exit_code":0}
122 """, match: "cmd"))
123 stub.enqueue(.init(status: 200, json: domainsJSON, match: "argv=list"))
124 let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay")
125
126 let challenge = try #require(await model.add(" Docs.Example.Test "))
127
128 #expect(challenge.name == "_gitbay-challenge.docs.example.test")
129 #expect(challenge.value == "gitbay-domain-verify=abc")
130 #expect(try writes(stub).first?["argv"] as? [String] == ["repo", "domain", "add", "krz/gitbay", "Docs.Example.Test"])
131 }
132
133 @Test func verifyReportsAMissingRecordVerbatim() async throws {
134 let (client, stub) = try makeClient()
135 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"_gitbay-challenge.docs.example.test does not carry the expected record \"x\"","exit_code":4}"#))
136 let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay")
137
138 #expect(await model.verify("docs.example.test") == false)
139 #expect(model.actionError == #"_gitbay-challenge.docs.example.test does not carry the expected record "x""#)
140 }
141
142 @Test func aRefusedVerifyRefreshesTheList() async throws {
143 let (client, stub) = try makeClient()
144 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"the claim on old.example.test expired; run repo domain add again","exit_code":2}"#, match: "cmd"))
145 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#, match: "argv=list"))
146 let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay")
147
148 #expect(await model.verify("old.example.test") == false)
149
150 #expect(model.actionError == "the claim on old.example.test expired; run repo domain add again")
151 guard case .empty = model.state else {
152 Issue.record("expected the removed claim gone, got \(model.state)")
153 return
154 }
155 }
156
157 @Test func verifyAndRemoveNameTheDomain() async throws {
158 let (client, stub) = try makeClient()
159 for _ in 0..<2 {
160 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"domain":"d","state":"verified"},"exit_code":0}"#, match: "cmd"))
161 stub.enqueue(.init(status: 200, json: domainsJSON, match: "argv=list"))
162 }
163 let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay")
164
165 #expect(await model.verify("docs.example.test"))
166 await model.remove("www.example.test")
167
168 #expect(try writes(stub).map { $0["argv"] as? [String] } == [
169 ["repo", "domain", "verify", "krz/gitbay", "docs.example.test"],
170 ["repo", "domain", "remove", "krz/gitbay", "www.example.test"],
171 ])
172 }
173}