Commit 64dbbfe57d
Unsigned
Layout: unified · split
gitbay/Discovery/ProfileViewModel.swift +15 −4
| @@ -32,6 +32,16 @@ final class ProfileViewModel { | ||
| 32 | 32 | |
| 33 | 33 | var isOrg: Bool { kind == "org" } |
| 34 | 34 | |
| 35 | /// Who may write this profile: a user edits their own, and an org | |
| 36 | /// is edited by its admins — the rule `org profile` enforces. | |
| 37 | func canEdit(by username: String?) -> Bool { | |
| 38 | guard let username else { return false } | |
| 39 | if isOrg { | |
| 40 | return (members ?? []).contains { $0.name == username && $0.role == "admin" } | |
| 41 | } | |
| 42 | return name == username | |
| 43 | } | |
| 44 | ||
| 35 | 45 | /// The filename the about text is rendered under. The server has |
| 36 | 46 | /// none to dispatch on either, so the stored format picks the |
| 37 | 47 | /// extension and anything but org is markdown. |
| @@ -123,12 +133,13 @@ final class ProfileViewModel { | ||
| 123 | 133 | return actionError == nil |
| 124 | 134 | } |
| 125 | 135 | |
| 126 | /// `profile set` — always the caller's own profile. The about text | |
| 127 | /// travels over stdin, never argv, since it is long-form and may | |
| 128 | /// carry newlines. | |
| 136 | /// `profile set` for the caller's own profile, `org profile <org>` for | |
| 137 | /// an organization; both take the same flags. The about text travels | |
| 138 | /// over stdin, never argv, since it is long-form and may carry newlines. | |
| 129 | 139 | func saveProfile(_ edit: ProfileEdit) async -> Bool { |
| 130 | 140 | guard edit.validationError == nil else { return false } |
| 131 | await perform(["profile", "set"] + edit.flags(), stdin: edit.aboutStdin) | |
| 141 | let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"] | |
| 142 | await perform(command + edit.flags(), stdin: edit.aboutStdin) | |
| 132 | 143 | return actionError == nil |
| 133 | 144 | } |
| 134 | 145 | |
gitbay/Views/Discovery/ProfileView.swift +4 −2
| @@ -45,8 +45,7 @@ struct ProfileView: View { | ||
| 45 | 45 | .navigationTitle(title ?? model.name) |
| 46 | 46 | .navigationBarTitleDisplayMode(.inline) |
| 47 | 47 | .toolbar { |
| 48 | if let profile = model.state.value, | |
| 49 | !profile.isOrg, session.current?.username == profile.name { | |
| 48 | if let profile = model.state.value, profile.canEdit(by: session.current?.username) { | |
| 50 | 49 | ToolbarItem(placement: .topBarTrailing) { |
| 51 | 50 | Button { |
| 52 | 51 | editingProfile = true |
| @@ -56,6 +55,9 @@ struct ProfileView: View { | ||
| 56 | 55 | .disabled(model.working) |
| 57 | 56 | .accessibilityIdentifier("profile-edit-button") |
| 58 | 57 | } |
| 58 | } | |
| 59 | if let profile = model.state.value, | |
| 60 | !profile.isOrg, session.current?.username == profile.name { | |
| 59 | 61 | ToolbarItem(placement: .topBarTrailing) { |
| 60 | 62 | Button { |
| 61 | 63 | newOrgName = "" |
gitbayTests/ProfileEditTests.swift +46
| @@ -158,6 +158,12 @@ private let profileJSON = """ | ||
| 158 | 158 | "activity_total":0},"exit_code":0} |
| 159 | 159 | """ |
| 160 | 160 | |
| 161 | private let orgShowJSON = """ | |
| 162 | {"protocol_version":1,"data":{"name":"krz","kind":"org",\ | |
| 163 | "description":"apps","members":[{"name":"cmc","role":"admin"},{"name":"guest","role":"member"}],\ | |
| 164 | "repos":[],"activity_total":0},"exit_code":0} | |
| 165 | """ | |
| 166 | ||
| 161 | 167 | @MainActor |
| 162 | 168 | struct ProfileSaveTests { |
| 163 | 169 | |
| @@ -190,6 +196,46 @@ struct ProfileSaveTests { | ||
| 190 | 196 | #expect(argv.contains { $0.contains("long form") } == false) |
| 191 | 197 | } |
| 192 | 198 | |
| 199 | /// An org's profile is written by `org profile <org>`, with the same | |
| 200 | /// flags; the loaded profile's kind decides which command runs. | |
| 201 | @Test func anOrgProfileSavesThroughOrgProfile() async throws { | |
| 202 | let (client, stub) = try makeClient() | |
| 203 | stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show")) | |
| 204 | let model = ProfileViewModel(client: client, name: "krz") | |
| 205 | await model.load() | |
| 206 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 207 | stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show")) | |
| 208 | ||
| 209 | let saved = await model.saveProfile(ProfileEdit(description: "apps", website: "https://krz.sh")) | |
| 210 | ||
| 211 | #expect(saved) | |
| 212 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 213 | let (argv, _) = try argvOf(write) | |
| 214 | #expect(Array(argv.prefix(3)) == ["org", "profile", "krz"]) | |
| 215 | #expect(argv.contains("--description")) | |
| 216 | } | |
| 217 | ||
| 218 | @Test func anOrgAdminMayEditItsProfileAndAMemberMayNot() throws { | |
| 219 | let profile = try JSONDecoder().decode( | |
| 220 | Envelope.self, from: Data(orgShowJSON.utf8)).data | |
| 221 | ||
| 222 | #expect(profile.canEdit(by: "cmc")) | |
| 223 | #expect(!profile.canEdit(by: "guest")) | |
| 224 | #expect(!profile.canEdit(by: "nobody")) | |
| 225 | } | |
| 226 | ||
| 227 | @Test func aUserProfileIsEditableOnlyByItsOwner() throws { | |
| 228 | let profile = try JSONDecoder().decode( | |
| 229 | Envelope.self, from: Data(profileJSON.utf8)).data | |
| 230 | ||
| 231 | #expect(profile.canEdit(by: "cmc")) | |
| 232 | #expect(!profile.canEdit(by: "guest")) | |
| 233 | } | |
| 234 | ||
| 235 | nonisolated private struct Envelope: Decodable { | |
| 236 | let data: ProfileViewModel.Profile | |
| 237 | } | |
| 238 | ||
| 193 | 239 | /// The screen shows what the server stored, not what was typed — the |
| 194 | 240 | /// reload's payload, not the edit, is what the caller sees next. |
| 195 | 241 | @Test func aSuccessfulSaveReloadsFromTheServer() async throws { |
gitbayUITests/LiveSmokeUITests.swift +7
| @@ -939,6 +939,13 @@ extension LiveSmokeUITests { | ||
| 939 | 939 | .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch |
| 940 | 940 | .exists, "repositories missing") |
| 941 | 941 | |
| 942 | // --- an org admin can edit the org's profile --- | |
| 943 | app.staticTexts["krz"].firstMatch.tap() | |
| 944 | XCTAssertTrue(app.descendants(matching: .any) | |
| 945 | .matching(identifier: "profile-edit-button").firstMatch | |
| 946 | .waitForExistence(timeout: 15), "org profile offers no edit to its admin") | |
| 947 | back() | |
| 948 | ||
| 942 | 949 | // --- a log entry opens its commit --- |
| 943 | 950 | app.terminate() |
| 944 | 951 | app.launch() |