neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Audit: membership, invitations, and the org's own settings
85accc3 · Gabriel Morell · 2026-10-21 02:55
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/audit/catalog.ex
+18
−0
@@ -24,6 +24,24 @@ defmodule GitGud.Audit.Catalog do
| 24 | 24 | "org_secret.deleted" => %{category: "secrets", target: "secret", keys: ~w()}, |
| 25 | 25 | "org_variable.set" => %{category: "secrets", target: "variable", keys: ~w()}, |
| 26 | 26 | "org_variable.deleted" => %{category: "secrets", target: "variable", keys: ~w()}, |
| 27 | + # Membership. The person invited, promoted or removed is the | |
| 28 | + # entry's subject and its target label (their handle). | |
| 29 | + "org.invitation_sent" => %{category: "members", target: "invitation", keys: ~w(role)}, | |
| 30 | + "org.invitation_withdrawn" => %{category: "members", target: "invitation", keys: ~w()}, | |
| 31 | + "org.invitation_accepted" => %{category: "members", target: "invitation", keys: ~w(role)}, | |
| 32 | + "org.invitation_declined" => %{category: "members", target: "invitation", keys: ~w()}, | |
| 33 | + "org.member_role_changed" => %{category: "members", target: "membership", keys: ~w(from to)}, | |
| 34 | + "org.member_removed" => %{category: "members", target: "membership", keys: ~w(role)}, | |
| 35 | + "team.created" => %{category: "members", target: "team", keys: ~w()}, | |
| 36 | + # The organization itself. `fields` names what a profile edit | |
| 37 | + # changed, never the new values. | |
| 38 | + "org.created" => %{category: "settings", target: "organization", keys: ~w(visibility)}, | |
| 39 | + "org.profile_updated" => %{category: "settings", target: "organization", keys: ~w(fields)}, | |
| 40 | + "org.visibility_changed" => %{ | |
| 41 | + category: "settings", | |
| 42 | + target: "organization", | |
| 43 | + keys: ~w(from to) | |
| 44 | + }, | |
| 27 | 45 | # The pruner's own trace: how much went, from before when. |
| 28 | 46 | "audit.pruned" => %{category: "instance", target: nil, keys: ~w(count cutoff)} |
| 29 | 47 | } |
modified
lib/git_gud/organizations.ex
+55
−23
@@ -93,8 +93,13 @@ defmodule GitGud.Organizations do
| 93 | 93 | @doc """ |
| 94 | 94 | Create an organization and make `creator` its first admin. Atomic via |
| 95 | 95 | a transaction. |
| 96 | + | |
| 97 | + `audit: actor` records `org.created` in that transaction. The call | |
| 98 | + site can't wrap this one in `GitGud.Audit.transact/5`: the profile | |
| 99 | + repository is created after the commit, on disk, and a failure there | |
| 100 | + must not take the organization with it. | |
| 96 | 101 | """ |
| 97 | − def create_organization(%User{id: uid}, attrs) do | |
| 102 | + def create_organization(%User{id: uid}, attrs, opts \\ []) do | |
| 98 | 103 | result = |
| 99 | 104 | Repo.transaction(fn -> |
| 100 | 105 | case %Organization{} |> Organization.changeset(attrs) |> Repo.insert() do |
@@ -107,7 +112,16 @@ defmodule GitGud.Organizations do
| 107 | 112 | }) |
| 108 | 113 | |> Repo.insert!() |
| 109 | 114 | |
| 110 | − org | |
| 115 | + entry = | |
| 116 | + if actor = opts[:audit] do | |
| 117 | + GitGud.Audit.insert!(actor, "org.created", %{ | |
| 118 | + organization: org, | |
| 119 | + target_id: org.id, | |
| 120 | + metadata: %{visibility: org.visibility} | |
| 121 | + }) | |
| 122 | + end | |
| 123 | + | |
| 124 | + {org, entry} | |
| 111 | 125 | |
| 112 | 126 | {:error, cs} -> |
| 113 | 127 | Repo.rollback(cs) |
@@ -115,7 +129,9 @@ defmodule GitGud.Organizations do
| 115 | 129 | end) |
| 116 | 130 | |
| 117 | 131 | case result do |
| 118 | − {:ok, org} -> | |
| 132 | + {:ok, {org, entry}} -> | |
| 133 | + if entry, do: GitGud.Audit.notice(entry) | |
| 134 | + | |
| 119 | 135 | # Best-effort profile README repo — same shape as on user |
| 120 | 136 | # registration. Logged + ignored on failure. |
| 121 | 137 | case GitGud.Repositories.ensure_profile_repo(org) do |
@@ -179,26 +195,33 @@ defmodule GitGud.Organizations do
| 179 | 195 | rather than an error. |
| 180 | 196 | """ |
| 181 | 197 | def invite_member(%Organization{} = org, %User{} = user, invited_by \\ nil, role \\ "member") do |
| 182 | − if member?(org, user) do | |
| 183 | − {:error, :already_member} | |
| 184 | − else | |
| 185 | − attrs = %{ | |
| 186 | − organization_id: org.id, | |
| 187 | − invited_user_id: user.id, | |
| 188 | − invited_by_id: invited_by && invited_by.id, | |
| 189 | − role: role | |
| 190 | − } | |
| 191 | − | |
| 192 | − case %Invitation{} |> Invitation.changeset(attrs) |> Repo.insert() do | |
| 193 | − {:ok, invitation} -> | |
| 194 | − {:ok, Repo.preload(invitation, [:organization, :invited_user, :invited_by])} | |
| 195 | − | |
| 196 | − {:error, _cs} -> | |
| 197 | − case pending_invitation(org, user) do | |
| 198 | − nil -> {:error, :not_invitable} | |
| 199 | − existing -> {:ok, existing} | |
| 200 | − end | |
| 201 | − end | |
| 198 | + # The pending one is looked up first rather than recovered from the | |
| 199 | + # insert's unique violation: inside a caller's transaction (an audit | |
| 200 | + # entry's) that violation would abort the whole transaction. | |
| 201 | + cond do | |
| 202 | + member?(org, user) -> {:error, :already_member} | |
| 203 | + existing = pending_invitation(org, user) -> {:ok, existing} | |
| 204 | + true -> insert_invitation(org, user, invited_by, role) | |
| 205 | + end | |
| 206 | + end | |
| 207 | + | |
| 208 | + defp insert_invitation(org, user, invited_by, role) do | |
| 209 | + attrs = %{ | |
| 210 | + organization_id: org.id, | |
| 211 | + invited_user_id: user.id, | |
| 212 | + invited_by_id: invited_by && invited_by.id, | |
| 213 | + role: role | |
| 214 | + } | |
| 215 | + | |
| 216 | + case %Invitation{} |> Invitation.changeset(attrs) |> Repo.insert() do | |
| 217 | + {:ok, invitation} -> | |
| 218 | + {:ok, Repo.preload(invitation, [:organization, :invited_user, :invited_by])} | |
| 219 | + | |
| 220 | + {:error, _cs} -> | |
| 221 | + case pending_invitation(org, user) do | |
| 222 | + nil -> {:error, :not_invitable} | |
| 223 | + existing -> {:ok, existing} | |
| 224 | + end | |
| 202 | 225 | end |
| 203 | 226 | end |
| 204 | 227 |
@@ -343,6 +366,15 @@ defmodule GitGud.Organizations do
| 343 | 366 | ) |
| 344 | 367 | end |
| 345 | 368 | |
| 369 | + @doc "`user`'s role in `org` (\"admin\" or \"member\"), nil when they aren't in it." | |
| 370 | + def member_role(%Organization{id: oid}, %User{id: uid}) do | |
| 371 | + Repo.one( | |
| 372 | + from m in Membership, | |
| 373 | + where: m.organization_id == ^oid and m.user_id == ^uid, | |
| 374 | + select: m.role | |
| 375 | + ) | |
| 376 | + end | |
| 377 | + | |
| 346 | 378 | @doc """ |
| 347 | 379 | Change a member's role. Refuses to demote the last admin — the org |
| 348 | 380 | must always have at least one admin. Returns |
modified
lib/git_gud_web/components/audit_components.ex
+19
−0
@@ -24,11 +24,30 @@ defmodule GitGudWeb.AuditComponents do
| 24 | 24 | "org_secret.deleted" -> "deleted the organization secret" |
| 25 | 25 | "org_variable.set" -> "set the organization variable" |
| 26 | 26 | "org_variable.deleted" -> "deleted the organization variable" |
| 27 | + "org.invitation_sent" -> "invited #{entry.metadata["role"] || "member"}" | |
| 28 | + "org.invitation_withdrawn" -> "withdrew the invitation for" | |
| 29 | + "org.invitation_accepted" -> "accepted the invitation as #{entry.metadata["role"]}" | |
| 30 | + "org.invitation_declined" -> "declined the invitation" | |
| 31 | + "org.member_role_changed" -> role_change(entry.metadata) | |
| 32 | + "org.member_removed" -> "removed #{entry.metadata["role"] || "member"}" | |
| 33 | + "team.created" -> "created the team" | |
| 34 | + "org.created" -> "created the organization (#{entry.metadata["visibility"]})" | |
| 35 | + "org.profile_updated" -> profile_change(entry.metadata) | |
| 36 | + "org.visibility_changed" -> "changed visibility #{from_to(entry.metadata)}" | |
| 27 | 37 | "audit.pruned" -> "pruned #{entry.metadata["count"]} audit entries" |
| 28 | 38 | other -> other |
| 29 | 39 | end |
| 30 | 40 | end |
| 31 | 41 | |
| 42 | + defp role_change(metadata), do: "changed the role #{from_to(metadata)} of" | |
| 43 | + | |
| 44 | + defp profile_change(%{"fields" => [_ | _] = fields}), | |
| 45 | + do: "updated the profile (#{Enum.join(fields, ", ")})" | |
| 46 | + | |
| 47 | + defp profile_change(_), do: "updated the profile" | |
| 48 | + | |
| 49 | + defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})" | |
| 50 | + | |
| 32 | 51 | @doc "Category tab label." |
| 33 | 52 | def category_label("secrets"), do: "Secrets & variables" |
| 34 | 53 | def category_label("members"), do: "Members" |
modified
lib/git_gud_web/live/org_live/members.ex
+65
−4
@@ -14,6 +14,7 @@ defmodule GitGudWeb.OrgLive.Members do
| 14 | 14 | use GitGudWeb, :live_view |
| 15 | 15 | |
| 16 | 16 | alias GitGud.Accounts |
| 17 | + alias GitGud.Audit | |
| 17 | 18 | alias GitGud.Organizations |
| 18 | 19 | alias GitGud.Organizations.Organization |
| 19 | 20 |
@@ -62,7 +63,23 @@ defmodule GitGudWeb.OrgLive.Members do
| 62 | 63 | {:noreply, put_flash(socket, :error, "No user matched #{inspect(raw)}.")} |
| 63 | 64 | |
| 64 | 65 | %Accounts.User{} = user -> |
| 65 | − case Organizations.invite_member(org, user, inviter) do | |
| 66 | + # Inviting someone already invited hands back their invitation: | |
| 67 | + # nothing new to record. | |
| 68 | + pending = Organizations.pending_invitation(org, user) | |
| 69 | + | |
| 70 | + audited = | |
| 71 | + Audit.transact( | |
| 72 | + socket.assigns.current_scope, | |
| 73 | + "org.invitation_sent", | |
| 74 | + fn -> Organizations.invite_member(org, user, inviter) end, | |
| 75 | + fn invitation -> | |
| 76 | + if pending, | |
| 77 | + do: :skip, | |
| 78 | + else: member_attrs(org, user, invitation, %{role: invitation.role}) | |
| 79 | + end | |
| 80 | + ) | |
| 81 | + | |
| 82 | + case audited do | |
| 66 | 83 | {:ok, _invitation} -> |
| 67 | 84 | {:noreply, |
| 68 | 85 | socket |
@@ -87,7 +104,16 @@ defmodule GitGudWeb.OrgLive.Members do
| 87 | 104 | {:noreply, socket} |
| 88 | 105 | |
| 89 | 106 | invitation -> |
| 90 | − {:ok, _} = Organizations.revoke_invitation(invitation) | |
| 107 | + {:ok, _} = | |
| 108 | + Audit.transact( | |
| 109 | + socket.assigns.current_scope, | |
| 110 | + "org.invitation_withdrawn", | |
| 111 | + fn -> Organizations.revoke_invitation(invitation) end, | |
| 112 | + fn _ -> | |
| 113 | + member_attrs(socket.assigns.org, invitation.invited_user, invitation, %{}) | |
| 114 | + end | |
| 115 | + ) | |
| 116 | + | |
| 91 | 117 | {:noreply, socket |> load_members() |> put_flash(:info, "Invitation withdrawn.")} |
| 92 | 118 | end |
| 93 | 119 | end |
@@ -98,7 +124,21 @@ defmodule GitGudWeb.OrgLive.Members do
| 98 | 124 | |
| 99 | 125 | with {user_id, ""} <- Integer.parse(uid), |
| 100 | 126 | user when not is_nil(user) <- GitGud.Repo.get(Accounts.User, user_id) do |
| 101 | − case Organizations.set_member_role(org, user, role) do | |
| 127 | + from = Organizations.member_role(org, user) | |
| 128 | + | |
| 129 | + audited = | |
| 130 | + Audit.transact( | |
| 131 | + socket.assigns.current_scope, | |
| 132 | + "org.member_role_changed", | |
| 133 | + fn -> Organizations.set_member_role(org, user, role) end, | |
| 134 | + fn membership -> | |
| 135 | + if from == role, | |
| 136 | + do: :skip, | |
| 137 | + else: member_attrs(org, user, membership, %{from: from, to: role}) | |
| 138 | + end | |
| 139 | + ) | |
| 140 | + | |
| 141 | + case audited do | |
| 102 | 142 | {:ok, _} -> |
| 103 | 143 | {:noreply, socket |> load_members() |> put_flash(:info, "Role updated.")} |
| 104 | 144 |
@@ -122,7 +162,17 @@ defmodule GitGudWeb.OrgLive.Members do
| 122 | 162 | |
| 123 | 163 | with {user_id, ""} <- Integer.parse(uid), |
| 124 | 164 | user when not is_nil(user) <- GitGud.Repo.get(Accounts.User, user_id) do |
| 125 | − case Organizations.remove_member_guarded(org, user) do | |
| 165 | + role = Organizations.member_role(org, user) | |
| 166 | + | |
| 167 | + audited = | |
| 168 | + Audit.transact( | |
| 169 | + socket.assigns.current_scope, | |
| 170 | + "org.member_removed", | |
| 171 | + fn -> Organizations.remove_member_guarded(org, user) end, | |
| 172 | + fn :ok -> member_attrs(org, user, nil, %{role: role}) end | |
| 173 | + ) | |
| 174 | + | |
| 175 | + case audited do | |
| 126 | 176 | :ok -> |
| 127 | 177 | {:noreply, socket |> load_members() |> put_flash(:info, "Removed #{display(user)}.")} |
| 128 | 178 |
@@ -138,6 +188,17 @@ defmodule GitGudWeb.OrgLive.Members do
| 138 | 188 | end |
| 139 | 189 | end |
| 140 | 190 | |
| 191 | + # The person acted on is both the subject and, by handle, the label. | |
| 192 | + defp member_attrs(org, user, target, metadata) do | |
| 193 | + %{ | |
| 194 | + organization: org, | |
| 195 | + subject: user, | |
| 196 | + target_id: target && target.id, | |
| 197 | + target_label: user.handle, | |
| 198 | + metadata: metadata | |
| 199 | + } | |
| 200 | + end | |
| 201 | + | |
| 141 | 202 | defp find_user(input) do |
| 142 | 203 | raw = String.trim(input) |
| 143 | 204 |
modified
lib/git_gud_web/live/org_live/new.ex
+3
−1
@@ -21,7 +21,9 @@ defmodule GitGudWeb.OrgLive.New do
| 21 | 21 | end |
| 22 | 22 | |
| 23 | 23 | def handle_event("save", %{"organization" => attrs}, socket) do |
| 24 | − case Organizations.create_organization(socket.assigns.current_scope.user, attrs) do | |
| 24 | + %{current_scope: scope} = socket.assigns | |
| 25 | + | |
| 26 | + case Organizations.create_organization(scope.user, attrs, audit: scope) do | |
| 25 | 27 | {:ok, org} -> |
| 26 | 28 | {:noreply, |
| 27 | 29 | socket |
modified
lib/git_gud_web/live/org_live/settings_profile.ex
+47
−2
@@ -8,12 +8,16 @@ defmodule GitGudWeb.OrgLive.SettingsProfile do
| 8 | 8 | |
| 9 | 9 | use GitGudWeb, :live_view |
| 10 | 10 | |
| 11 | + alias GitGud.Audit | |
| 11 | 12 | alias GitGud.Organizations |
| 12 | 13 | alias GitGud.Organizations.Organization |
| 13 | 14 | alias GitGud.Profiles |
| 14 | 15 | alias GitGud.Repositories |
| 15 | 16 | alias GitGud.Repositories.Repository |
| 16 | 17 | |
| 18 | + @profile_fields ~w(display_name description bio url location company public_email timezone | |
| 19 | + socials)a | |
| 20 | + | |
| 17 | 21 | @impl true |
| 18 | 22 | def mount(%{"handle" => handle}, _session, socket) do |
| 19 | 23 | user = socket.assigns.current_scope.user |
@@ -135,7 +139,20 @@ defmodule GitGudWeb.OrgLive.SettingsProfile do
| 135 | 139 | ]) |
| 136 | 140 | |> Map.put("socials", parse_socials(params["socials_text"])) |
| 137 | 141 | |
| 138 | − case Organizations.update_organization_profile(org, attrs) do | |
| 142 | + audited = | |
| 143 | + Audit.transact( | |
| 144 | + socket.assigns.current_scope, | |
| 145 | + "org.profile_updated", | |
| 146 | + fn -> Organizations.update_organization_profile(org, attrs) end, | |
| 147 | + fn updated -> | |
| 148 | + case changed_fields(org, updated) do | |
| 149 | + [] -> :skip | |
| 150 | + fields -> %{organization: updated, target_id: org.id, metadata: %{fields: fields}} | |
| 151 | + end | |
| 152 | + end | |
| 153 | + ) | |
| 154 | + | |
| 155 | + case audited do | |
| 139 | 156 | {:ok, updated} -> |
| 140 | 157 | {:noreply, |
| 141 | 158 | socket |
@@ -149,7 +166,27 @@ defmodule GitGudWeb.OrgLive.SettingsProfile do
| 149 | 166 | end |
| 150 | 167 | |
| 151 | 168 | defp handle_admin_event("update_visibility", %{"visibility" => v}, socket) do |
| 152 | − case Organizations.update_organization_visibility(socket.assigns.org, v) do | |
| 169 | + org = socket.assigns.org | |
| 170 | + | |
| 171 | + audited = | |
| 172 | + Audit.transact( | |
| 173 | + socket.assigns.current_scope, | |
| 174 | + "org.visibility_changed", | |
| 175 | + fn -> Organizations.update_organization_visibility(org, v) end, | |
| 176 | + fn | |
| 177 | + %{visibility: same} when same == org.visibility -> | |
| 178 | + :skip | |
| 179 | + | |
| 180 | + updated -> | |
| 181 | + %{ | |
| 182 | + organization: updated, | |
| 183 | + target_id: org.id, | |
| 184 | + metadata: %{from: org.visibility, to: updated.visibility} | |
| 185 | + } | |
| 186 | + end | |
| 187 | + ) | |
| 188 | + | |
| 189 | + case audited do | |
| 153 | 190 | {:ok, updated} -> |
| 154 | 191 | {:noreply, socket |> assign(:org, updated) |> put_flash(:info, "Visibility updated.")} |
| 155 | 192 |
@@ -198,6 +235,14 @@ defmodule GitGudWeb.OrgLive.SettingsProfile do
| 198 | 235 | {:noreply, load_pins(socket)} |
| 199 | 236 | end |
| 200 | 237 | |
| 238 | + # Which fields a profile save changed, by name: what they now say is | |
| 239 | + # on the public profile, and doesn't belong in the log. | |
| 240 | + defp changed_fields(before, updated) do | |
| 241 | + for field <- @profile_fields, | |
| 242 | + Map.get(before, field) != Map.get(updated, field), | |
| 243 | + do: Atom.to_string(field) | |
| 244 | + end | |
| 245 | + | |
| 201 | 246 | @impl true |
| 202 | 247 | # Sent by GitGudWeb.AvatarUpload after an upload or a removal. The |
| 203 | 248 | # component reads its own state, so this only has to let the parent |
modified
lib/git_gud_web/live/org_live/show.ex
+11
−1
@@ -98,7 +98,17 @@ defmodule GitGudWeb.OrgLive.Show do
| 98 | 98 | def handle_event(_event, _params, socket), do: {:noreply, socket} |
| 99 | 99 | |
| 100 | 100 | defp create_team(attrs, socket) do |
| 101 | − case Organizations.create_team(socket.assigns.org, attrs) do | |
| 101 | + org = socket.assigns.org | |
| 102 | + | |
| 103 | + audited = | |
| 104 | + GitGud.Audit.transact( | |
| 105 | + socket.assigns.current_scope, | |
| 106 | + "team.created", | |
| 107 | + fn -> Organizations.create_team(org, attrs) end, | |
| 108 | + fn team -> %{organization: org, target_id: team.id, target_label: team.name} end | |
| 109 | + ) | |
| 110 | + | |
| 111 | + case audited do | |
| 102 | 112 | {:ok, _team} -> |
| 103 | 113 | {:noreply, |
| 104 | 114 | socket |
modified
lib/git_gud_web/live/page_live/home.ex
+33
−4
@@ -98,17 +98,30 @@ defmodule GitGudWeb.PageLive.Home do
| 98 | 98 | |
| 99 | 99 | @impl true |
| 100 | 100 | def handle_event("accept_org_invitation", %{"id" => id}, socket) do |
| 101 | − respond(socket, id, &Organizations.accept_invitation/2, "Joined the organization.") | |
| 101 | + respond( | |
| 102 | + socket, | |
| 103 | + id, | |
| 104 | + "org.invitation_accepted", | |
| 105 | + &Organizations.accept_invitation/2, | |
| 106 | + "Joined the organization." | |
| 107 | + ) | |
| 102 | 108 | end |
| 103 | 109 | |
| 104 | 110 | def handle_event("decline_org_invitation", %{"id" => id}, socket) do |
| 105 | − respond(socket, id, &Organizations.decline_invitation/2, "Invitation declined.") | |
| 111 | + respond( | |
| 112 | + socket, | |
| 113 | + id, | |
| 114 | + "org.invitation_declined", | |
| 115 | + &Organizations.decline_invitation/2, | |
| 116 | + "Invitation declined." | |
| 117 | + ) | |
| 106 | 118 | end |
| 107 | 119 | |
| 108 | 120 | # Both answers do the same bookkeeping; only the context call and the |
| 109 | 121 | # confirmation differ. The invitation is looked up in the assigns, so |
| 110 | 122 | # a viewer can only answer one that was actually addressed to them. |
| 111 | − defp respond(socket, id, fun, message) do | |
| 123 | + # The answer goes in the org's audit log, with the invitee as actor. | |
| 124 | + defp respond(socket, id, action, fun, message) do | |
| 112 | 125 | user = socket.assigns.user |
| 113 | 126 | |
| 114 | 127 | case Enum.find(socket.assigns.org_invitations, &(&1.id == String.to_integer(id))) do |
@@ -116,7 +129,23 @@ defmodule GitGudWeb.PageLive.Home do
| 116 | 129 | {:noreply, socket} |
| 117 | 130 | |
| 118 | 131 | invitation -> |
| 119 | − case fun.(invitation, user) do | |
| 132 | + audited = | |
| 133 | + GitGud.Audit.transact( | |
| 134 | + socket.assigns.current_scope, | |
| 135 | + action, | |
| 136 | + fn -> fun.(invitation, user) end, | |
| 137 | + fn _ -> | |
| 138 | + %{ | |
| 139 | + organization: invitation.organization, | |
| 140 | + subject: user, | |
| 141 | + target_id: invitation.id, | |
| 142 | + metadata: | |
| 143 | + if(action == "org.invitation_accepted", do: %{role: invitation.role}, else: %{}) | |
| 144 | + } | |
| 145 | + end | |
| 146 | + ) | |
| 147 | + | |
| 148 | + case audited do | |
| 120 | 149 | {:ok, _} -> |
| 121 | 150 | {:noreply, |
| 122 | 151 | socket |
added
test/git_gud_web/live/org_audit_members_test.exs
+212
−0
@@ -0,0 +1,212 @@
| 1 | +defmodule GitGudWeb.OrgAuditMembersTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Membership and the org's own settings leave audit entries: who was | |
| 4 | + invited, promoted or removed and by whom, who answered an invitation, | |
| 5 | + and what the org's admins changed about it. No-ops and refusals leave | |
| 6 | + none. | |
| 7 | + """ | |
| 8 | + use GitGudWeb.ConnCase, async: true | |
| 9 | + | |
| 10 | + import Ecto.Query | |
| 11 | + import Phoenix.LiveViewTest | |
| 12 | + import GitGud.AccountsFixtures | |
| 13 | + | |
| 14 | + alias GitGud.Audit.Entry | |
| 15 | + alias GitGud.Organizations | |
| 16 | + | |
| 17 | + setup %{conn: conn} do | |
| 18 | + admin = user_fixture() | |
| 19 | + handle = "am#{System.unique_integer([:positive])}" | |
| 20 | + {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle, "name" => handle}) | |
| 21 | + {:ok, org: org, admin: admin, conn: log_in_user(conn, admin)} | |
| 22 | + end | |
| 23 | + | |
| 24 | + defp entries(org), | |
| 25 | + do: GitGud.Repo.all(from e in Entry, where: e.organization_id == ^org.id, order_by: e.id) | |
| 26 | + | |
| 27 | + defp actions(org), do: Enum.map(entries(org), & &1.action) | |
| 28 | + | |
| 29 | + defp members(conn, org) do | |
| 30 | + {:ok, lv, _} = live(conn, ~p"/orgs/#{org.handle}/settings/members") | |
| 31 | + lv | |
| 32 | + end | |
| 33 | + | |
| 34 | + defp invite!(lv, who) do | |
| 35 | + lv | |
| 36 | + |> form("#invite-member-form", %{"invite" => %{"handle_or_email" => who.handle}}) | |
| 37 | + |> render_submit() | |
| 38 | + end | |
| 39 | + | |
| 40 | + describe "invitations" do | |
| 41 | + test "sending one, and sending it again", %{conn: conn, org: org, admin: admin} do | |
| 42 | + invitee = user_fixture() | |
| 43 | + lv = members(conn, org) | |
| 44 | + | |
| 45 | + invite!(lv, invitee) | |
| 46 | + invite!(lv, invitee) | |
| 47 | + | |
| 48 | + assert [entry] = entries(org) | |
| 49 | + assert entry.action == "org.invitation_sent" | |
| 50 | + assert entry.actor_id == admin.id | |
| 51 | + assert entry.subject_user_id == invitee.id | |
| 52 | + assert entry.target_label == invitee.handle | |
| 53 | + assert entry.metadata == %{"role" => "member"} | |
| 54 | + end | |
| 55 | + | |
| 56 | + test "inviting a member records nothing", %{conn: conn, org: org} do | |
| 57 | + member = user_fixture() | |
| 58 | + {:ok, _} = Organizations.add_member(org, member) | |
| 59 | + | |
| 60 | + invite!(members(conn, org), member) | |
| 61 | + assert entries(org) == [] | |
| 62 | + end | |
| 63 | + | |
| 64 | + test "withdrawing one", %{conn: conn, org: org, admin: admin} do | |
| 65 | + invitee = user_fixture() | |
| 66 | + {:ok, inv} = Organizations.invite_member(org, invitee, admin) | |
| 67 | + | |
| 68 | + render_click(members(conn, org), "revoke_invitation", %{"id" => to_string(inv.id)}) | |
| 69 | + | |
| 70 | + assert [%Entry{action: "org.invitation_withdrawn", target_id: id, subject_user_id: sid}] = | |
| 71 | + entries(org) | |
| 72 | + | |
| 73 | + assert {id, sid} == {inv.id, invitee.id} | |
| 74 | + end | |
| 75 | + | |
| 76 | + test "the invitee's answers, from the dashboard", %{conn: conn, org: org, admin: admin} do | |
| 77 | + [yes, no] = [user_fixture(), user_fixture()] | |
| 78 | + {:ok, accept} = Organizations.invite_member(org, yes, admin) | |
| 79 | + {:ok, decline} = Organizations.invite_member(org, no, admin) | |
| 80 | + | |
| 81 | + {:ok, lv, _} = live(log_in_user(conn, yes), ~p"/") | |
| 82 | + render_hook(lv, "accept_org_invitation", %{"id" => to_string(accept.id)}) | |
| 83 | + | |
| 84 | + {:ok, lv, _} = live(log_in_user(conn, no), ~p"/") | |
| 85 | + render_hook(lv, "decline_org_invitation", %{"id" => to_string(decline.id)}) | |
| 86 | + | |
| 87 | + assert [accepted, declined] = entries(org) | |
| 88 | + | |
| 89 | + assert {accepted.action, accepted.actor_id, accepted.metadata} == | |
| 90 | + {"org.invitation_accepted", yes.id, %{"role" => "member"}} | |
| 91 | + | |
| 92 | + assert {declined.action, declined.actor_id} == {"org.invitation_declined", no.id} | |
| 93 | + end | |
| 94 | + end | |
| 95 | + | |
| 96 | + describe "members" do | |
| 97 | + test "a role change, and setting the same role", %{conn: conn, org: org} do | |
| 98 | + member = user_fixture() | |
| 99 | + {:ok, _} = Organizations.add_member(org, member) | |
| 100 | + lv = members(conn, org) | |
| 101 | + | |
| 102 | + render_change(lv, "set_role", %{"user_id" => to_string(member.id), "role" => "admin"}) | |
| 103 | + render_change(lv, "set_role", %{"user_id" => to_string(member.id), "role" => "admin"}) | |
| 104 | + | |
| 105 | + assert [entry] = entries(org) | |
| 106 | + assert entry.action == "org.member_role_changed" | |
| 107 | + assert entry.metadata == %{"from" => "member", "to" => "admin"} | |
| 108 | + assert entry.subject_user_id == member.id | |
| 109 | + end | |
| 110 | + | |
| 111 | + test "the last admin's refused demotion records nothing", %{ | |
| 112 | + conn: conn, | |
| 113 | + org: org, | |
| 114 | + admin: admin | |
| 115 | + } do | |
| 116 | + render_change(members(conn, org), "set_role", %{ | |
| 117 | + "user_id" => to_string(admin.id), | |
| 118 | + "role" => "member" | |
| 119 | + }) | |
| 120 | + | |
| 121 | + assert Organizations.org_admin?(org, admin) | |
| 122 | + assert entries(org) == [] | |
| 123 | + end | |
| 124 | + | |
| 125 | + test "a removal keeps the role they had", %{conn: conn, org: org} do | |
| 126 | + member = user_fixture() | |
| 127 | + {:ok, _} = Organizations.add_member(org, member, "admin") | |
| 128 | + | |
| 129 | + render_click(members(conn, org), "remove_member", %{"user_id" => to_string(member.id)}) | |
| 130 | + | |
| 131 | + refute Organizations.member?(org, member) | |
| 132 | + assert [entry] = entries(org) | |
| 133 | + assert {entry.action, entry.metadata} == {"org.member_removed", %{"role" => "admin"}} | |
| 134 | + assert entry.target_label == member.handle | |
| 135 | + end | |
| 136 | + | |
| 137 | + test "a member who isn't an admin can't, and leaves nothing", %{conn: conn, org: org} do | |
| 138 | + member = user_fixture() | |
| 139 | + other = user_fixture() | |
| 140 | + {:ok, _} = Organizations.add_member(org, member, "admin") | |
| 141 | + {:ok, _} = Organizations.add_member(org, other) | |
| 142 | + lv = members(log_in_user(conn, member), org) | |
| 143 | + {:ok, _} = Organizations.set_member_role(org, member, "member") | |
| 144 | + | |
| 145 | + render_click(lv, "remove_member", %{"user_id" => to_string(other.id)}) | |
| 146 | + | |
| 147 | + assert Organizations.member?(org, other) | |
| 148 | + assert actions(org) == [] | |
| 149 | + end | |
| 150 | + end | |
| 151 | + | |
| 152 | + describe "the organization" do | |
| 153 | + test "creating one", %{conn: conn} do | |
| 154 | + handle = "new#{System.unique_integer([:positive])}" | |
| 155 | + {:ok, lv, _} = live(conn, ~p"/orgs/new") | |
| 156 | + | |
| 157 | + lv | |
| 158 | + |> form("#new-org-form", %{"organization" => %{"handle" => handle, "name" => handle}}) | |
| 159 | + |> render_submit() | |
| 160 | + | |
| 161 | + org = Organizations.get_organization_by_handle!(handle) | |
| 162 | + assert [entry] = entries(org) | |
| 163 | + assert entry.action == "org.created" | |
| 164 | + assert entry.metadata == %{"visibility" => org.visibility} | |
| 165 | + end | |
| 166 | + | |
| 167 | + test "a profile edit names the fields, not their values", %{conn: conn, org: org} do | |
| 168 | + {:ok, lv, _} = live(conn, ~p"/orgs/#{org.handle}/settings/profile") | |
| 169 | + | |
| 170 | + save = fn attrs -> | |
| 171 | + lv |> form("#org_profile_form", %{"organization" => attrs}) |> render_submit() | |
| 172 | + end | |
| 173 | + | |
| 174 | + save.(%{"bio" => "Bio-value-xyzzy", "location" => "Somewhere-plugh"}) | |
| 175 | + # Saved again unchanged: nothing to record. | |
| 176 | + save.(%{"bio" => "Bio-value-xyzzy", "location" => "Somewhere-plugh"}) | |
| 177 | + | |
| 178 | + assert [entry] = entries(org) | |
| 179 | + assert entry.action == "org.profile_updated" | |
| 180 | + assert entry.metadata == %{"fields" => ["bio", "location"]} | |
| 181 | + refute inspect(entry) =~ "xyzzy" | |
| 182 | + end | |
| 183 | + | |
| 184 | + test "visibility, and the same visibility again", %{conn: conn, org: org} do | |
| 185 | + {:ok, lv, _} = live(conn, ~p"/orgs/#{org.handle}/settings/profile") | |
| 186 | + | |
| 187 | + render_submit(lv, "update_visibility", %{"visibility" => "private"}) | |
| 188 | + render_submit(lv, "update_visibility", %{"visibility" => "private"}) | |
| 189 | + | |
| 190 | + assert [entry] = entries(org) | |
| 191 | + assert entry.action == "org.visibility_changed" | |
| 192 | + assert entry.metadata == %{"from" => org.visibility, "to" => "private"} | |
| 193 | + end | |
| 194 | + | |
| 195 | + test "a team", %{conn: conn, org: org} do | |
| 196 | + {:ok, lv, _} = live(conn, ~p"/orgs/#{org.handle}") | |
| 197 | + lv |> form("#team-form", %{"team" => %{"name" => "core"}}) |> render_submit() | |
| 198 | + | |
| 199 | + assert [%Entry{action: "team.created", target_label: "core"}] = entries(org) | |
| 200 | + end | |
| 201 | + end | |
| 202 | + | |
| 203 | + test "they show on the org's audit page", %{conn: conn, org: org} do | |
| 204 | + invitee = user_fixture() | |
| 205 | + invite!(members(conn, org), invitee) | |
| 206 | + | |
| 207 | + {:ok, view, _} = live(conn, ~p"/orgs/#{org.handle}/settings/audit/members") | |
| 208 | + [entry] = entries(org) | |
| 209 | + assert has_element?(view, "#audit-entry-#{entry.id}", invitee.handle) | |
| 210 | + assert has_element?(view, "#audit-category-settings") | |
| 211 | + end | |
| 212 | +end |
Parents: b259852