gitbayTests/AccountTests.swift
268 lines · 12327 bytes
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 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws {
183 let (model, stub) = try await loadedModel()
184 for _ in 0..<2 {
185 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
186 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
187 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
188 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
189 }
190
191 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone", scope: "git", label: "phone")
192 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone", scope: "git", label: " ")
193
194 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
195 #expect(writes[0] == ["keys", "add", "--scope", "git", "--label", "phone"])
196 #expect(writes[1] == ["keys", "add", "--scope", "git"])
197 }
198
199 /// `keys label <fingerprint> [<text>]`: no text clears the label.
200 @Test func labellingAKeySendsTheTextAndAnEmptyTextClears() async throws {
201 let (model, stub) = try await loadedModel()
202 for _ in 0..<2 {
203 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
204 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
205 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
206 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
207 }
208 let key = try #require(model.state.value).sshKeys[0]
209
210 await model.labelSSHKey(key, "homelab")
211 await model.labelSSHKey(key, " ")
212
213 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
214 #expect(writes[0] == ["keys", "label", key.fingerprint, "homelab"])
215 #expect(writes[1] == ["keys", "label", key.fingerprint])
216 }
217
218 @Test func removalsTargetTheFingerprint() async throws {
219 let (model, stub) = try await loadedModel()
220 for _ in 0..<2 {
221 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
222 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
223 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
224 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
225 }
226 let loaded = try #require(model.state.value)
227
228 await model.removeSSHKey(loaded.sshKeys[0])
229 await model.removePGPKey(loaded.pgpKeys[0])
230
231 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
232 #expect(writes[0] == ["keys", "remove", "SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY"])
233 #expect(writes[1] == ["pgp", "remove", "3917973fb159bbb86194538569451a517ac0cb37"])
234 }
235
236 @Test func emailAddAndVerifySendTheirCommands() async throws {
237 let (model, stub) = try await loadedModel()
238 for _ in 0..<2 {
239 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
240 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
241 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
242 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
243 }
244
245 await model.addEmail(" claude@cleberg.net ")
246 #expect(model.notice?.contains("on its way") == true)
247 await model.verifyEmail(code: "123456")
248 #expect(model.notice == "Email verified.")
249
250 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
251 #expect(writes[0] == ["email", "add", "claude@cleberg.net"])
252 #expect(writes[1] == ["email", "verify", "123456"])
253 }
254
255 @Test func contentValidationUsageErrorsSurfaceVerbatimHere() async throws {
256 let (model, stub) = try await loadedModel()
257 stub.enqueue(.init(status: 400, json:
258 #"{"protocol_version":1,"error":"not a valid public key in authorized_keys format: illegal base64","exit_code":2}"#,
259 match: "cmd"))
260
261 await model.addSSHKey("garbage", scope: "full")
262
263 // Exit 2 stays generic app-wide; on this screen it validates
264 // pasted content and the message is for the person.
265 #expect(model.actionError ==
266 "not a valid public key in authorized_keys format: illegal base64")
267 }
268}