gitbayTests/AccountTests.swift

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

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}