gitbayTests/AccountTests.swift

v1.13.0
gitbay-ios/gitbayTests/AccountTests.swift history · blame · raw

276 lines · 12697 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    /// 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}