Commit 3958f6060f
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -327,6 +327,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 327 | WebhookListView(client: client, repo: repo) | 327 | WebhookListView(client: client, repo: repo) |
| 328 | case .webhookDeliveries(let repo): | 328 | case .webhookDeliveries(let repo): |
| 329 | WebhookDeliveriesView(client: client, repo: repo) | 329 | WebhookDeliveriesView(client: client, repo: repo) |
| 330 | case .buildSecrets(let repo): | ||
| 331 | BuildSecretsView(client: client, repo: repo) | ||
| 332 | case .deployKeys(let repo): | ||
| 333 | DeployKeysView(client: client, repo: repo) | ||
| 330 | case .wiki(let repo): | 334 | case .wiki(let repo): |
| 331 | WikiView(client: client, repo: repo) | 335 | WikiView(client: client, repo: repo) |
| 332 | case .wikiPage(let repo, let page): | 336 | case .wikiPage(let repo, let page): |
gitbay/Repos/RepoCredentials.swift added +139
| @@ -0,0 +1,139 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `repo secret list`, `set` and `remove`. Names only: a value goes to | ||
| 5 | /// the server on stdin and never comes back. | ||
| 6 | @Observable | ||
| 7 | @MainActor | ||
| 8 | final class BuildSecretsViewModel { | ||
| 9 | |||
| 10 | private(set) var state: LoadState<[String]> = .loading | ||
| 11 | private(set) var actionError: String? | ||
| 12 | private(set) var working = false | ||
| 13 | |||
| 14 | private let client: GitbayClient | ||
| 15 | let repoPath: String | ||
| 16 | |||
| 17 | init(client: GitbayClient, repoPath: String) { | ||
| 18 | self.client = client | ||
| 19 | self.repoPath = repoPath | ||
| 20 | } | ||
| 21 | |||
| 22 | func load() async { | ||
| 23 | do { | ||
| 24 | let names = try await client.readList(["repo", "secret", "list", repoPath], of: String.self) | ||
| 25 | state = names.isEmpty ? .empty("No build secrets yet.") : .loaded(names) | ||
| 26 | } catch { | ||
| 27 | state = .from(error) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Sets or replaces a secret. The name is the user's, so a refusal | ||
| 32 | /// of it is shown as the server words it. | ||
| 33 | func set(name: String, value: String) async -> Bool { | ||
| 34 | let name = name.trimmingCharacters(in: .whitespaces) | ||
| 35 | guard !name.isEmpty, !value.isEmpty else { return false } | ||
| 36 | return await perform(["repo", "secret", "set", repoPath, name], stdin: value) | ||
| 37 | } | ||
| 38 | |||
| 39 | func remove(_ name: String) async { | ||
| 40 | await perform(["repo", "secret", "remove", repoPath, name]) | ||
| 41 | } | ||
| 42 | |||
| 43 | @discardableResult | ||
| 44 | private func perform(_ argv: [String], stdin: String? = nil) async -> Bool { | ||
| 45 | working = true | ||
| 46 | actionError = nil | ||
| 47 | defer { working = false } | ||
| 48 | do { | ||
| 49 | try await client.run(argv, stdin: stdin) | ||
| 50 | await load() | ||
| 51 | return true | ||
| 52 | } catch let error as GitbayError { | ||
| 53 | actionError = error.messageForTypedInput | ||
| 54 | } catch { | ||
| 55 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 56 | } | ||
| 57 | return false | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// One row of `repo deploy-key list`. | ||
| 62 | nonisolated struct DeployKey: Decodable, Sendable, Hashable, Identifiable { | ||
| 63 | let fingerprint: String | ||
| 64 | let algo: String | ||
| 65 | /// `ro` or `rw`. | ||
| 66 | let mode: String | ||
| 67 | /// The key's comment; empty when it had none. | ||
| 68 | let label: String | ||
| 69 | var lastUsedAt: Date? = nil | ||
| 70 | var expiresAt: Date? = nil | ||
| 71 | var id: String { fingerprint } | ||
| 72 | |||
| 73 | var canPush: Bool { mode == "rw" } | ||
| 74 | |||
| 75 | enum CodingKeys: String, CodingKey { | ||
| 76 | case fingerprint, algo, mode, label | ||
| 77 | case lastUsedAt = "last_used_at" | ||
| 78 | case expiresAt = "expires_at" | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | /// `repo deploy-key list`, `add` and `remove`: keys bound to this one | ||
| 83 | /// repository, read-only unless added with `--rw`. | ||
| 84 | @Observable | ||
| 85 | @MainActor | ||
| 86 | final class DeployKeysViewModel { | ||
| 87 | |||
| 88 | private(set) var state: LoadState<[DeployKey]> = .loading | ||
| 89 | private(set) var actionError: String? | ||
| 90 | private(set) var working = false | ||
| 91 | |||
| 92 | private let client: GitbayClient | ||
| 93 | let repoPath: String | ||
| 94 | |||
| 95 | init(client: GitbayClient, repoPath: String) { | ||
| 96 | self.client = client | ||
| 97 | self.repoPath = repoPath | ||
| 98 | } | ||
| 99 | |||
| 100 | func load() async { | ||
| 101 | do { | ||
| 102 | let keys = try await client.readList(["repo", "deploy-key", "list", repoPath], of: DeployKey.self) | ||
| 103 | state = keys.isEmpty ? .empty("No deploy keys yet.") : .loaded(keys) | ||
| 104 | } catch { | ||
| 105 | state = .from(error) | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | /// `repo deploy-key add <repo> [--rw] [--ttl <d>] < key.pub`. | ||
| 110 | func add(publicKey: String, canPush: Bool, ttl: String?) async -> Bool { | ||
| 111 | let key = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 112 | guard !key.isEmpty else { return false } | ||
| 113 | var argv = ["repo", "deploy-key", "add", repoPath] | ||
| 114 | if canPush { argv.append("--rw") } | ||
| 115 | if let ttl { argv += ["--ttl", ttl] } | ||
| 116 | return await perform(argv, stdin: key) | ||
| 117 | } | ||
| 118 | |||
| 119 | func remove(_ key: DeployKey) async { | ||
| 120 | await perform(["repo", "deploy-key", "remove", repoPath, key.fingerprint]) | ||
| 121 | } | ||
| 122 | |||
| 123 | @discardableResult | ||
| 124 | private func perform(_ argv: [String], stdin: String? = nil) async -> Bool { | ||
| 125 | working = true | ||
| 126 | actionError = nil | ||
| 127 | defer { working = false } | ||
| 128 | do { | ||
| 129 | try await client.run(argv, stdin: stdin) | ||
| 130 | await load() | ||
| 131 | return true | ||
| 132 | } catch let error as GitbayError { | ||
| 133 | actionError = error.messageForTypedInput | ||
| 134 | } catch { | ||
| 135 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 136 | } | ||
| 137 | return false | ||
| 138 | } | ||
| 139 | } | ||
gitbay/Views/Repos/RepoCredentialViews.swift added +274
| @@ -0,0 +1,274 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// A repository's build secrets: their names, a way to set or replace | ||
| 4 | /// one, and removal. Values are never shown, here or anywhere. | ||
| 5 | struct BuildSecretsView: View { | ||
| 6 | |||
| 7 | @State private var model: BuildSecretsViewModel | ||
| 8 | @State private var adding = false | ||
| 9 | @State private var removing: String? | ||
| 10 | |||
| 11 | init(client: GitbayClient, repo: String) { | ||
| 12 | _model = State(initialValue: BuildSecretsViewModel(client: client, repoPath: repo)) | ||
| 13 | } | ||
| 14 | |||
| 15 | var body: some View { | ||
| 16 | List { | ||
| 17 | if let error = model.actionError, !adding { | ||
| 18 | Section { GBNotice(error, .gbWarn) } | ||
| 19 | } | ||
| 20 | Section { | ||
| 21 | ForEach(model.state.value ?? [], id: \.self) { name in | ||
| 22 | Text(name) | ||
| 23 | .font(.gbMono(.subheadline)) | ||
| 24 | .swipeActions { | ||
| 25 | Button("Remove", role: .destructive) { removing = name } | ||
| 26 | .disabled(model.working) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | } footer: { | ||
| 30 | if model.state.value != nil { | ||
| 31 | Text("Builds read each secret as an environment variable. Setting a name again replaces its value.") | ||
| 32 | } | ||
| 33 | } | ||
| 34 | } | ||
| 35 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 36 | .navigationTitle("Build Secrets") | ||
| 37 | .navigationBarTitleDisplayMode(.inline) | ||
| 38 | .toolbar { | ||
| 39 | ToolbarItem(placement: .topBarTrailing) { | ||
| 40 | Button { adding = true } label: { Image(systemName: "plus") } | ||
| 41 | .accessibilityLabel("Set Secret") | ||
| 42 | .disabled(model.working) | ||
| 43 | .accessibilityIdentifier("secret-add-button") | ||
| 44 | } | ||
| 45 | } | ||
| 46 | .sheet(isPresented: $adding) { | ||
| 47 | SecretSetSheet(model: model) { adding = false } | ||
| 48 | } | ||
| 49 | .confirmationDialog( | ||
| 50 | "Remove \(removing ?? "")? Builds that read it will no longer find it.", | ||
| 51 | isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), | ||
| 52 | titleVisibility: .visible | ||
| 53 | ) { | ||
| 54 | Button("Remove", role: .destructive) { | ||
| 55 | if let name = removing { Task { await model.remove(name) } } | ||
| 56 | removing = nil | ||
| 57 | } | ||
| 58 | Button("Cancel", role: .cancel) {} | ||
| 59 | } | ||
| 60 | .task { await model.load() } | ||
| 61 | .refreshable { await model.load() } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | private struct SecretSetSheet: View { | ||
| 66 | let model: BuildSecretsViewModel | ||
| 67 | let onDone: () -> Void | ||
| 68 | |||
| 69 | @State private var name = "" | ||
| 70 | @State private var value = "" | ||
| 71 | |||
| 72 | var body: some View { | ||
| 73 | NavigationStack { | ||
| 74 | Form { | ||
| 75 | Section { | ||
| 76 | TextField("NAME", text: $name) | ||
| 77 | .font(.gbMono(.body)) | ||
| 78 | .autocorrectionDisabled() | ||
| 79 | .textInputAutocapitalization(.characters) | ||
| 80 | .accessibilityIdentifier("secret-name") | ||
| 81 | SecureField("Value", text: $value) | ||
| 82 | .autocorrectionDisabled() | ||
| 83 | .textInputAutocapitalization(.never) | ||
| 84 | .accessibilityIdentifier("secret-value") | ||
| 85 | } footer: { | ||
| 86 | Text("Uppercase letters, digits and underscores. The value is sent once and cannot be read back.") | ||
| 87 | } | ||
| 88 | if let error = model.actionError { | ||
| 89 | Section { GBNotice(error, .gbWarn) } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | .navigationTitle("Set Secret") | ||
| 93 | .navigationBarTitleDisplayMode(.inline) | ||
| 94 | .toolbar { | ||
| 95 | ToolbarItem(placement: .cancellationAction) { | ||
| 96 | Button("Cancel") { onDone() }.disabled(model.working) | ||
| 97 | } | ||
| 98 | ToolbarItem(placement: .confirmationAction) { | ||
| 99 | if model.working { | ||
| 100 | ProgressView() | ||
| 101 | } else { | ||
| 102 | Button("Set") { | ||
| 103 | Task { | ||
| 104 | guard await model.set(name: name, value: value) else { return } | ||
| 105 | // A secure field that still holds text as the sheet | ||
| 106 | // goes makes iOS offer to save it as a password. | ||
| 107 | value = "" | ||
| 108 | await Task.yield() | ||
| 109 | onDone() | ||
| 110 | } | ||
| 111 | } | ||
| 112 | .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || value.isEmpty) | ||
| 113 | .accessibilityIdentifier("secret-submit") | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | .interactiveDismissDisabled(model.working) | ||
| 118 | } | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | /// A repository's deploy keys: keys bound to it alone, read-only unless | ||
| 123 | /// they may push, with when each was used and when it expires. | ||
| 124 | struct DeployKeysView: View { | ||
| 125 | |||
| 126 | @State private var model: DeployKeysViewModel | ||
| 127 | @State private var adding = false | ||
| 128 | @State private var removing: DeployKey? | ||
| 129 | |||
| 130 | init(client: GitbayClient, repo: String) { | ||
| 131 | _model = State(initialValue: DeployKeysViewModel(client: client, repoPath: repo)) | ||
| 132 | } | ||
| 133 | |||
| 134 | var body: some View { | ||
| 135 | List { | ||
| 136 | if let error = model.actionError, !adding { | ||
| 137 | Section { GBNotice(error, .gbWarn) } | ||
| 138 | } | ||
| 139 | Section { | ||
| 140 | ForEach(model.state.value ?? []) { key in | ||
| 141 | VStack(alignment: .leading, spacing: 3) { | ||
| 142 | if !key.label.isEmpty { | ||
| 143 | Text(key.label).font(.gbSans(.subheadline)) | ||
| 144 | } | ||
| 145 | Text(key.fingerprint) | ||
| 146 | .font(.gbMono(.caption)) | ||
| 147 | .lineLimit(1) | ||
| 148 | .truncationMode(.middle) | ||
| 149 | HStack(spacing: 6) { | ||
| 150 | GBChip(key.canPush ? "read-write" : "read-only", key.canPush ? Color.gbWarn : Color.secondary) | ||
| 151 | dates(key) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | .padding(.vertical, 2) | ||
| 155 | .accessibilityElement(children: .combine) | ||
| 156 | .swipeActions { | ||
| 157 | Button("Remove", role: .destructive) { removing = key } | ||
| 158 | .disabled(model.working) | ||
| 159 | } | ||
| 160 | } | ||
| 161 | } footer: { | ||
| 162 | if model.state.value != nil { | ||
| 163 | Text("A deploy key reaches this repository and nothing else. A read-write key can push.") | ||
| 164 | } | ||
| 165 | } | ||
| 166 | } | ||
| 167 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 168 | .navigationTitle("Deploy Keys") | ||
| 169 | .navigationBarTitleDisplayMode(.inline) | ||
| 170 | .toolbar { | ||
| 171 | ToolbarItem(placement: .topBarTrailing) { | ||
| 172 | Button { adding = true } label: { Image(systemName: "plus") } | ||
| 173 | .accessibilityLabel("Add Deploy Key") | ||
| 174 | .disabled(model.working) | ||
| 175 | .accessibilityIdentifier("deploy-key-add-button") | ||
| 176 | } | ||
| 177 | } | ||
| 178 | .sheet(isPresented: $adding) { | ||
| 179 | DeployKeyAddSheet(model: model) { adding = false } | ||
| 180 | } | ||
| 181 | .confirmationDialog( | ||
| 182 | "Remove the deploy key \(removing.map { $0.label.isEmpty ? $0.fingerprint : $0.label } ?? "")?", | ||
| 183 | isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), | ||
| 184 | titleVisibility: .visible | ||
| 185 | ) { | ||
| 186 | Button("Remove", role: .destructive) { | ||
| 187 | if let key = removing { Task { await model.remove(key) } } | ||
| 188 | removing = nil | ||
| 189 | } | ||
| 190 | Button("Cancel", role: .cancel) {} | ||
| 191 | } | ||
| 192 | .task { await model.load() } | ||
| 193 | .refreshable { await model.load() } | ||
| 194 | } | ||
| 195 | |||
| 196 | private func dates(_ key: DeployKey) -> some View { | ||
| 197 | HStack(spacing: 4) { | ||
| 198 | if let used = key.lastUsedAt { | ||
| 199 | Text("used \(used.formatted(.relative(presentation: .named)))") | ||
| 200 | } else { | ||
| 201 | Text("never used") | ||
| 202 | } | ||
| 203 | if let expires = key.expiresAt { | ||
| 204 | Text("·") | ||
| 205 | if expires <= .now { | ||
| 206 | Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))") | ||
| 207 | .foregroundStyle(Color.gbBad) | ||
| 208 | } else { | ||
| 209 | Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))") | ||
| 210 | } | ||
| 211 | } | ||
| 212 | } | ||
| 213 | .font(.gbSans(.caption2)) | ||
| 214 | .foregroundStyle(.secondary) | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | private struct DeployKeyAddSheet: View { | ||
| 219 | let model: DeployKeysViewModel | ||
| 220 | let onDone: () -> Void | ||
| 221 | |||
| 222 | @State private var text = "" | ||
| 223 | @State private var canPush = false | ||
| 224 | @State private var ttl: String? | ||
| 225 | |||
| 226 | var body: some View { | ||
| 227 | NavigationStack { | ||
| 228 | Form { | ||
| 229 | Section { | ||
| 230 | TextEditor(text: $text) | ||
| 231 | .frame(minHeight: 120) | ||
| 232 | .font(.gbMono(.caption)) | ||
| 233 | .autocorrectionDisabled() | ||
| 234 | .textInputAutocapitalization(.never) | ||
| 235 | .accessibilityIdentifier("deploy-key-text") | ||
| 236 | } footer: { | ||
| 237 | Text("Paste the public key, as in authorized_keys. Its comment becomes the label.") | ||
| 238 | } | ||
| 239 | Section { | ||
| 240 | Toggle("Allow pushes", isOn: $canPush) | ||
| 241 | .accessibilityIdentifier("deploy-key-rw") | ||
| 242 | Picker("Expires", selection: $ttl) { | ||
| 243 | Text("Never").tag(String?.none) | ||
| 244 | Text("In 30 days").tag(String?.some("30d")) | ||
| 245 | Text("In 90 days").tag(String?.some("90d")) | ||
| 246 | Text("In a year").tag(String?.some("365d")) | ||
| 247 | } | ||
| 248 | } | ||
| 249 | if let error = model.actionError { | ||
| 250 | Section { GBNotice(error, .gbWarn) } | ||
| 251 | } | ||
| 252 | } | ||
| 253 | .navigationTitle("Add Deploy Key") | ||
| 254 | .navigationBarTitleDisplayMode(.inline) | ||
| 255 | .toolbar { | ||
| 256 | ToolbarItem(placement: .cancellationAction) { | ||
| 257 | Button("Cancel") { onDone() }.disabled(model.working) | ||
| 258 | } | ||
| 259 | ToolbarItem(placement: .confirmationAction) { | ||
| 260 | if model.working { | ||
| 261 | ProgressView() | ||
| 262 | } else { | ||
| 263 | Button("Add") { | ||
| 264 | Task { if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() } } | ||
| 265 | } | ||
| 266 | .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | ||
| 267 | .accessibilityIdentifier("deploy-key-submit") | ||
| 268 | } | ||
| 269 | } | ||
| 270 | } | ||
| 271 | .interactiveDismissDisabled(model.working) | ||
| 272 | } | ||
| 273 | } | ||
| 274 | } | ||
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -25,6 +25,8 @@ nonisolated enum RepoRoute: Hashable { | |||
| 25 | case queryResults(name: String) | 25 | case queryResults(name: String) |
| 26 | case webhooks(repo: String) | 26 | case webhooks(repo: String) |
| 27 | case webhookDeliveries(repo: String) | 27 | case webhookDeliveries(repo: String) |
| 28 | case buildSecrets(repo: String) | ||
| 29 | case deployKeys(repo: String) | ||
| 28 | case profile(String) | 30 | case profile(String) |
| 29 | case account | 31 | case account |
| 30 | case tokens | 32 | case tokens |
gitbay/Views/Repos/RepoSettingsView.swift +10
| @@ -551,6 +551,16 @@ struct RepoSettingsView: View { | |||
| 551 | .font(.gbSans(.subheadline)) | 551 | .font(.gbSans(.subheadline)) |
| 552 | } | 552 | } |
| 553 | .accessibilityIdentifier("settings-webhooks-link") | 553 | .accessibilityIdentifier("settings-webhooks-link") |
| 554 | NavigationLink(value: RepoRoute.buildSecrets(repo: model.repoPath)) { | ||
| 555 | Label("Build secrets", systemImage: "key.horizontal") | ||
| 556 | .font(.gbSans(.subheadline)) | ||
| 557 | } | ||
| 558 | .accessibilityIdentifier("settings-secrets-link") | ||
| 559 | NavigationLink(value: RepoRoute.deployKeys(repo: model.repoPath)) { | ||
| 560 | Label("Deploy keys", systemImage: "key") | ||
| 561 | .font(.gbSans(.subheadline)) | ||
| 562 | } | ||
| 563 | .accessibilityIdentifier("settings-deploy-keys-link") | ||
| 554 | } | 564 | } |
| 555 | } | 565 | } |
| 556 | 566 | ||
gitbayTests/RepoCredentialTests.swift added +136
| @@ -0,0 +1,136 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private 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 | private func body(_ seen: StubProtocol.Seen) throws -> [String: Any] { | ||
| 16 | try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 17 | } | ||
| 18 | |||
| 19 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 20 | private let secretsJSON = #"{"protocol_version":1,"data":["DEPLOY_TOKEN","NPM_TOKEN"],"exit_code":0}"# | ||
| 21 | private let deployKeysJSON = """ | ||
| 22 | {"protocol_version":1,"data":[\ | ||
| 23 | {"fingerprint":"SHA256:abc","algo":"ssh-ed25519","mode":"rw","label":"ci@box","last_used_at":"2026-10-10T08:00:00.000Z"},\ | ||
| 24 | {"fingerprint":"SHA256:def","algo":"ssh-ed25519","mode":"ro","label":"","expires_at":"2026-11-10T08:00:00Z"}\ | ||
| 25 | ],"exit_code":0} | ||
| 26 | """ | ||
| 27 | |||
| 28 | @MainActor | ||
| 29 | struct BuildSecretsViewModelTests { | ||
| 30 | |||
| 31 | @Test func listsSecretNames() async throws { | ||
| 32 | let (client, stub) = try makeClient() | ||
| 33 | stub.enqueue(.init(status: 200, json: secretsJSON)) | ||
| 34 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 35 | await model.load() | ||
| 36 | #expect(model.state.value == ["DEPLOY_TOKEN", "NPM_TOKEN"]) | ||
| 37 | #expect(stub.seen.first?.url.query() == "argv=repo&argv=secret&argv=list&argv=krz/gitbay") | ||
| 38 | } | ||
| 39 | |||
| 40 | @Test func noSecretsIsAnEmptyState() async throws { | ||
| 41 | let (client, stub) = try makeClient() | ||
| 42 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":null,"exit_code":0}"#)) | ||
| 43 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 44 | await model.load() | ||
| 45 | guard case .empty = model.state else { | ||
| 46 | Issue.record("expected .empty, got \(model.state)") | ||
| 47 | return | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test func setSendsTheValueOnStdinNeverInArgv() async throws { | ||
| 52 | let (client, stub) = try makeClient() | ||
| 53 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"secret":"DEPLOY_TOKEN"},"exit_code":0}"#, match: "cmd")) | ||
| 54 | stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list")) | ||
| 55 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 56 | |||
| 57 | #expect(await model.set(name: " DEPLOY_TOKEN ", value: "s3cret")) | ||
| 58 | |||
| 59 | let sent = try body(try #require(stub.seen.first)) | ||
| 60 | #expect(sent["argv"] as? [String] == ["repo", "secret", "set", "krz/gitbay", "DEPLOY_TOKEN"]) | ||
| 61 | #expect(sent["stdin"] as? String == "s3cret") | ||
| 62 | #expect(model.state.value == ["DEPLOY_TOKEN", "NPM_TOKEN"]) | ||
| 63 | } | ||
| 64 | |||
| 65 | @Test func aRefusedNameSaysWhy() async throws { | ||
| 66 | let (client, stub) = try makeClient() | ||
| 67 | stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"secret names are env-var shaped: uppercase letters, digits, _","exit_code":2}"#)) | ||
| 68 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 69 | |||
| 70 | #expect(await model.set(name: "lower", value: "x") == false) | ||
| 71 | #expect(model.actionError == "secret names are env-var shaped: uppercase letters, digits, _") | ||
| 72 | } | ||
| 73 | |||
| 74 | @Test func removeNamesTheSecret() async throws { | ||
| 75 | let (client, stub) = try makeClient() | ||
| 76 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 77 | stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list")) | ||
| 78 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 79 | |||
| 80 | await model.remove("NPM_TOKEN") | ||
| 81 | |||
| 82 | #expect(try body(try #require(stub.seen.first))["argv"] as? [String] | ||
| 83 | == ["repo", "secret", "remove", "krz/gitbay", "NPM_TOKEN"]) | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | @MainActor | ||
| 88 | struct DeployKeysViewModelTests { | ||
| 89 | |||
| 90 | @Test func listsKeysWithModeAndDates() async throws { | ||
| 91 | let (client, stub) = try makeClient() | ||
| 92 | stub.enqueue(.init(status: 200, json: deployKeysJSON)) | ||
| 93 | let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay") | ||
| 94 | await model.load() | ||
| 95 | |||
| 96 | let keys = try #require(model.state.value) | ||
| 97 | #expect(keys.map(\.fingerprint) == ["SHA256:abc", "SHA256:def"]) | ||
| 98 | #expect(keys[0].canPush && !keys[1].canPush) | ||
| 99 | #expect(keys[0].lastUsedAt != nil && keys[0].expiresAt == nil) | ||
| 100 | #expect(keys[1].lastUsedAt == nil && keys[1].expiresAt != nil) | ||
| 101 | #expect(stub.seen.first?.url.query() == "argv=repo&argv=deploy-key&argv=list&argv=krz/gitbay") | ||
| 102 | } | ||
| 103 | |||
| 104 | @Test func addSendsTheKeyOnStdinWithModeAndLifetime() async throws { | ||
| 105 | let (client, stub) = try makeClient() | ||
| 106 | for _ in 0..<2 { | ||
| 107 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"fingerprint":"SHA256:x","mode":"ro"},"exit_code":0}"#, match: "cmd")) | ||
| 108 | stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list")) | ||
| 109 | } | ||
| 110 | let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay") | ||
| 111 | |||
| 112 | #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci\n", canPush: false, ttl: nil)) | ||
| 113 | #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci", canPush: true, ttl: "30d")) | ||
| 114 | |||
| 115 | let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body) | ||
| 116 | #expect(writes.map { $0["argv"] as? [String] } == [ | ||
| 117 | ["repo", "deploy-key", "add", "krz/gitbay"], | ||
| 118 | ["repo", "deploy-key", "add", "krz/gitbay", "--rw", "--ttl", "30d"], | ||
| 119 | ]) | ||
| 120 | #expect(writes.allSatisfy { $0["stdin"] as? String == "ssh-ed25519 AAAA ci" }) | ||
| 121 | } | ||
| 122 | |||
| 123 | @Test func removeNamesTheFingerprint() async throws { | ||
| 124 | let (client, stub) = try makeClient() | ||
| 125 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 126 | stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list")) | ||
| 127 | stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list")) | ||
| 128 | let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay") | ||
| 129 | await model.load() | ||
| 130 | |||
| 131 | await model.remove(try #require(model.state.value?.first)) | ||
| 132 | |||
| 133 | let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body) | ||
| 134 | #expect(writes.first?["argv"] as? [String] == ["repo", "deploy-key", "remove", "krz/gitbay", "SHA256:abc"]) | ||
| 135 | } | ||
| 136 | } | ||