Audit: membership, invitations, and the org's own settings

85accc3 · Gabriel Morell · 2026-10-21 02:55

20 files +463 -35
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