gitbayTests/AccountTests.swift
276 lines · 12697 bytes
19 symbols in this file
makeClientargvOfAccountViewModelTestsloadedModelmailPreferenceLoadsAndFlipswatchPreferenceLoadsAndFlipsanOlderServerLeavesTheNotificationPreferencesUnknownemailsLoadWithVerifiedAndPrimaryFlagsanOlderServerLeavesTheEmailListUnknownremoveAndPrimarySendTheirCommandsThenReloadEmailsloadsBothKeyListsAndDecodesNestedEmailssshKeyTravelsAsRawStdinWithScopesshKeyLabelDecodessshKeyConfirmationIsTheFingerprintPrefixsshKeyAddPassesALabelOnlyWhenGivenlabellingAKeySendsTheTextAndAnEmptyTextClearsremovalsTargetTheFingerprintemailAddAndVerifySendTheirCommandscontentValidationUsageErrorsSurfaceVerbatimHere
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func 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
20private let keysJSON = """
21 {"protocol_version":1,"data":[\
22 {"fingerprint":"SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY","algo":"ssh-ed25519","scope":"full",\
23 "label":"cmc@mac"}\
24 ],"exit_code":0}
25 """
26private let pgpJSON = """
27 {"protocol_version":1,"data":[\
28 {"fingerprint":"3917973fb159bbb86194538569451a517ac0cb37",\
29 "emails":"[\\"hello@cleberg.net\\"]"}],"exit_code":0}
30 """
31private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
32private let orgListJSON = """
33 {"protocol_version":1,"data":[{"org":"krz","role":"admin"}],"exit_code":0}
34 """
35
36@MainActor
37struct AccountViewModelTests {
38
39 private func loadedModel() async throws -> (AccountViewModel, StubProtocol.Box) {
40 let (client, stub) = try makeClient()
41 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
42 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
43 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
44 let model = AccountViewModel(client: client)
45 await model.load()
46 return (model, stub)
47 }
48
49 /// v1.16.0: `notifications settings show` says whether activity mail
50 /// is on; `notifications settings mail on|off` flips it. The read is
51 /// separate from the keys, and an older server leaves it unknown.
52 /// A server before v1.19.0 answers without `watch`, which stays nil.
53 @Test func mailPreferenceLoadsAndFlips() async throws {
54 let (model, stub) = try await loadedModel()
55 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
56
57 await model.loadNotificationPreferences()
58
59 #expect(model.mailOn == true)
60 #expect(model.watchOn == nil)
61 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
62 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
63
64 await model.setMail(false)
65
66 let write = try #require(stub.seen.first { $0.method == "POST" })
67 let (argv, _) = try argvOf(write)
68 #expect(argv == ["notifications", "settings", "mail", "off"])
69 #expect(model.mailOn == false)
70 }
71
72 /// v1.19.0: `show` also reports `watch`, and `notifications settings
73 /// watch on|off` flips it.
74 @Test func watchPreferenceLoadsAndFlips() async throws {
75 let (model, stub) = try await loadedModel()
76 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
77
78 await model.loadNotificationPreferences()
79
80 #expect(model.mailOn == true)
81 #expect(model.watchOn == false)
82 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
83 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
84
85 await model.setWatch(true)
86
87 let write = try #require(stub.seen.first { $0.method == "POST" })
88 let (argv, _) = try argvOf(write)
89 #expect(argv == ["notifications", "settings", "watch", "on"])
90 #expect(model.watchOn == true)
91 }
92
93 @Test func anOlderServerLeavesTheNotificationPreferencesUnknown() async throws {
94 let (model, stub) = try await loadedModel()
95 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show"))
96
97 await model.loadNotificationPreferences()
98
99 #expect(model.mailOn == nil)
100 #expect(model.watchOn == nil)
101 #expect(model.actionError == nil)
102 }
103
104 /// v1.16.0's `email list`, `email remove` and `email primary`. The
105 /// list is its own read, and an older server leaves it nil.
106 @Test func emailsLoadWithVerifiedAndPrimaryFlags() async throws {
107 let (model, stub) = try await loadedModel()
108 stub.enqueue(.init(status: 200, json: """
109 {"protocol_version":1,"data":[\
110 {"address":"hello@cleberg.net","verified":true,"verified_by":"code","primary":true},\
111 {"address":"c@example.test","verified":false,"primary":false}],"exit_code":0}
112 """, match: "argv=email&argv=list"))
113
114 await model.loadEmails()
115
116 let emails = try #require(model.emails)
117 #expect(emails.map(\.address) == ["hello@cleberg.net", "c@example.test"])
118 #expect(emails[0].verified && emails[0].primary)
119 #expect(!emails[1].verified && !emails[1].primary)
120 }
121
122 @Test func anOlderServerLeavesTheEmailListUnknown() async throws {
123 let (model, stub) = try await loadedModel()
124 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=email&argv=list"))
125
126 await model.loadEmails()
127
128 #expect(model.emails == nil)
129 #expect(model.actionError == nil)
130 }
131
132 @Test func removeAndPrimarySendTheirCommandsThenReloadEmails() async throws {
133 let (model, stub) = try await loadedModel()
134 let listJSON = #"{"protocol_version":1,"data":[{"address":"hello@cleberg.net","verified":true,"primary":true}],"exit_code":0}"#
135 for _ in 0..<2 {
136 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
137 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=email&argv=list"))
138 }
139
140 await model.removeEmail("c@example.test")
141 await model.setPrimaryEmail("hello@cleberg.net")
142
143 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
144 #expect(writes == [
145 ["email", "remove", "c@example.test"],
146 ["email", "primary", "hello@cleberg.net"],
147 ])
148 #expect(stub.seen.filter { $0.url.query()?.contains("argv=email&argv=list") == true }.count == 2)
149 }
150
151 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws {
152 let (model, _) = try await loadedModel()
153
154 let loaded = try #require(model.state.value)
155 #expect(loaded.sshKeys.first?.algo == "ssh-ed25519")
156 // The UID list is JSON-encoded inside the JSON field.
157 #expect(loaded.pgpKeys.first?.emailList == ["hello@cleberg.net"])
158 }
159
160 @Test func sshKeyTravelsAsRawStdinWithScope() async throws {
161 let (model, stub) = try await loadedModel()
162 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
163 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
164 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
165 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
166
167 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone\n", scope: "git")
168
169 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
170 // No --file - here: keys add reads bare stdin.
171 #expect(argv == ["keys", "add", "--scope", "git"])
172 #expect(stdin == "ssh-ed25519 AAAAC3Nza phone")
173 }
174
175 /// v1.21.0: a key carries a label. It is omitempty, so an unlabelled
176 /// key has no key at all.
177 @Test func sshKeyLabelDecodes() async throws {
178 let (model, _) = try await loadedModel()
179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac")
180 }
181
182 /// The web's typed confirmation for removing a key: the first eight
183 /// characters of the fingerprint after `SHA256:`.
184 @Test func sshKeyConfirmationIsTheFingerprintPrefix() async throws {
185 let (model, _) = try await loadedModel()
186 let key = try #require(model.state.value?.sshKeys.first)
187 #expect(key.confirmation == "15jrWGl3")
188 }
189
190 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws {
191 let (model, stub) = try await loadedModel()
192 for _ in 0..<2 {
193 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
194 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
195 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
196 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
197 }
198
199 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone", scope: "git", label: "phone")
200 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone", scope: "git", label: " ")
201
202 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
203 #expect(writes[0] == ["keys", "add", "--scope", "git", "--label", "phone"])
204 #expect(writes[1] == ["keys", "add", "--scope", "git"])
205 }
206
207 /// `keys label <fingerprint> [<text>]`: no text clears the label.
208 @Test func labellingAKeySendsTheTextAndAnEmptyTextClears() async throws {
209 let (model, stub) = try await loadedModel()
210 for _ in 0..<2 {
211 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
212 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
213 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
214 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
215 }
216 let key = try #require(model.state.value).sshKeys[0]
217
218 await model.labelSSHKey(key, "homelab")
219 await model.labelSSHKey(key, " ")
220
221 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
222 #expect(writes[0] == ["keys", "label", key.fingerprint, "homelab"])
223 #expect(writes[1] == ["keys", "label", key.fingerprint])
224 }
225
226 @Test func removalsTargetTheFingerprint() async throws {
227 let (model, stub) = try await loadedModel()
228 for _ in 0..<2 {
229 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
230 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
231 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
232 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
233 }
234 let loaded = try #require(model.state.value)
235
236 await model.removeSSHKey(loaded.sshKeys[0])
237 await model.removePGPKey(loaded.pgpKeys[0])
238
239 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
240 #expect(writes[0] == ["keys", "remove", "SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY"])
241 #expect(writes[1] == ["pgp", "remove", "3917973fb159bbb86194538569451a517ac0cb37"])
242 }
243
244 @Test func emailAddAndVerifySendTheirCommands() async throws {
245 let (model, stub) = try await loadedModel()
246 for _ in 0..<2 {
247 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
248 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
249 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
250 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
251 }
252
253 await model.addEmail(" claude@cleberg.net ")
254 #expect(model.notice?.contains("on its way") == true)
255 await model.verifyEmail(code: "123456")
256 #expect(model.notice == "Email verified.")
257
258 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
259 #expect(writes[0] == ["email", "add", "claude@cleberg.net"])
260 #expect(writes[1] == ["email", "verify", "123456"])
261 }
262
263 @Test func contentValidationUsageErrorsSurfaceVerbatimHere() async throws {
264 let (model, stub) = try await loadedModel()
265 stub.enqueue(.init(status: 400, json:
266 #"{"protocol_version":1,"error":"not a valid public key in authorized_keys format: illegal base64","exit_code":2}"#,
267 match: "cmd"))
268
269 await model.addSSHKey("garbage", scope: "full")
270
271 // Exit 2 stays generic app-wide; on this screen it validates
272 // pasted content and the message is for the person.
273 #expect(model.actionError ==
274 "not a valid public key in authorized_keys format: illegal base64")
275 }
276}