import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return (try #require(body["argv"] as? [String]), body["stdin"] as? String) } private let keysJSON = """ {"protocol_version":1,"data":[\ {"fingerprint":"SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY","algo":"ssh-ed25519","scope":"full"}\ ],"exit_code":0} """ private let pgpJSON = """ {"protocol_version":1,"data":[\ {"fingerprint":"3917973fb159bbb86194538569451a517ac0cb37",\ "emails":"[\\"hello@cleberg.net\\"]"}],"exit_code":0} """ private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# private let orgListJSON = """ {"protocol_version":1,"data":[{"org":"krz","role":"admin"}],"exit_code":0} """ @MainActor struct AccountViewModelTests { private func loadedModel() async throws -> (AccountViewModel, StubProtocol.Box) { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) let model = AccountViewModel(client: client) await model.load() return (model, stub) } /// v1.16.0: `notifications settings show` says whether activity mail /// is on; `notifications settings mail on|off` flips it. The read is /// separate from the keys, and an older server leaves it unknown. /// A server before v1.19.0 answers without `watch`, which stays nil. @Test func mailPreferenceLoadsAndFlips() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show")) await model.loadNotificationPreferences() #expect(model.mailOn == true) #expect(model.watchOn == nil) stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show")) await model.setMail(false) let write = try #require(stub.seen.first { $0.method == "POST" }) let (argv, _) = try argvOf(write) #expect(argv == ["notifications", "settings", "mail", "off"]) #expect(model.mailOn == false) } /// v1.19.0: `show` also reports `watch`, and `notifications settings /// watch on|off` flips it. @Test func watchPreferenceLoadsAndFlips() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}"#, match: "argv=settings&argv=show")) await model.loadNotificationPreferences() #expect(model.mailOn == true) #expect(model.watchOn == false) stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":true},"exit_code":0}"#, match: "argv=settings&argv=show")) await model.setWatch(true) let write = try #require(stub.seen.first { $0.method == "POST" }) let (argv, _) = try argvOf(write) #expect(argv == ["notifications", "settings", "watch", "on"]) #expect(model.watchOn == true) } @Test func anOlderServerLeavesTheNotificationPreferencesUnknown() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show")) await model.loadNotificationPreferences() #expect(model.mailOn == nil) #expect(model.watchOn == nil) #expect(model.actionError == nil) } /// v1.16.0's `email list`, `email remove` and `email primary`. The /// list is its own read, and an older server leaves it nil. @Test func emailsLoadWithVerifiedAndPrimaryFlags() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[\ {"address":"hello@cleberg.net","verified":true,"verified_by":"code","primary":true},\ {"address":"c@example.test","verified":false,"primary":false}],"exit_code":0} """, match: "argv=email&argv=list")) await model.loadEmails() let emails = try #require(model.emails) #expect(emails.map(\.address) == ["hello@cleberg.net", "c@example.test"]) #expect(emails[0].verified && emails[0].primary) #expect(!emails[1].verified && !emails[1].primary) } @Test func anOlderServerLeavesTheEmailListUnknown() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=email&argv=list")) await model.loadEmails() #expect(model.emails == nil) #expect(model.actionError == nil) } @Test func removeAndPrimarySendTheirCommandsThenReloadEmails() async throws { let (model, stub) = try await loadedModel() let listJSON = #"{"protocol_version":1,"data":[{"address":"hello@cleberg.net","verified":true,"primary":true}],"exit_code":0}"# for _ in 0..<2 { stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: listJSON, match: "argv=email&argv=list")) } await model.removeEmail("c@example.test") await model.setPrimaryEmail("hello@cleberg.net") let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } #expect(writes == [ ["email", "remove", "c@example.test"], ["email", "primary", "hello@cleberg.net"], ]) #expect(stub.seen.filter { $0.url.query()?.contains("argv=email&argv=list") == true }.count == 2) } @Test func loadsBothKeyListsAndDecodesNestedEmails() async throws { let (model, _) = try await loadedModel() let loaded = try #require(model.state.value) #expect(loaded.sshKeys.first?.algo == "ssh-ed25519") // The UID list is JSON-encoded inside the JSON field. #expect(loaded.pgpKeys.first?.emailList == ["hello@cleberg.net"]) } @Test func sshKeyTravelsAsRawStdinWithScope() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone\n", scope: "git") let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) // No --file - here: keys add reads bare stdin. #expect(argv == ["keys", "add", "--scope", "git"]) #expect(stdin == "ssh-ed25519 AAAAC3Nza phone") } @Test func removalsTargetTheFingerprint() async throws { let (model, stub) = try await loadedModel() for _ in 0..<2 { stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) } let loaded = try #require(model.state.value) await model.removeSSHKey(loaded.sshKeys[0]) await model.removePGPKey(loaded.pgpKeys[0]) let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } #expect(writes[0] == ["keys", "remove", "SHA256:15jrWGl3s3BB1CeG0z9TfGnyf35l8lcBWoYfA0+IJbY"]) #expect(writes[1] == ["pgp", "remove", "3917973fb159bbb86194538569451a517ac0cb37"]) } @Test func emailAddAndVerifySendTheirCommands() async throws { let (model, stub) = try await loadedModel() for _ in 0..<2 { stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) } await model.addEmail(" claude@cleberg.net ") #expect(model.notice?.contains("on its way") == true) await model.verifyEmail(code: "123456") #expect(model.notice == "Email verified.") let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } #expect(writes[0] == ["email", "add", "claude@cleberg.net"]) #expect(writes[1] == ["email", "verify", "123456"]) } @Test func contentValidationUsageErrorsSurfaceVerbatimHere() async throws { let (model, stub) = try await loadedModel() stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"not a valid public key in authorized_keys format: illegal base64","exit_code":2}"#, match: "cmd")) await model.addSSHKey("garbage", scope: "full") // Exit 2 stays generic app-wide; on this screen it validates // pasted content and the message is for the person. #expect(model.actionError == "not a valid public key in authorized_keys format: illegal base64") } }