gitbayTests/AccountTests.swift
224 lines · 10167 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 ],"exit_code":0}
24 """
25private let pgpJSON = """
26 {"protocol_version":1,"data":[\
27 {"fingerprint":"3917973fb159bbb86194538569451a517ac0cb37",\
28 "emails":"[\\"hello@cleberg.net\\"]"}],"exit_code":0}
29 """
30private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
31private let orgListJSON = """
32 {"protocol_version":1,"data":[{"org":"krz","role":"admin"}],"exit_code":0}
33 """
34
35@MainActor
36struct AccountViewModelTests {
37
38 private func loadedModel() async throws -> (AccountViewModel, StubProtocol.Box) {
39 let (client, stub) = try makeClient()
40 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
41 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
42 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
43 let model = AccountViewModel(client: client)
44 await model.load()
45 return (model, stub)
46 }
47
48 /// v1.16.0: `notifications settings show` says whether activity mail
49 /// is on; `notifications settings mail on|off` flips it. The read is
50 /// separate from the keys, and an older server leaves it unknown.
51 /// A server before v1.19.0 answers without `watch`, which stays nil.
52 @Test func mailPreferenceLoadsAndFlips() async throws {
53 let (model, stub) = try await loadedModel()
54 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
55
56 await model.loadNotificationPreferences()
57
58 #expect(model.mailOn == true)
59 #expect(model.watchOn == nil)
60 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
61 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
62
63 await model.setMail(false)
64
65 let write = try #require(stub.seen.first { $0.method == "POST" })
66 let (argv, _) = try argvOf(write)
67 #expect(argv == ["notifications", "settings", "mail", "off"])
68 #expect(model.mailOn == false)
69 }
70
71 /// v1.19.0: `show` also reports `watch`, and `notifications settings
72 /// watch on|off` flips it.
73 @Test func watchPreferenceLoadsAndFlips() async throws {
74 let (model, stub) = try await loadedModel()
75 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
76
77 await model.loadNotificationPreferences()
78
79 #expect(model.mailOn == true)
80 #expect(model.watchOn == false)
81 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
82 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
83
84 await model.setWatch(true)
85
86 let write = try #require(stub.seen.first { $0.method == "POST" })
87 let (argv, _) = try argvOf(write)
88 #expect(argv == ["notifications", "settings", "watch", "on"])
89 #expect(model.watchOn == true)
90 }
91
92 @Test func anOlderServerLeavesTheNotificationPreferencesUnknown() async throws {
93 let (model, stub) = try await loadedModel()
94 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show"))
95
96 await model.loadNotificationPreferences()
97
98 #expect(model.mailOn == nil)
99 #expect(model.watchOn == nil)
100 #expect(model.actionError == nil)
101 }
102
103 /// v1.16.0's `email list`, `email remove` and `email primary`. The
104 /// list is its own read, and an older server leaves it nil.
105 @Test func emailsLoadWithVerifiedAndPrimaryFlags() async throws {
106 let (model, stub) = try await loadedModel()
107 stub.enqueue(.init(status: 200, json: """
108 {"protocol_version":1,"data":[\
109 {"address":"hello@cleberg.net","verified":true,"verified_by":"code","primary":true},\
110 {"address":"c@example.test","verified":false,"primary":false}],"exit_code":0}
111 """, match: "argv=email&argv=list"))
112
113 await model.loadEmails()
114
115 let emails = try #require(model.emails)
116 #expect(emails.map(\.address) == ["hello@cleberg.net", "c@example.test"])
117 #expect(emails[0].verified && emails[0].primary)
118 #expect(!emails[1].verified && !emails[1].primary)
119 }
120
121 @Test func anOlderServerLeavesTheEmailListUnknown() async throws {
122 let (model, stub) = try await loadedModel()
123 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=email&argv=list"))
124
125 await model.loadEmails()
126
127 #expect(model.emails == nil)
128 #expect(model.actionError == nil)
129 }
130
131 @Test func removeAndPrimarySendTheirCommandsThenReloadEmails() async throws {
132 let (model, stub) = try await loadedModel()
133 let listJSON = #"{"protocol_version":1,"data":[{"address":"hello@cleberg.net","verified":true,"primary":true}],"exit_code":0}"#
134 for _ in 0..<2 {
135 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
136 stub.enqueue(.init(status: 200, json: listJSON, match: "argv=email&argv=list"))
137 }
138
139 await model.removeEmail("c@example.test")
140 await model.setPrimaryEmail("hello@cleberg.net")
141
142 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
143 #expect(writes == [
144 ["email", "remove", "c@example.test"],
145 ["email", "primary", "hello@cleberg.net"],
146 ])
147 #expect(stub.seen.filter { $0.url.query()?.contains("argv=email&argv=list") == true }.count == 2)
148 }
149
150 @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws {
151 let (model, _) = try await loadedModel()
152
153 let loaded = try #require(model.state.value)
154 #expect(loaded.sshKeys.first?.algo == "ssh-ed25519")
155 // The UID list is JSON-encoded inside the JSON field.
156 #expect(loaded.pgpKeys.first?.emailList == ["hello@cleberg.net"])
157 }
158
159 @Test func sshKeyTravelsAsRawStdinWithScope() async throws {
160 let (model, stub) = try await loadedModel()
161 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
162 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
163 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
164 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
165
166 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone\n", scope: "git")
167
168 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
169 // No --file - here: keys add reads bare stdin.
170 #expect(argv == ["keys", "add", "--scope", "git"])
171 #expect(stdin == "ssh-ed25519 AAAAC3Nza phone")
172 }
173
174 @Test func removalsTargetTheFingerprint() async throws {
175 let (model, stub) = try await loadedModel()
176 for _ in 0..<2 {
177 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
178 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
179 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
180 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
181 }
182 let loaded = try #require(model.state.value)
183
184 await model.removeSSHKey(loaded.sshKeys[0])
185 await model.removePGPKey(loaded.pgpKeys[0])
186
187 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
188 #expect(writes[0] == ["keys", "remove", "SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY"])
189 #expect(writes[1] == ["pgp", "remove", "3917973fb159bbb86194538569451a517ac0cb37"])
190 }
191
192 @Test func emailAddAndVerifySendTheirCommands() async throws {
193 let (model, stub) = try await loadedModel()
194 for _ in 0..<2 {
195 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
196 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
197 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
198 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
199 }
200
201 await model.addEmail(" claude@cleberg.net ")
202 #expect(model.notice?.contains("on its way") == true)
203 await model.verifyEmail(code: "123456")
204 #expect(model.notice == "Email verified.")
205
206 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
207 #expect(writes[0] == ["email", "add", "claude@cleberg.net"])
208 #expect(writes[1] == ["email", "verify", "123456"])
209 }
210
211 @Test func contentValidationUsageErrorsSurfaceVerbatimHere() async throws {
212 let (model, stub) = try await loadedModel()
213 stub.enqueue(.init(status: 400, json:
214 #"{"protocol_version":1,"error":"not a valid public key in authorized_keys format: illegal base64","exit_code":2}"#,
215 match: "cmd"))
216
217 await model.addSSHKey("garbage", scope: "full")
218
219 // Exit 2 stays generic app-wide; on this screen it validates
220 // pasted content and the message is for the person.
221 #expect(model.actionError ==
222 "not a valid public key in authorized_keys format: illegal base64")
223 }
224}