Repository settings: build secrets and deploy keys !182
6 files changed, +615 −0
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -327,6 +327,10 @@ private struct RouteDestinations: ViewModifier { | ||
| 327 | 327 | WebhookListView(client: client, repo: repo) |
| 328 | 328 | case .webhookDeliveries(let repo): |
| 329 | 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 | 334 | case .wiki(let repo): |
| 331 | 335 | WikiView(client: client, repo: repo) |
| 332 | 336 | case .wikiPage(let repo, let page): |
gitbay/Repos/RepoCredentials.swift added +145
| @@ -0,0 +1,145 @@ | ||
| 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 | // A refusal may mean the list changed elsewhere, e.g. a row | |
| 58 | // removed from another device. | |
| 59 | await load() | |
| 60 | return false | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | /// One row of `repo deploy-key list`. | |
| 65 | nonisolated struct DeployKey: Decodable, Sendable, Hashable, Identifiable { | |
| 66 | let fingerprint: String | |
| 67 | let algo: String | |
| 68 | /// `ro` or `rw`. | |
| 69 | let mode: String | |
| 70 | /// The key's comment; empty when it had none. | |
| 71 | let label: String | |
| 72 | var lastUsedAt: Date? = nil | |
| 73 | var expiresAt: Date? = nil | |
| 74 | var id: String { fingerprint } | |
| 75 | ||
| 76 | var canPush: Bool { mode == "rw" } | |
| 77 | ||
| 78 | enum CodingKeys: String, CodingKey { | |
| 79 | case fingerprint, algo, mode, label | |
| 80 | case lastUsedAt = "last_used_at" | |
| 81 | case expiresAt = "expires_at" | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /// `repo deploy-key list`, `add` and `remove`: keys bound to this one | |
| 86 | /// repository, read-only unless added with `--rw`. | |
| 87 | @Observable | |
| 88 | @MainActor | |
| 89 | final class DeployKeysViewModel { | |
| 90 | ||
| 91 | private(set) var state: LoadState<[DeployKey]> = .loading | |
| 92 | private(set) var actionError: String? | |
| 93 | private(set) var working = false | |
| 94 | ||
| 95 | private let client: GitbayClient | |
| 96 | let repoPath: String | |
| 97 | ||
| 98 | init(client: GitbayClient, repoPath: String) { | |
| 99 | self.client = client | |
| 100 | self.repoPath = repoPath | |
| 101 | } | |
| 102 | ||
| 103 | func load() async { | |
| 104 | do { | |
| 105 | let keys = try await client.readList(["repo", "deploy-key", "list", repoPath], of: DeployKey.self) | |
| 106 | state = keys.isEmpty ? .empty("No deploy keys yet.") : .loaded(keys) | |
| 107 | } catch { | |
| 108 | state = .from(error) | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | /// `repo deploy-key add <repo> [--rw] [--ttl <d>] < key.pub`. | |
| 113 | func add(publicKey: String, canPush: Bool, ttl: String?) async -> Bool { | |
| 114 | let key = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 115 | guard !key.isEmpty else { return false } | |
| 116 | var argv = ["repo", "deploy-key", "add", repoPath] | |
| 117 | if canPush { argv.append("--rw") } | |
| 118 | if let ttl { argv += ["--ttl", ttl] } | |
| 119 | return await perform(argv, stdin: key) | |
| 120 | } | |
| 121 | ||
| 122 | func remove(_ key: DeployKey) async { | |
| 123 | await perform(["repo", "deploy-key", "remove", repoPath, key.fingerprint]) | |
| 124 | } | |
| 125 | ||
| 126 | @discardableResult | |
| 127 | private func perform(_ argv: [String], stdin: String? = nil) async -> Bool { | |
| 128 | working = true | |
| 129 | actionError = nil | |
| 130 | defer { working = false } | |
| 131 | do { | |
| 132 | try await client.run(argv, stdin: stdin) | |
| 133 | await load() | |
| 134 | return true | |
| 135 | } catch let error as GitbayError { | |
| 136 | actionError = error.messageForTypedInput | |
| 137 | } catch { | |
| 138 | actionError = GitbayError.transport(error).userFacingMessage | |
| 139 | } | |
| 140 | // A refusal may mean the list changed elsewhere, e.g. a row | |
| 141 | // removed from another device. | |
| 142 | await load() | |
| 143 | return false | |
| 144 | } | |
| 145 | } | |
gitbay/Views/Repos/RepoCredentialViews.swift added +294
| @@ -0,0 +1,294 @@ | ||
| 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 | @State private var multiLine = false | |
| 72 | /// Errors shown here are this sheet's own, not an earlier list action's. | |
| 73 | @State private var attempted = false | |
| 74 | ||
| 75 | var body: some View { | |
| 76 | NavigationStack { | |
| 77 | Form { | |
| 78 | Section { | |
| 79 | TextField("NAME", text: $name) | |
| 80 | .font(.gbMono(.body)) | |
| 81 | .autocorrectionDisabled() | |
| 82 | .textInputAutocapitalization(.characters) | |
| 83 | .accessibilityIdentifier("secret-name") | |
| 84 | if multiLine { | |
| 85 | TextEditor(text: $value) | |
| 86 | .frame(minHeight: 120) | |
| 87 | .font(.gbMono(.caption)) | |
| 88 | .autocorrectionDisabled() | |
| 89 | .textInputAutocapitalization(.never) | |
| 90 | .accessibilityIdentifier("secret-value-multiline") | |
| 91 | } else { | |
| 92 | SecureField("Value", text: $value) | |
| 93 | .autocorrectionDisabled() | |
| 94 | .textInputAutocapitalization(.never) | |
| 95 | .accessibilityIdentifier("secret-value") | |
| 96 | } | |
| 97 | Toggle("Multi-line value", isOn: $multiLine) | |
| 98 | } footer: { | |
| 99 | Text(multiLine | |
| 100 | ? "Shown while you type, for a value with line breaks such as a private key. It is sent once and cannot be read back." | |
| 101 | : "Uppercase letters, digits and underscores. The value is sent once and cannot be read back.") | |
| 102 | } | |
| 103 | if attempted, let error = model.actionError { | |
| 104 | Section { GBNotice(error, .gbWarn) } | |
| 105 | } | |
| 106 | } | |
| 107 | .navigationTitle("Set Secret") | |
| 108 | .navigationBarTitleDisplayMode(.inline) | |
| 109 | .toolbar { | |
| 110 | ToolbarItem(placement: .cancellationAction) { | |
| 111 | Button("Cancel") { onDone() }.disabled(model.working) | |
| 112 | } | |
| 113 | ToolbarItem(placement: .confirmationAction) { | |
| 114 | if model.working { | |
| 115 | ProgressView() | |
| 116 | } else { | |
| 117 | Button("Set") { | |
| 118 | Task { | |
| 119 | attempted = true | |
| 120 | guard await model.set(name: name, value: value) else { return } | |
| 121 | // A secure field that still holds text as the sheet | |
| 122 | // goes makes iOS offer to save it as a password. | |
| 123 | value = "" | |
| 124 | await Task.yield() | |
| 125 | onDone() | |
| 126 | } | |
| 127 | } | |
| 128 | .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || value.isEmpty) | |
| 129 | .accessibilityIdentifier("secret-submit") | |
| 130 | } | |
| 131 | } | |
| 132 | } | |
| 133 | .interactiveDismissDisabled(model.working) | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | /// A repository's deploy keys: keys bound to it alone, read-only unless | |
| 139 | /// they may push, with when each was used and when it expires. | |
| 140 | struct DeployKeysView: View { | |
| 141 | ||
| 142 | @State private var model: DeployKeysViewModel | |
| 143 | @State private var adding = false | |
| 144 | @State private var removing: DeployKey? | |
| 145 | ||
| 146 | init(client: GitbayClient, repo: String) { | |
| 147 | _model = State(initialValue: DeployKeysViewModel(client: client, repoPath: repo)) | |
| 148 | } | |
| 149 | ||
| 150 | var body: some View { | |
| 151 | List { | |
| 152 | if let error = model.actionError, !adding { | |
| 153 | Section { GBNotice(error, .gbWarn) } | |
| 154 | } | |
| 155 | Section { | |
| 156 | ForEach(model.state.value ?? []) { key in | |
| 157 | VStack(alignment: .leading, spacing: 3) { | |
| 158 | if !key.label.isEmpty { | |
| 159 | Text(key.label).font(.gbSans(.subheadline)) | |
| 160 | } | |
| 161 | Text(key.fingerprint) | |
| 162 | .font(.gbMono(.caption)) | |
| 163 | .lineLimit(1) | |
| 164 | .truncationMode(.middle) | |
| 165 | HStack(spacing: 6) { | |
| 166 | GBChip(key.canPush ? "read-write" : "read-only", key.canPush ? Color.gbWarn : Color.secondary) | |
| 167 | dates(key) | |
| 168 | } | |
| 169 | } | |
| 170 | .padding(.vertical, 2) | |
| 171 | .accessibilityElement(children: .combine) | |
| 172 | .swipeActions { | |
| 173 | Button("Remove", role: .destructive) { removing = key } | |
| 174 | .disabled(model.working) | |
| 175 | } | |
| 176 | } | |
| 177 | } footer: { | |
| 178 | if model.state.value != nil { | |
| 179 | Text("A deploy key reaches this repository and nothing else. A read-write key can push.") | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | .overlay { LoadStateOverlay(state: model.state) } | |
| 184 | .navigationTitle("Deploy Keys") | |
| 185 | .navigationBarTitleDisplayMode(.inline) | |
| 186 | .toolbar { | |
| 187 | ToolbarItem(placement: .topBarTrailing) { | |
| 188 | Button { adding = true } label: { Image(systemName: "plus") } | |
| 189 | .accessibilityLabel("Add Deploy Key") | |
| 190 | .disabled(model.working) | |
| 191 | .accessibilityIdentifier("deploy-key-add-button") | |
| 192 | } | |
| 193 | } | |
| 194 | .sheet(isPresented: $adding) { | |
| 195 | DeployKeyAddSheet(model: model) { adding = false } | |
| 196 | } | |
| 197 | .confirmationDialog( | |
| 198 | "Remove the deploy key \(removing.map { $0.label.isEmpty ? $0.fingerprint : $0.label } ?? "")?", | |
| 199 | isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), | |
| 200 | titleVisibility: .visible | |
| 201 | ) { | |
| 202 | Button("Remove", role: .destructive) { | |
| 203 | if let key = removing { Task { await model.remove(key) } } | |
| 204 | removing = nil | |
| 205 | } | |
| 206 | Button("Cancel", role: .cancel) {} | |
| 207 | } | |
| 208 | .task { await model.load() } | |
| 209 | .refreshable { await model.load() } | |
| 210 | } | |
| 211 | ||
| 212 | private func dates(_ key: DeployKey) -> some View { | |
| 213 | HStack(spacing: 4) { | |
| 214 | if let used = key.lastUsedAt { | |
| 215 | Text("used \(used.formatted(.relative(presentation: .named)))") | |
| 216 | } else { | |
| 217 | Text("never used") | |
| 218 | } | |
| 219 | if let expires = key.expiresAt { | |
| 220 | Text("·") | |
| 221 | if expires <= .now { | |
| 222 | Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))") | |
| 223 | .foregroundStyle(Color.gbBad) | |
| 224 | } else { | |
| 225 | Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))") | |
| 226 | } | |
| 227 | } | |
| 228 | } | |
| 229 | .font(.gbSans(.caption2)) | |
| 230 | .foregroundStyle(.secondary) | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | private struct DeployKeyAddSheet: View { | |
| 235 | let model: DeployKeysViewModel | |
| 236 | let onDone: () -> Void | |
| 237 | ||
| 238 | @State private var text = "" | |
| 239 | @State private var canPush = false | |
| 240 | @State private var ttl: String? | |
| 241 | @State private var attempted = false | |
| 242 | ||
| 243 | var body: some View { | |
| 244 | NavigationStack { | |
| 245 | Form { | |
| 246 | Section { | |
| 247 | TextEditor(text: $text) | |
| 248 | .frame(minHeight: 120) | |
| 249 | .font(.gbMono(.caption)) | |
| 250 | .autocorrectionDisabled() | |
| 251 | .textInputAutocapitalization(.never) | |
| 252 | .accessibilityIdentifier("deploy-key-text") | |
| 253 | } footer: { | |
| 254 | Text("Paste the public key, as in authorized_keys. Its comment becomes the label.") | |
| 255 | } | |
| 256 | Section { | |
| 257 | Toggle("Allow pushes", isOn: $canPush) | |
| 258 | .accessibilityIdentifier("deploy-key-rw") | |
| 259 | Picker("Expires", selection: $ttl) { | |
| 260 | Text("Never").tag(String?.none) | |
| 261 | Text("In 30 days").tag(String?.some("30d")) | |
| 262 | Text("In 90 days").tag(String?.some("90d")) | |
| 263 | Text("In a year").tag(String?.some("365d")) | |
| 264 | } | |
| 265 | } | |
| 266 | if attempted, let error = model.actionError { | |
| 267 | Section { GBNotice(error, .gbWarn) } | |
| 268 | } | |
| 269 | } | |
| 270 | .navigationTitle("Add Deploy Key") | |
| 271 | .navigationBarTitleDisplayMode(.inline) | |
| 272 | .toolbar { | |
| 273 | ToolbarItem(placement: .cancellationAction) { | |
| 274 | Button("Cancel") { onDone() }.disabled(model.working) | |
| 275 | } | |
| 276 | ToolbarItem(placement: .confirmationAction) { | |
| 277 | if model.working { | |
| 278 | ProgressView() | |
| 279 | } else { | |
| 280 | Button("Add") { | |
| 281 | Task { | |
| 282 | attempted = true | |
| 283 | if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() } | |
| 284 | } | |
| 285 | } | |
| 286 | .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 287 | .accessibilityIdentifier("deploy-key-submit") | |
| 288 | } | |
| 289 | } | |
| 290 | } | |
| 291 | .interactiveDismissDisabled(model.working) | |
| 292 | } | |
| 293 | } | |
| 294 | } | |
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -25,6 +25,8 @@ nonisolated enum RepoRoute: Hashable { | ||
| 25 | 25 | case queryResults(name: String) |
| 26 | 26 | case webhooks(repo: String) |
| 27 | 27 | case webhookDeliveries(repo: String) |
| 28 | case buildSecrets(repo: String) | |
| 29 | case deployKeys(repo: String) | |
| 28 | 30 | case profile(String) |
| 29 | 31 | case account |
| 30 | 32 | case tokens |
gitbay/Views/Repos/RepoSettingsView.swift +10
| @@ -551,6 +551,16 @@ struct RepoSettingsView: View { | ||
| 551 | 551 | .font(.gbSans(.subheadline)) |
| 552 | 552 | } |
| 553 | 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 +160
| @@ -0,0 +1,160 @@ | ||
| 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 aFailedWriteStillRefreshesTheList() async throws { | |
| 75 | let (client, stub) = try makeClient() | |
| 76 | stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no secret NPM_TOKEN on krz/gitbay","exit_code":3}"#, match: "cmd")) | |
| 77 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":["DEPLOY_TOKEN"],"exit_code":0}"#, match: "argv=list")) | |
| 78 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | |
| 79 | ||
| 80 | await model.remove("NPM_TOKEN") | |
| 81 | ||
| 82 | #expect(model.actionError == "no secret NPM_TOKEN on krz/gitbay") | |
| 83 | #expect(model.state.value == ["DEPLOY_TOKEN"]) | |
| 84 | } | |
| 85 | ||
| 86 | @Test func aMultiLineValueKeepsItsNewlines() async throws { | |
| 87 | let (client, stub) = try makeClient() | |
| 88 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 89 | stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list")) | |
| 90 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | |
| 91 | let key = "-----BEGIN KEY-----\nabc\n-----END KEY-----\n" | |
| 92 | ||
| 93 | #expect(await model.set(name: "BOT_SSH_KEY", value: key)) | |
| 94 | ||
| 95 | #expect(try body(try #require(stub.seen.first))["stdin"] as? String == key) | |
| 96 | } | |
| 97 | ||
| 98 | @Test func removeNamesTheSecret() async throws { | |
| 99 | let (client, stub) = try makeClient() | |
| 100 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 101 | stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list")) | |
| 102 | let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay") | |
| 103 | ||
| 104 | await model.remove("NPM_TOKEN") | |
| 105 | ||
| 106 | #expect(try body(try #require(stub.seen.first))["argv"] as? [String] | |
| 107 | == ["repo", "secret", "remove", "krz/gitbay", "NPM_TOKEN"]) | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | @MainActor | |
| 112 | struct DeployKeysViewModelTests { | |
| 113 | ||
| 114 | @Test func listsKeysWithModeAndDates() async throws { | |
| 115 | let (client, stub) = try makeClient() | |
| 116 | stub.enqueue(.init(status: 200, json: deployKeysJSON)) | |
| 117 | let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay") | |
| 118 | await model.load() | |
| 119 | ||
| 120 | let keys = try #require(model.state.value) | |
| 121 | #expect(keys.map(\.fingerprint) == ["SHA256:abc", "SHA256:def"]) | |
| 122 | #expect(keys[0].canPush && !keys[1].canPush) | |
| 123 | #expect(keys[0].lastUsedAt != nil && keys[0].expiresAt == nil) | |
| 124 | #expect(keys[1].lastUsedAt == nil && keys[1].expiresAt != nil) | |
| 125 | #expect(stub.seen.first?.url.query() == "argv=repo&argv=deploy-key&argv=list&argv=krz/gitbay") | |
| 126 | } | |
| 127 | ||
| 128 | @Test func addSendsTheKeyOnStdinWithModeAndLifetime() async throws { | |
| 129 | let (client, stub) = try makeClient() | |
| 130 | for _ in 0..<2 { | |
| 131 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"fingerprint":"SHA256:x","mode":"ro"},"exit_code":0}"#, match: "cmd")) | |
| 132 | stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list")) | |
| 133 | } | |
| 134 | let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay") | |
| 135 | ||
| 136 | #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci\n", canPush: false, ttl: nil)) | |
| 137 | #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci", canPush: true, ttl: "30d")) | |
| 138 | ||
| 139 | let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body) | |
| 140 | #expect(writes.map { $0["argv"] as? [String] } == [ | |
| 141 | ["repo", "deploy-key", "add", "krz/gitbay"], | |
| 142 | ["repo", "deploy-key", "add", "krz/gitbay", "--rw", "--ttl", "30d"], | |
| 143 | ]) | |
| 144 | #expect(writes.allSatisfy { $0["stdin"] as? String == "ssh-ed25519 AAAA ci" }) | |
| 145 | } | |
| 146 | ||
| 147 | @Test func removeNamesTheFingerprint() async throws { | |
| 148 | let (client, stub) = try makeClient() | |
| 149 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 150 | stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list")) | |
| 151 | stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list")) | |
| 152 | let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay") | |
| 153 | await model.load() | |
| 154 | ||
| 155 | await model.remove(try #require(model.state.value?.first)) | |
| 156 | ||
| 157 | let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body) | |
| 158 | #expect(writes.first?["argv"] as? [String] == ["repo", "deploy-key", "remove", "krz/gitbay", "SHA256:abc"]) | |
| 159 | } | |
| 160 | } | |