account: SSH keys, PGP keys, email verification !9
7 files changed, +588 −1
gitbay/Account/AccountViewModel.swift added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// SSH keys, PGP keys, and email verification — the account rows the web | |
| 5 | /// has. Tokens stay SSH-only by design and get no UI here. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class AccountViewModel { | |
| 9 | ||
| 10 | nonisolated struct SSHKey: Decodable, Sendable, Hashable, Identifiable { | |
| 11 | let fingerprint: String | |
| 12 | let algo: String | |
| 13 | let scope: String | |
| 14 | var id: String { fingerprint } | |
| 15 | } | |
| 16 | ||
| 17 | nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable { | |
| 18 | let fingerprint: String | |
| 19 | /// The server stores the UID list JSON-encoded inside the field. | |
| 20 | let emails: String? | |
| 21 | var id: String { fingerprint } | |
| 22 | ||
| 23 | var emailList: [String] { | |
| 24 | guard let emails, | |
| 25 | let data = try? JSONDecoder().decode([String].self, from: Data(emails.utf8)) else { | |
| 26 | return [] | |
| 27 | } | |
| 28 | return data | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | nonisolated struct Loaded: Sendable, Hashable { | |
| 33 | let sshKeys: [SSHKey] | |
| 34 | let pgpKeys: [PGPKey] | |
| 35 | } | |
| 36 | ||
| 37 | private(set) var state: LoadState<Loaded> = .loading | |
| 38 | private(set) var actionError: String? | |
| 39 | /// Set after `email add` succeeds, so the screen can say a code is | |
| 40 | /// on its way. | |
| 41 | private(set) var notice: String? | |
| 42 | private(set) var working = false | |
| 43 | ||
| 44 | private let client: GitbayClient | |
| 45 | ||
| 46 | init(client: GitbayClient) { | |
| 47 | self.client = client | |
| 48 | } | |
| 49 | ||
| 50 | func load() async { | |
| 51 | do { | |
| 52 | async let ssh = client.readList(["keys", "list"], of: SSHKey.self) | |
| 53 | async let pgp = client.readList(["pgp", "list"], of: PGPKey.self) | |
| 54 | state = .loaded(Loaded(sshKeys: try await ssh, pgpKeys: try await pgp)) | |
| 55 | } catch { | |
| 56 | state = .from(error) | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | // MARK: - SSH keys | |
| 61 | ||
| 62 | /// `keys add [--scope full|git]` — the authorized_keys line travels | |
| 63 | /// as raw stdin; a public key is not a secret. | |
| 64 | func addSSHKey(_ publicKey: String, scope: String) async { | |
| 65 | await perform(["keys", "add", "--scope", scope], | |
| 66 | stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines)) | |
| 67 | } | |
| 68 | ||
| 69 | func removeSSHKey(_ key: SSHKey) async { | |
| 70 | await perform(["keys", "remove", key.fingerprint]) | |
| 71 | } | |
| 72 | ||
| 73 | // MARK: - PGP keys | |
| 74 | ||
| 75 | func addPGPKey(_ armored: String) async { | |
| 76 | await perform(["pgp", "add"], | |
| 77 | stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines)) | |
| 78 | } | |
| 79 | ||
| 80 | func removePGPKey(_ key: PGPKey) async { | |
| 81 | await perform(["pgp", "remove", key.fingerprint]) | |
| 82 | } | |
| 83 | ||
| 84 | // MARK: - Email | |
| 85 | ||
| 86 | func addEmail(_ address: String) async { | |
| 87 | await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)]) | |
| 88 | if actionError == nil { | |
| 89 | notice = "A verification code is on its way to \(address)." | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | func verifyEmail(code: String) async { | |
| 94 | await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)]) | |
| 95 | if actionError == nil { | |
| 96 | notice = "Email verified." | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | private func perform(_ argv: [String], stdin: String? = nil) async { | |
| 101 | working = true | |
| 102 | actionError = nil | |
| 103 | notice = nil | |
| 104 | defer { working = false } | |
| 105 | do { | |
| 106 | try await client.run(argv, stdin: stdin) | |
| 107 | await load() | |
| 108 | } catch let error as GitbayError { | |
| 109 | // Exit 2 is normally an app bug and stays generic, but on | |
| 110 | // this screen it validates pasted content ("not a valid | |
| 111 | // public key…", duplicates) — written for the person. | |
| 112 | if case .usage(let message) = error, !message.isEmpty { | |
| 113 | actionError = message | |
| 114 | } else { | |
| 115 | actionError = error.userFacingMessage | |
| 116 | } | |
| 117 | } catch { | |
| 118 | actionError = GitbayError.transport(error).userFacingMessage | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
gitbay/ContentView.swift +2
| @@ -77,6 +77,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 77 | 77 | GrepView(client: client, repo: repo) |
| 78 | 78 | case .profile(let name): |
| 79 | 79 | ProfileView(client: client, name: name) |
| 80 | case .account: | |
| 81 | AccountView(client: client) | |
| 80 | 82 | case .addAccount: |
| 81 | 83 | SignInView() |
| 82 | 84 | } |
gitbay/Views/Account/AccountView.swift added +285
| @@ -0,0 +1,285 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// SSH keys, PGP keys, and email verification. Tokens are minted over | |
| 4 | /// SSH only, by design — no UI implies otherwise. | |
| 5 | struct AccountView: View { | |
| 6 | ||
| 7 | @State private var model: AccountViewModel | |
| 8 | @State private var addingSSH = false | |
| 9 | @State private var addingPGP = false | |
| 10 | @State private var emailAddress = "" | |
| 11 | @State private var verifyCode = "" | |
| 12 | @State private var removingSSH: AccountViewModel.SSHKey? | |
| 13 | @State private var removingPGP: AccountViewModel.PGPKey? | |
| 14 | ||
| 15 | init(client: GitbayClient) { | |
| 16 | _model = State(initialValue: AccountViewModel(client: client)) | |
| 17 | } | |
| 18 | ||
| 19 | var body: some View { | |
| 20 | List { | |
| 21 | if let loaded = model.state.value { | |
| 22 | sshSection(loaded.sshKeys) | |
| 23 | pgpSection(loaded.pgpKeys) | |
| 24 | // Feedback sits beside the email actions: with the | |
| 25 | // keyboard up, a banner at the top of the list is | |
| 26 | // scrolled out of existence. | |
| 27 | if let error = model.actionError { | |
| 28 | Section { | |
| 29 | Label(error, systemImage: "hand.raised") | |
| 30 | .foregroundStyle(.orange) | |
| 31 | .font(.subheadline) | |
| 32 | } | |
| 33 | } | |
| 34 | if let notice = model.notice { | |
| 35 | Section { | |
| 36 | Label(notice, systemImage: "envelope") | |
| 37 | .foregroundStyle(.secondary) | |
| 38 | .font(.subheadline) | |
| 39 | } | |
| 40 | } | |
| 41 | emailSection | |
| 42 | } | |
| 43 | } | |
| 44 | .overlay { LoadStateOverlay(state: model.state) } | |
| 45 | .navigationTitle("Account") | |
| 46 | .navigationBarTitleDisplayMode(.inline) | |
| 47 | .task { await model.load() } | |
| 48 | .refreshable { await model.load() } | |
| 49 | .sheet(isPresented: $addingSSH) { | |
| 50 | KeyPasteSheet( | |
| 51 | heading: "Add SSH Key", | |
| 52 | prompt: "Paste an authorized_keys line (ssh-ed25519 AAAA…)", | |
| 53 | scopes: ["full", "git"], | |
| 54 | working: model.working, | |
| 55 | errorMessage: model.actionError | |
| 56 | ) { text, scope in | |
| 57 | Task { | |
| 58 | await model.addSSHKey(text, scope: scope ?? "full") | |
| 59 | if model.actionError == nil { addingSSH = false } | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | .sheet(isPresented: $addingPGP) { | |
| 64 | KeyPasteSheet( | |
| 65 | heading: "Add PGP Key", | |
| 66 | prompt: "Paste an armored public key (-----BEGIN PGP PUBLIC KEY BLOCK-----)", | |
| 67 | scopes: nil, | |
| 68 | working: model.working, | |
| 69 | errorMessage: model.actionError | |
| 70 | ) { text, _ in | |
| 71 | Task { | |
| 72 | await model.addPGPKey(text) | |
| 73 | if model.actionError == nil { addingPGP = false } | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | .confirmationDialog( | |
| 78 | "Remove this SSH key? Anything authenticating with it loses access.", | |
| 79 | isPresented: Binding( | |
| 80 | get: { removingSSH != nil }, | |
| 81 | set: { if !$0 { removingSSH = nil } } | |
| 82 | ) | |
| 83 | ) { | |
| 84 | Button("Remove", role: .destructive) { | |
| 85 | if let key = removingSSH { | |
| 86 | Task { await model.removeSSHKey(key) } | |
| 87 | } | |
| 88 | removingSSH = nil | |
| 89 | } | |
| 90 | Button("Cancel", role: .cancel) {} | |
| 91 | } | |
| 92 | .confirmationDialog( | |
| 93 | "Remove this PGP key? Commits it signed become unverifiable.", | |
| 94 | isPresented: Binding( | |
| 95 | get: { removingPGP != nil }, | |
| 96 | set: { if !$0 { removingPGP = nil } } | |
| 97 | ) | |
| 98 | ) { | |
| 99 | Button("Remove", role: .destructive) { | |
| 100 | if let key = removingPGP { | |
| 101 | Task { await model.removePGPKey(key) } | |
| 102 | } | |
| 103 | removingPGP = nil | |
| 104 | } | |
| 105 | Button("Cancel", role: .cancel) {} | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | // MARK: - Sections | |
| 110 | ||
| 111 | private func sshSection(_ keys: [AccountViewModel.SSHKey]) -> some View { | |
| 112 | Section { | |
| 113 | ForEach(keys) { key in | |
| 114 | VStack(alignment: .leading, spacing: 2) { | |
| 115 | Text(key.fingerprint) | |
| 116 | .font(.caption.monospaced()) | |
| 117 | .lineLimit(1) | |
| 118 | .truncationMode(.middle) | |
| 119 | HStack(spacing: 6) { | |
| 120 | Text(key.algo) | |
| 121 | Text(key.scope) | |
| 122 | .padding(.horizontal, 5) | |
| 123 | .padding(.vertical, 1) | |
| 124 | .background(.quaternary, in: Capsule()) | |
| 125 | } | |
| 126 | .font(.caption2) | |
| 127 | .foregroundStyle(.secondary) | |
| 128 | } | |
| 129 | .swipeActions { | |
| 130 | Button("Remove", role: .destructive) { | |
| 131 | removingSSH = key | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | Button { | |
| 136 | addingSSH = true | |
| 137 | } label: { | |
| 138 | Label("Add SSH Key", systemImage: "plus") | |
| 139 | .font(.subheadline) | |
| 140 | } | |
| 141 | .accessibilityIdentifier("add-ssh-key") | |
| 142 | } header: { | |
| 143 | Text("SSH keys") | |
| 144 | } footer: { | |
| 145 | Text("A git-scoped key can push and pull but not run account commands.") | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | private func pgpSection(_ keys: [AccountViewModel.PGPKey]) -> some View { | |
| 150 | Section { | |
| 151 | ForEach(keys) { key in | |
| 152 | VStack(alignment: .leading, spacing: 2) { | |
| 153 | Text(key.fingerprint) | |
| 154 | .font(.caption.monospaced()) | |
| 155 | .lineLimit(1) | |
| 156 | .truncationMode(.middle) | |
| 157 | if !key.emailList.isEmpty { | |
| 158 | Text(key.emailList.joined(separator: ", ")) | |
| 159 | .font(.caption2) | |
| 160 | .foregroundStyle(.secondary) | |
| 161 | } | |
| 162 | } | |
| 163 | .swipeActions { | |
| 164 | Button("Remove", role: .destructive) { | |
| 165 | removingPGP = key | |
| 166 | } | |
| 167 | } | |
| 168 | } | |
| 169 | Button { | |
| 170 | addingPGP = true | |
| 171 | } label: { | |
| 172 | Label("Add PGP Key", systemImage: "plus") | |
| 173 | .font(.subheadline) | |
| 174 | } | |
| 175 | .accessibilityIdentifier("add-pgp-key") | |
| 176 | } header: { | |
| 177 | Text("PGP keys") | |
| 178 | } footer: { | |
| 179 | Text("Signed commits verify against these; the badge on the log names the state.") | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | private var emailSection: some View { | |
| 184 | Section { | |
| 185 | HStack { | |
| 186 | TextField("Add email address", text: $emailAddress) | |
| 187 | .keyboardType(.emailAddress) | |
| 188 | .autocorrectionDisabled() | |
| 189 | .textInputAutocapitalization(.never) | |
| 190 | .accessibilityIdentifier("email-address") | |
| 191 | Button("Add") { | |
| 192 | let address = emailAddress | |
| 193 | emailAddress = "" | |
| 194 | Task { await model.addEmail(address) } | |
| 195 | } | |
| 196 | .font(.caption) | |
| 197 | .disabled(!emailAddress.contains("@") || model.working) | |
| 198 | } | |
| 199 | HStack { | |
| 200 | TextField("Verification code", text: $verifyCode) | |
| 201 | .autocorrectionDisabled() | |
| 202 | .textInputAutocapitalization(.never) | |
| 203 | .accessibilityIdentifier("email-code") | |
| 204 | Button("Verify") { | |
| 205 | let code = verifyCode | |
| 206 | verifyCode = "" | |
| 207 | Task { await model.verifyEmail(code: code) } | |
| 208 | } | |
| 209 | .font(.caption) | |
| 210 | .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 211 | .accessibilityIdentifier("email-verify") | |
| 212 | } | |
| 213 | } header: { | |
| 214 | Text("Email") | |
| 215 | } footer: { | |
| 216 | Text("Adding an address mails a code; commits carry your verified identity.") | |
| 217 | } | |
| 218 | } | |
| 219 | } | |
| 220 | ||
| 221 | /// Paste-a-key sheet shared by SSH and PGP. Public keys are not secrets. | |
| 222 | private struct KeyPasteSheet: View { | |
| 223 | ||
| 224 | let heading: String | |
| 225 | let prompt: String | |
| 226 | let scopes: [String]? | |
| 227 | let working: Bool | |
| 228 | let errorMessage: String? | |
| 229 | let onSubmit: (String, String?) -> Void | |
| 230 | ||
| 231 | @Environment(\.dismiss) private var dismiss | |
| 232 | @State private var text = "" | |
| 233 | @State private var scope = "full" | |
| 234 | ||
| 235 | var body: some View { | |
| 236 | NavigationStack { | |
| 237 | Form { | |
| 238 | Section { | |
| 239 | TextEditor(text: $text) | |
| 240 | .frame(minHeight: 120) | |
| 241 | .font(.caption.monospaced()) | |
| 242 | .autocorrectionDisabled() | |
| 243 | .textInputAutocapitalization(.never) | |
| 244 | .accessibilityIdentifier("key-paste-text") | |
| 245 | } footer: { | |
| 246 | Text(prompt) | |
| 247 | } | |
| 248 | if let scopes { | |
| 249 | Section { | |
| 250 | Picker("Scope", selection: $scope) { | |
| 251 | ForEach(scopes, id: \.self) { Text($0).tag($0) } | |
| 252 | } | |
| 253 | .pickerStyle(.segmented) | |
| 254 | } | |
| 255 | } | |
| 256 | if let errorMessage { | |
| 257 | Section { | |
| 258 | Label(errorMessage, systemImage: "exclamationmark.triangle") | |
| 259 | .foregroundStyle(.red) | |
| 260 | .font(.subheadline) | |
| 261 | } | |
| 262 | } | |
| 263 | } | |
| 264 | .navigationTitle(heading) | |
| 265 | .navigationBarTitleDisplayMode(.inline) | |
| 266 | .toolbar { | |
| 267 | ToolbarItem(placement: .cancellationAction) { | |
| 268 | Button("Cancel") { dismiss() } | |
| 269 | } | |
| 270 | ToolbarItem(placement: .confirmationAction) { | |
| 271 | if working { | |
| 272 | ProgressView() | |
| 273 | } else { | |
| 274 | Button("Add") { | |
| 275 | onSubmit(text, scopes != nil ? scope : nil) | |
| 276 | } | |
| 277 | .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 278 | .accessibilityIdentifier("key-paste-submit") | |
| 279 | } | |
| 280 | } | |
| 281 | } | |
| 282 | .interactiveDismissDisabled(working) | |
| 283 | } | |
| 284 | } | |
| 285 | } | |
gitbay/Views/Dashboard/DashboardView.swift +9 −1
| @@ -30,7 +30,15 @@ struct DashboardView: View { | ||
| 30 | 30 | } |
| 31 | 31 | .overlay { LoadStateOverlay(state: model.state) } |
| 32 | 32 | .navigationTitle("Dashboard") |
| 33 | .toolbar { AccountMenu() } | |
| 33 | .toolbar { | |
| 34 | ToolbarItem(placement: .topBarTrailing) { | |
| 35 | NavigationLink(value: RepoRoute.account) { | |
| 36 | Image(systemName: "key") | |
| 37 | } | |
| 38 | .accessibilityIdentifier("account-screen-link") | |
| 39 | } | |
| 40 | AccountMenu() | |
| 41 | } | |
| 34 | 42 | .task { await model.load() } |
| 35 | 43 | .refreshable { await model.load() } |
| 36 | 44 | } |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -10,5 +10,6 @@ nonisolated enum RepoRoute: Hashable { | ||
| 10 | 10 | case settings(repo: String) |
| 11 | 11 | case grep(repo: String) |
| 12 | 12 | case profile(String) |
| 13 | case account | |
| 13 | 14 | case addAccount |
| 14 | 15 | } |
gitbayTests/AccountTests.swift added +115
| @@ -0,0 +1,115 @@ | ||
| 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 argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { | |
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 17 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | |
| 18 | } | |
| 19 | ||
| 20 | private let keysJSON = """ | |
| 21 | {"protocol_version":1,"data":[\ | |
| 22 | {"fingerprint":"SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY","algo":"ssh-ed25519","scope":"full"}\ | |
| 23 | ],"exit_code":0} | |
| 24 | """ | |
| 25 | private let pgpJSON = """ | |
| 26 | {"protocol_version":1,"data":[\ | |
| 27 | {"fingerprint":"3917973fb159bbb86194538569451a517ac0cb37",\ | |
| 28 | "emails":"[\\"hello@cleberg.net\\"]"}],"exit_code":0} | |
| 29 | """ | |
| 30 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 31 | ||
| 32 | @MainActor | |
| 33 | struct AccountViewModelTests { | |
| 34 | ||
| 35 | private func loadedModel() async throws -> (AccountViewModel, StubProtocol.Box) { | |
| 36 | let (client, stub) = try makeClient() | |
| 37 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) | |
| 38 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) | |
| 39 | let model = AccountViewModel(client: client) | |
| 40 | await model.load() | |
| 41 | return (model, stub) | |
| 42 | } | |
| 43 | ||
| 44 | @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws { | |
| 45 | let (model, _) = try await loadedModel() | |
| 46 | ||
| 47 | let loaded = try #require(model.state.value) | |
| 48 | #expect(loaded.sshKeys.first?.algo == "ssh-ed25519") | |
| 49 | // The UID list is JSON-encoded inside the JSON field. | |
| 50 | #expect(loaded.pgpKeys.first?.emailList == ["hello@cleberg.net"]) | |
| 51 | } | |
| 52 | ||
| 53 | @Test func sshKeyTravelsAsRawStdinWithScope() async throws { | |
| 54 | let (model, stub) = try await loadedModel() | |
| 55 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 56 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) | |
| 57 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) | |
| 58 | ||
| 59 | await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone\n", scope: "git") | |
| 60 | ||
| 61 | let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) | |
| 62 | // No --file - here: keys add reads bare stdin. | |
| 63 | #expect(argv == ["keys", "add", "--scope", "git"]) | |
| 64 | #expect(stdin == "ssh-ed25519 AAAAC3Nza phone") | |
| 65 | } | |
| 66 | ||
| 67 | @Test func removalsTargetTheFingerprint() async throws { | |
| 68 | let (model, stub) = try await loadedModel() | |
| 69 | for _ in 0..<2 { | |
| 70 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 71 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) | |
| 72 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) | |
| 73 | } | |
| 74 | let loaded = try #require(model.state.value) | |
| 75 | ||
| 76 | await model.removeSSHKey(loaded.sshKeys[0]) | |
| 77 | await model.removePGPKey(loaded.pgpKeys[0]) | |
| 78 | ||
| 79 | let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } | |
| 80 | #expect(writes[0] == ["keys", "remove", "SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY"]) | |
| 81 | #expect(writes[1] == ["pgp", "remove", "3917973fb159bbb86194538569451a517ac0cb37"]) | |
| 82 | } | |
| 83 | ||
| 84 | @Test func emailAddAndVerifySendTheirCommands() async throws { | |
| 85 | let (model, stub) = try await loadedModel() | |
| 86 | for _ in 0..<2 { | |
| 87 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 88 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) | |
| 89 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) | |
| 90 | } | |
| 91 | ||
| 92 | await model.addEmail(" claude@cleberg.net ") | |
| 93 | #expect(model.notice?.contains("on its way") == true) | |
| 94 | await model.verifyEmail(code: "123456") | |
| 95 | #expect(model.notice == "Email verified.") | |
| 96 | ||
| 97 | let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } | |
| 98 | #expect(writes[0] == ["email", "add", "claude@cleberg.net"]) | |
| 99 | #expect(writes[1] == ["email", "verify", "123456"]) | |
| 100 | } | |
| 101 | ||
| 102 | @Test func contentValidationUsageErrorsSurfaceVerbatimHere() async throws { | |
| 103 | let (model, stub) = try await loadedModel() | |
| 104 | stub.enqueue(.init(status: 400, json: | |
| 105 | #"{"protocol_version":1,"error":"not a valid public key in authorized_keys format: illegal base64","exit_code":2}"#, | |
| 106 | match: "cmd")) | |
| 107 | ||
| 108 | await model.addSSHKey("garbage", scope: "full") | |
| 109 | ||
| 110 | // Exit 2 stays generic app-wide; on this screen it validates | |
| 111 | // pasted content and the message is for the person. | |
| 112 | #expect(model.actionError == | |
| 113 | "not a valid public key in authorized_keys format: illegal base64") | |
| 114 | } | |
| 115 | } | |
gitbayUITests/LiveSmokeUITests.swift +55
| @@ -360,3 +360,58 @@ extension LiveSmokeUITests { | ||
| 360 | 360 | app.buttons["Cancel"].firstMatch.tap() |
| 361 | 361 | } |
| 362 | 362 | } |
| 363 | ||
| 364 | extension LiveSmokeUITests { | |
| 365 | ||
| 366 | /// Account keys and email. Read-only plus refusal paths — no key is | |
| 367 | /// added or removed, no mail is sent. | |
| 368 | func testAccountFlows() throws { | |
| 369 | // Dashboard toolbar -> account screen. | |
| 370 | let accountLink = app.descendants(matching: .any) | |
| 371 | .matching(identifier: "account-screen-link").firstMatch | |
| 372 | XCTAssertTrue(accountLink.waitForExistence(timeout: 10)) | |
| 373 | accountLink.tap() | |
| 374 | ||
| 375 | // Real keys render: SSH fingerprints and the PGP key's UID email. | |
| 376 | XCTAssertTrue(app.staticTexts | |
| 377 | .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch | |
| 378 | .waitForExistence(timeout: 15), "SSH keys missing") | |
| 379 | XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch | |
| 380 | .waitForExistence(timeout: 10), "PGP key UID missing") | |
| 381 | ||
| 382 | // Pasting garbage as an SSH key surfaces the server's validation. | |
| 383 | app.descendants(matching: .any).matching(identifier: "add-ssh-key") | |
| 384 | .firstMatch.tap() | |
| 385 | let paste = app.descendants(matching: .any) | |
| 386 | .matching(identifier: "key-paste-text").firstMatch | |
| 387 | XCTAssertTrue(paste.waitForExistence(timeout: 5)) | |
| 388 | paste.tap() | |
| 389 | paste.typeText("not a key") | |
| 390 | app.descendants(matching: .any).matching(identifier: "key-paste-submit") | |
| 391 | .firstMatch.tap() | |
| 392 | XCTAssertTrue(app.staticTexts | |
| 393 | .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch | |
| 394 | .waitForExistence(timeout: 15), "invalid-key refusal not surfaced") | |
| 395 | app.buttons["Cancel"].firstMatch.tap() | |
| 396 | ||
| 397 | // A bogus verification code is refused, not swallowed. | |
| 398 | let code = app.descendants(matching: .any) | |
| 399 | .matching(identifier: "email-code").firstMatch | |
| 400 | XCTAssertTrue(code.waitForExistence(timeout: 5)) | |
| 401 | code.tap() | |
| 402 | XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) | |
| 403 | code.typeText("000000") | |
| 404 | if (code.value as? String)?.contains("000000") != true { | |
| 405 | // The first keystrokes can race focus; type once more. | |
| 406 | code.typeText("000000") | |
| 407 | } | |
| 408 | let verify = app.descendants(matching: .any) | |
| 409 | .matching(identifier: "email-verify").firstMatch | |
| 410 | XCTAssertTrue(verify.waitForExistence(timeout: 5)) | |
| 411 | XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed") | |
| 412 | verify.tap() | |
| 413 | XCTAssertTrue(app.staticTexts | |
| 414 | .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch | |
| 415 | .waitForExistence(timeout: 15), "bad-code refusal not surfaced") | |
| 416 | } | |
| 417 | } | |