gitbayTests/AccountTests.swift

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

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}