Commit 3f57451dba

3f57451dba6f58c30a11786044f9ba50d3ee37d7

parent: 42a9918acf

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 03:09 UTC

Repository settings: mirrors and custom domains

Mirrors lists each push or pull mirror with its username, whether a
sync is pending, when it last synced and the last error; adds one with
an optional username and a token on stdin; removes one; and schedules
a sync of them all. Custom domains lists pages domain claims, verified
or pending; a claim shows the DNS TXT record to create, once, with a
way to verify it then or later from the list; a claim can be removed.

Closes #44
Closes #45

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