gitbayTests/AdminAccountTests.swift
167 lines · 8003 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 posted(_ stub: StubProtocol.Box) throws -> [([String], String?)] {
16 try stub.seen.filter { $0.method == "POST" }.map { seen in
17 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
18 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
19 }
20}
21
22private let showJSON = """
23 {"protocol_version":1,"data":{"username":"other","state":"active","admin":true,\
24 "created_at":"2026-10-14T07:49:15.505Z","keys":[{"fingerprint":"SHA256:a","algo":"ssh-ed25519",\
25 "scope":"full","label":"other","created_at":"2026-10-14T07:49:15.505Z"}],\
26 "emails":[{"address":"other@example.test","verified":true,"verified_by":"admin","primary":true}],\
27 "pgp_keys":[],"orgs":[{"org":"acme","role":"admin"}],"repos":2,"repo_limit":0,"byte_limit":0,\
28 "api_tokens":[{"name":"ci","scope":"read","created_at":"2026-10-14T07:49:15.505Z"}],"web_sessions":1}}
29 """
30
31private let limitsJSON = """
32 {"protocol_version":1,"data":{"user":"other","repos":10,"bytes":0,"orgs":0,"repos_owned":2,\
33 "bytes_owned":4096,"orgs_created":0,"override":true}}
34 """
35
36private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
37
38@MainActor
39struct AdminAccountTests {
40
41 private func loaded() async throws -> (AdminAccountViewModel, StubProtocol.Box) {
42 let (client, stub) = try makeClient()
43 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
44 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
45 let model = AdminAccountViewModel(client: client, username: "other")
46 await model.load()
47 return (model, stub)
48 }
49
50 @Test func anAccountLoadsWithItsLimits() async throws {
51 let (model, _) = try await loaded()
52 let loaded = try #require(model.state.value)
53 #expect(loaded.detail.keys.first?.label == "other")
54 #expect(loaded.detail.emails.first?.primary == true)
55 #expect(loaded.detail.orgs.first?.org == "acme")
56 #expect(loaded.detail.apiTokens.first?.name == "ci")
57 #expect(loaded.detail.webSessions == 1)
58 #expect(loaded.limits?.repos == 10)
59 #expect(loaded.limits?.reposOwned == 2)
60 #expect(loaded.limits?.override == true)
61 }
62
63 @Test func limitFlagsLeaveEmptyFieldsOutAndScaleMegabytes() {
64 #expect(OwnerLimits.flags(repos: "5", megabytes: "", orgs: "default")
65 == ["--repos", "5", "--orgs", "default"])
66 #expect(OwnerLimits.flags(repos: "", megabytes: "2", orgs: nil) == ["--bytes", "2000000"])
67 #expect(OwnerLimits.flags(repos: "x", megabytes: "", orgs: nil) == nil)
68 #expect(OwnerLimits.flags(repos: "-1", megabytes: "", orgs: nil) == nil)
69 #expect(OwnerLimits.flags(repos: "", megabytes: "", orgs: "") == [])
70 // A cap too large to hold in bytes is refused, not a crash.
71 #expect(OwnerLimits.flags(repos: "", megabytes: "10000000000000", orgs: nil) == nil)
72 }
73
74 @Test func settingLimitsSendsTheChangedCaps() async throws {
75 let (model, stub) = try await loaded()
76 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
78 stub.enqueue(.init(status: 200, json: limitsJSON, match: "cmd"))
79
80 #expect(await model.setLimits(repos: "20", megabytes: "", orgs: "0"))
81
82 // The first post is the load's read of the caps; the write follows.
83 #expect(try posted(stub).map(\.0) == [["admin", "user", "limits", "other"],
84 ["admin", "user", "limits", "other", "--repos", "20", "--orgs", "0"],
85 ["admin", "user", "limits", "other"]])
86 }
87
88 @Test func aMalformedCapIsRefusedBeforeAsking() async throws {
89 let (model, stub) = try await loaded()
90
91 #expect(!(await model.setLimits(repos: "lots", megabytes: "", orgs: "")))
92 #expect(try posted(stub).count == 1) // the load's read only
93 #expect(model.actionError != nil)
94 }
95
96 @Test func deletingSendsYes() async throws {
97 let (model, stub) = try await loaded()
98 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"deleted":"other"},"exit_code":0}"#, match: "cmd"))
99
100 #expect(await model.delete())
101 #expect(try posted(stub).map(\.0).last == ["admin", "user", "delete", "other", "--yes"])
102 }
103}
104
105@MainActor
106struct AdminCreateInviteTests {
107
108 @Test func creatingSendsTheKeyOnStdin() async throws {
109 let (client, stub) = try makeClient()
110 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"user":"dee"},"exit_code":0}"#, match: "cmd"))
111 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#, match: "argv=list"))
112 let model = AdminUsersViewModel(client: client)
113
114 let made = await model.create(username: " dee ", email: "dee@example.test", verified: true, admin: false,
115 key: "ssh-ed25519 AAAA dee\n")
116
117 #expect(made)
118 let (argv, stdin) = try #require(try posted(stub).first)
119 #expect(argv == ["admin", "user", "create", "dee", "--email", "dee@example.test", "--verified", "--key", "-"])
120 #expect(stdin == "ssh-ed25519 AAAA dee")
121 }
122
123 @Test func noEmailMeansNoVerifiedFlagAndNoKeyMeansNoStdin() async throws {
124 let (client, stub) = try makeClient()
125 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
126 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#, match: "argv=list"))
127 let model = AdminUsersViewModel(client: client)
128
129 _ = await model.create(username: "eve", email: "", verified: true, admin: true, key: " ")
130
131 let (argv, stdin) = try #require(try posted(stub).first)
132 #expect(argv == ["admin", "user", "create", "eve", "--admin"])
133 #expect(stdin == nil)
134 }
135
136 @Test func anInviteThatCouldNotBeMailedHandsOverTheCode() async throws {
137 let (client, stub) = try makeClient()
138 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"email":"fay@example.test","mailed":false,"code":"abc123"},"exit_code":0}"#, match: "cmd"))
139 let model = AdminUsersViewModel(client: client)
140
141 let note = await model.invite(email: "fay@example.test")
142
143 #expect(try posted(stub).map(\.0) == [["admin", "invite", "--email", "fay@example.test"]])
144 #expect(note?.contains("abc123") == true)
145 }
146
147 @Test func aLimitsReadThatFailsSaysWhy() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
150 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"a read-scoped token cannot run that","exit_code":4}"#, match: "cmd"))
151 let model = AdminAccountViewModel(client: client, username: "other")
152
153 await model.load()
154
155 #expect(model.state.value?.limits == nil)
156 #expect(model.state.value?.limitsError == "a read-scoped token cannot run that")
157 }
158
159 @Test func aDeleteWhoseRetryFindsNothingIsDone() async throws {
160 let (client, stub) = try makeClient()
161 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"cleanup failed","exit_code":1}"#, match: "cmd"))
162 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no user \"other\"","exit_code":3}"#, match: "cmd"))
163 let model = AdminAccountViewModel(client: client, username: "other")
164
165 #expect(await model.delete())
166 }
167}