Commit d12b57a119
Unsigned
Layout: unified · split
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 | GrepView(client: client, repo: repo) | 77 | GrepView(client: client, repo: repo) |
| 78 | case .profile(let name): | 78 | case .profile(let name): |
| 79 | ProfileView(client: client, name: name) | 79 | ProfileView(client: client, name: name) |
| 80 | case .account: | ||
| 81 | AccountView(client: client) | ||
| 80 | case .addAccount: | 82 | case .addAccount: |
| 81 | SignInView() | 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 | .overlay { LoadStateOverlay(state: model.state) } | 31 | .overlay { LoadStateOverlay(state: model.state) } |
| 32 | .navigationTitle("Dashboard") | 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 | .task { await model.load() } | 42 | .task { await model.load() } |
| 35 | .refreshable { await model.load() } | 43 | .refreshable { await model.load() } |
| 36 | } | 44 | } |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -10,5 +10,6 @@ nonisolated enum RepoRoute: Hashable { | |||
| 10 | case settings(repo: String) | 10 | case settings(repo: String) |
| 11 | case grep(repo: String) | 11 | case grep(repo: String) |
| 12 | case profile(String) | 12 | case profile(String) |
| 13 | case account | ||
| 13 | case addAccount | 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 | app.buttons["Cancel"].firstMatch.tap() | 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 | } | ||