Audit pages: filter by who and by repository

ba66a27 · Gabriel Morell · 2026-10-22 17:44

18 files +392 -38
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −1
@@ -65,7 +65,7 @@ reinvent them.
65 65 - **Invites** — single-use tokens with create / list / revoke per user; UI at `/users/settings/invites`
66 66 - **Org visibility**: `public` / `internal` / `private`. Repos can't be more open than their org; tightening an org cascades to over-public repos in one transaction. Public orgs are accessible to anonymous viewers; private orgs require membership.
67 67 - **Org membership by invitation** at `/orgs/:handle/settings/members` — an admin invites by handle or email and the invitee accepts from their dashboard; nobody is added to an org without agreeing. Withdraw a pending invite, change roles, remove members, with a last-admin guard so an org can never end up admin-less
68 − - **Audit log** at `/orgs/:handle/settings/audit`, and per repository at `/r/:owner/:name/settings/audit` — who changed which CI secret or variable; who was invited, promoted or removed; who created, forked, reconfigured or deleted a repository, and what the org's own settings became; branch protection, deploy keys, webhooks (by host only), registry tokens and job-token grants; runners minted, registered and removed, and fork runs approved; bans and report decisions. Written in the same transaction as the change so a change can't happen unrecorded. Never the value. For org admins and instance admins (and a personal repository's owner), in sudo mode; filters are path segments (`…/audit/secrets/before/<id>`). More of the org's history joins it slice by slice ([docs/plans/org-audit-log.md](docs/plans/org-audit-log.md)); entries are kept forever unless `GitGud.Audit` `retention_days` is set
68 + - **Audit log** at `/orgs/:handle/settings/audit`, and per repository at `/r/:owner/:name/settings/audit` — who changed which CI secret or variable; who was invited, promoted or removed; who created, forked, reconfigured or deleted a repository, and what the org's own settings became; branch protection, deploy keys, webhooks (by host only), registry tokens and job-token grants; runners minted, registered and removed, and fork runs approved; bans and report decisions. Written in the same transaction as the change so a change can't happen unrecorded. Never the value. For org admins and instance admins (and a personal repository's owner), in sudo mode; filters are path segments, by category, person and repository (`…/audit/secrets/actor/alice/repo/web/before/<id>`), and a deleted account's or repository's entries can still be picked out by its old name. More of the org's history joins it slice by slice ([docs/plans/org-audit-log.md](docs/plans/org-audit-log.md)); entries are kept forever unless `GitGud.Audit` `retention_days` is set
69 69 - **Moderation for owners**
70 70 - Org and repo admins get their own queue at `/orgs/:handle/settings/moderation` and `/r/:owner/:name/settings/moderation` — reports about *their* content, which previously only an instance admin could see
71 71 - **Scoped bans** on local users: a repo admin can stop someone acting in their repo, an org admin across every repo the org owns. Neither reaches further; suspending an account instance-wide stays an instance admin's power
modified lib/git_gud/audit.ex
+27 −1
@@ -256,7 +256,9 @@ defmodule GitGud.Audit do
256 256 `{:repo, repo}`, `:instance` (everything) or `{:user, user}` (what was
257 257 done to them, and what they did outside any org or repository).
258 258
259 − Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`.
259 + Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`,
260 + and `:actor_label` / `:repository_label` for entries whose account or
261 + repository is gone.
260 262 `cursor` is nil, `{:before, id}` (older) or `{:after, id}` (newer).
261 263 Returns `{entries, %{older: id | nil, newer: id | nil}}`: the cursors
262 264 for the next page in each direction, nil when there's none.
@@ -311,6 +313,22 @@ defmodule GitGud.Audit do
311 313 {rows, cursors}
312 314 end
313 315
316 + @doc """
317 + The names of `org`'s repositories that are gone but still have
318 + entries, recorded under their old `handle/name`.
319 + """
320 + @spec deleted_repository_names(Organization.t()) :: [String.t()]
321 + def deleted_repository_names(%Organization{id: id}) do
322 + from(e in Entry,
323 + where: e.organization_id == ^id and is_nil(e.repository_id),
324 + where: not is_nil(e.repository_label),
325 + distinct: true,
326 + select: e.repository_label
327 + )
328 + |> Repo.all()
329 + |> Enum.map(&(&1 |> String.split("/", parts: 2) |> List.last()))
330 + end
331 +
314 332 defp scoped(query, {:org, %Organization{id: id}}),
315 333 do: from(e in query, where: e.organization_id == ^id)
316 334
@@ -341,6 +359,14 @@ defmodule GitGud.Audit do
341 359 {:organization_id, id}, q when is_integer(id) ->
342 360 from(e in q, where: e.organization_id == ^id)
343 361
362 + # A deleted account's or repository's entries, by the label
363 + # they were recorded under.
364 + {:actor_label, label}, q when is_binary(label) ->
365 + from(e in q, where: is_nil(e.actor_id) and e.actor_label == ^label)
366 +
367 + {:repository_label, label}, q when is_binary(label) ->
368 + from(e in q, where: is_nil(e.repository_id) and e.repository_label == ^label)
369 +
344 370 _, q ->
345 371 q
346 372 end)
modified lib/git_gud_web/live/audit_live/filters.ex
+85 −26
@@ -2,33 +2,45 @@ defmodule GitGudWeb.AuditLive.Filters do
2 2 @moduledoc """
3 3 The audit views' filters as path segments, and back:
4 4
5 − <base> everything, newest first
6 − <base>/<category> one category ("all" for none)
7 − <base>/<category>/before/<id> older than entry <id>
8 − <base>/<category>/after/<id> newer than entry <id>
9 −
10 − `parse/2` reads the route params; `to_path/2` writes the one
11 − canonical path for a set of filters, which the views patch to when
12 − the URL they were given spells the same filters differently.
5 + <base> everything, newest first
6 + <base>/<category> one category ("all" for none)
7 + <base>/<category>/actor/<handle> what one person (or runner) did
8 + <base>/<category>/repo/<name> one of the org's repositories
9 + <base>/<category>/before/<id> older than entry <id>
10 + <base>/<category>/after/<id> newer than entry <id>
11 +
12 + The refinements combine, e.g.
13 + `…/audit/secrets/actor/alice/repo/web/before/12345`. `parse/3` reads
14 + the route params and accepts them in any order; `to_path/2` writes
15 + the one canonical path — category, actor, repo, cursor — which the
16 + views patch to when the URL they were given spells the same filters
17 + differently.
13 18 """
14 19
15 − defstruct category: nil, cursor: nil
20 + defstruct category: nil, actor: nil, repo: nil, cursor: nil
16 21
17 22 @type t :: %__MODULE__{
18 23 category: String.t() | nil,
24 + actor: String.t() | nil,
25 + repo: String.t() | nil,
19 26 cursor: {:before | :after, pos_integer()} | nil
20 27 }
21 28
29 + # Handles and repository names as the forge makes them; anything else
30 + # in a path segment is a typo or a probe, and gets the canonical path.
31 + @name ~r/\A[A-Za-z0-9][A-Za-z0-9_.\-]{0,99}\z/
32 +
22 33 @doc """
23 34 `{:ok, filters}` from the `category` and `refine` route params, or
24 − `:error` for a category not in `categories` or a refine that isn't a
25 − cursor.
35 + `:error` for a category not in `categories`, a refinement this view
36 + doesn't offer (`refines`, of `:actor` and `:repo`), a repeated one,
37 + or a malformed value.
26 38 """
27 − @spec parse(map(), [String.t()]) :: {:ok, t()} | :error
28 − def parse(params, categories) do
39 + @spec parse(map(), [String.t()], [:actor | :repo]) :: {:ok, t()} | :error
40 + def parse(params, categories, refines \\ [:actor, :repo]) do
29 41 with {:ok, category} <- category(params["category"], categories),
30 − {:ok, cursor} <- cursor(params["refine"]) do
31 − {:ok, %__MODULE__{category: category, cursor: cursor}}
42 + {:ok, filters} <- refine(params["refine"] || [], refines, %__MODULE__{}) do
43 + {:ok, %{filters | category: category}}
32 44 end
33 45 end
34 46
@@ -39,25 +51,72 @@ defmodule GitGudWeb.AuditLive.Filters do
39 51 if c in categories, do: {:ok, c}, else: :error
40 52 end
41 53
42 − defp cursor(nil), do: {:ok, nil}
43 − defp cursor([]), do: {:ok, nil}
54 + defp refine([], _refines, filters), do: {:ok, filters}
55 +
56 + defp refine([key, value | rest], refines, filters) when key in ["actor", "repo"] do
57 + field = String.to_existing_atom(key)
58 +
59 + if field in refines and Map.get(filters, field) == nil and Regex.match?(@name, value),
60 + do: refine(rest, refines, Map.put(filters, field, value)),
61 + else: :error
62 + end
44 63
45 − defp cursor([dir, id]) when dir in ["before", "after"] do
64 + defp refine([dir, id | rest], refines, %{cursor: nil} = filters)
65 + when dir in ["before", "after"] do
46 66 case Integer.parse(id) do
47 − {n, ""} when n > 0 -> {:ok, {String.to_existing_atom(dir), n}}
48 − _ -> :error
67 + {n, ""} when n > 0 ->
68 + refine(rest, refines, %{filters | cursor: {String.to_existing_atom(dir), n}})
69 +
70 + _ ->
71 + :error
49 72 end
50 73 end
51 74
52 − defp cursor(_), do: :error
75 + defp refine(_, _, _), do: :error
53 76
54 77 @doc "The canonical path for `filters` under `base`."
55 78 @spec to_path(String.t(), t()) :: String.t()
56 − def to_path(base, %__MODULE__{category: category, cursor: cursor}) do
57 − case {category, cursor} do
58 − {nil, nil} -> base
59 − {c, nil} -> "#{base}/#{c}"
60 − {c, {dir, id}} -> "#{base}/#{c || "all"}/#{dir}/#{id}"
79 + def to_path(base, %__MODULE__{} = filters) do
80 + refinements =
81 + [
82 + filters.actor && ["actor", filters.actor],
83 + filters.repo && ["repo", filters.repo],
84 + case filters.cursor do
85 + {dir, id} -> [Atom.to_string(dir), Integer.to_string(id)]
86 + nil -> nil
87 + end
88 + ]
89 + |> Enum.reject(&is_nil/1)
90 + |> List.flatten()
91 +
92 + case {filters.category, refinements} do
93 + {nil, []} -> base
94 + {c, []} -> "#{base}/#{c}"
95 + {c, segments} -> Enum.join([base, c || "all" | segments], "/")
61 96 end
62 97 end
98 +
99 + @doc """
100 + Filters from the filter form's fields: blank means any, and the
101 + cursor is dropped since the result is a different list. `:error` for
102 + a value no path could carry.
103 + """
104 + @spec from_form(t(), map()) :: {:ok, t()} | :error
105 + def from_form(%__MODULE__{} = filters, params) do
106 + with {:ok, actor} <- form_value(params["actor"]),
107 + {:ok, repo} <- form_value(params["repo"]) do
108 + {:ok, %{filters | actor: actor, repo: repo, cursor: nil}}
109 + end
110 + end
111 +
112 + defp form_value(nil), do: {:ok, nil}
113 +
114 + defp form_value(value) when is_binary(value) do
115 + case value |> String.trim() |> String.trim_leading("@") do
116 + "" -> {:ok, nil}
117 + v -> if Regex.match?(@name, v), do: {:ok, v}, else: :error
118 + end
119 + end
120 +
121 + defp form_value(_), do: :error
63 122 end
modified lib/git_gud_web/live/audit_live/org.ex
+15 −1
@@ -30,7 +30,7 @@ defmodule GitGudWeb.AuditLive.Org do
30 30 socket
31 31 |> assign(:org, org)
32 32 |> assign(:page_title, "Audit log — #{handle}")
33 − |> Page.setup(@categories)}
33 + |> Page.setup(@categories, repos: repo_names(org))}
34 34 else
35 35 {:ok, deny(socket, org)}
36 36 end
@@ -45,6 +45,18 @@ defmodule GitGudWeb.AuditLive.Org do
45 45 else: {:noreply, deny(socket, org)}
46 46 end
47 47
48 + @impl true
49 + def handle_event("filter", %{"filter" => params}, socket), do: Page.filter(socket, params)
50 + def handle_event(_event, _params, socket), do: {:noreply, socket}
51 +
52 + # Every repository the org has, including any gone since: a deleted
53 + # one's entries are still in the log, under its old name.
54 + defp repo_names(org) do
55 + live = GitGud.Repositories.list_repositories_for(org) |> Enum.map(& &1.name)
56 +
57 + (live ++ GitGud.Audit.deleted_repository_names(org)) |> Enum.uniq() |> Enum.sort()
58 + end
59 +
48 60 defp allowed?(_org, nil), do: false
49 61 defp allowed?(org, user), do: user.is_admin or Organizations.org_admin?(org, user)
50 62
@@ -63,7 +75,9 @@ defmodule GitGudWeb.AuditLive.Org do
63 75 <Page.body
64 76 base={@base}
65 77 filters={@filters}
78 + filter_form={@filter_form}
66 79 categories={@categories}
80 + repo_options={@repo_options}
67 81 entries={@streams.entries}
68 82 older={@older}
69 83 newer={@newer}
modified lib/git_gud_web/live/audit_live/page.ex
+104 −9
@@ -2,7 +2,13 @@ defmodule GitGudWeb.AuditLive.Page do
2 2 @moduledoc """
3 3 What the audit views share: the sudo check and canonical paths in
4 4 `handle_params`, a page of entries, and the page body — category
5 − tabs, the entries, and Older/Newer links.
5 + tabs, the actor and repository filter, the entries, and Older/Newer
6 + links.
7 +
8 + An actor filter names a handle: the account's entries if it exists,
9 + and otherwise the ones recorded under that label, so a deleted
10 + account's history can still be picked out. A repository filter does
11 + the same with the org's `handle/name`.
6 12
7 13 Each view keeps its own permission check (run on every
8 14 `handle_params`, before `params/5`) and its own chrome around
@@ -29,7 +35,8 @@ defmodule GitGudWeb.AuditLive.Page do
29 35 path = URI.parse(uri).path
30 36
31 37 with true <- Accounts.sudo_mode?(socket.assigns.current_scope.user) || :reauth,
32 − {:ok, filters} <- Filters.parse(params, socket.assigns.categories) do
38 + {:ok, filters} <-
39 + Filters.parse(params, socket.assigns.categories, socket.assigns.refines) do
33 40 canonical = Filters.to_path(base, filters)
34 41
35 42 if path == canonical,
@@ -53,20 +60,44 @@ defmodule GitGudWeb.AuditLive.Page do
53 60 @doc "The categories, of `wanted`, that some catalog action belongs to."
54 61 def categories(wanted), do: Enum.filter(wanted, &(&1 in GitGud.Audit.Catalog.categories()))
55 62
56 − @doc "Ready a mounted socket for `params/5`: the categories and the stream."
57 − def setup(socket, categories) do
63 + @doc """
64 + Ready a mounted socket for `params/5`: the categories, which
65 + refinements the view offers, and the stream. `repos:` lists the
66 + repository names the filter offers, for a view that filters by
67 + repository.
68 + """
69 + def setup(socket, categories, opts \\ []) do
70 + repos = Keyword.get(opts, :repos)
71 +
58 72 socket
59 73 |> assign(:categories, categories(categories))
74 + |> assign(:refines, if(repos, do: [:actor, :repo], else: [:actor]))
75 + |> assign(:repo_options, repos)
60 76 |> Phoenix.LiveView.stream_configure(:entries, dom_id: &"audit-entry-#{&1.id}")
61 77 end
62 78
79 + @doc "The filter form's submit: patch to the path for what it says."
80 + def filter(socket, params) do
81 + case Filters.from_form(socket.assigns.filters, params) do
82 + {:ok, filters} ->
83 + {:noreply, push_patch(socket, to: Filters.to_path(socket.assigns.base, filters))}
84 +
85 + :error ->
86 + {:noreply, put_flash(socket, :error, "That isn't a handle or repository name.")}
87 + end
88 + end
89 +
63 90 defp load(socket, base, scope, filters) do
64 91 {entries, cursors} =
65 − Audit.list(scope, %{category: filters.category}, filters.cursor, @per_page)
92 + Audit.list(scope, list_filters(scope, filters), filters.cursor, @per_page)
66 93
67 94 socket
68 95 |> assign(:base, base)
69 96 |> assign(:filters, filters)
97 + |> assign(
98 + :filter_form,
99 + to_form(%{"actor" => filters.actor || "", "repo" => filters.repo || ""}, as: :filter)
100 + )
70 101 |> assign(
71 102 :older,
72 103 cursors.older && Filters.to_path(base, %{filters | cursor: {:before, cursors.older}})
@@ -75,15 +106,43 @@ defmodule GitGudWeb.AuditLive.Page do
75 106 |> stream(:entries, entries, reset: true)
76 107 end
77 108
109 + defp list_filters(scope, filters) do
110 + %{category: filters.category}
111 + |> Map.merge(actor_filter(filters.actor))
112 + |> Map.merge(repo_filter(scope, filters.repo))
113 + end
114 +
115 + defp actor_filter(nil), do: %{}
116 +
117 + defp actor_filter(handle) do
118 + case Accounts.get_user_by_handle(handle) do
119 + %{id: id} -> %{actor_id: id}
120 + nil -> %{actor_label: handle}
121 + end
122 + end
123 +
124 + defp repo_filter(_scope, nil), do: %{}
125 +
126 + defp repo_filter({:org, org}, name) do
127 + case GitGud.Repo.get_by(GitGud.Repositories.Repository, organization_id: org.id, name: name) do
128 + %{id: id} -> %{repository_id: id}
129 + nil -> %{repository_label: "#{org.handle}/#{name}"}
130 + end
131 + end
132 +
133 + defp repo_filter(_scope, _name), do: %{}
134 +
78 135 # Back to the top when the newer page would be the first one anyway.
79 136 defp newer_path(_base, %Filters{cursor: nil}, _), do: nil
80 137 defp newer_path(base, filters, nil), do: Filters.to_path(base, %{filters | cursor: nil})
81 138 defp newer_path(base, filters, id), do: Filters.to_path(base, %{filters | cursor: {:after, id}})
82 139
83 − @doc "Category tabs, the entries, and the pager."
140 + @doc "Category tabs, the filter, the entries, and the pager."
84 141 attr :base, :string, required: true
85 142 attr :filters, Filters, required: true
143 + attr :filter_form, Phoenix.HTML.Form, required: true
86 144 attr :categories, :list, required: true
145 + attr :repo_options, :list, default: nil
87 146 attr :entries, :any, required: true
88 147 attr :older, :string, default: nil
89 148 attr :newer, :string, default: nil
@@ -93,7 +152,7 @@ defmodule GitGudWeb.AuditLive.Page do
93 152 <nav id="audit-categories" class="flex flex-wrap gap-1" aria-label="Category">
94 153 <.link
95 154 id="audit-category-all"
96 − patch={@base}
155 + patch={tab(@base, @filters, nil)}
97 156 class={["btn btn-xs", if(@filters.category == nil, do: "btn-primary", else: "btn-ghost")]}
98 157 >
99 158 All
@@ -101,16 +160,47 @@ defmodule GitGudWeb.AuditLive.Page do
101 160 <.link
102 161 :for={c <- @categories}
103 162 id={"audit-category-#{c}"}
104 − patch={"#{@base}/#{c}"}
163 + patch={tab(@base, @filters, c)}
105 164 class={["btn btn-xs", if(@filters.category == c, do: "btn-primary", else: "btn-ghost")]}
106 165 >
107 166 {category_label(c)}
108 167 </.link>
109 168 </nav>
110 169
170 + <.form
171 + for={@filter_form}
172 + id="audit-filter"
173 + phx-submit="filter"
174 + class="flex flex-wrap items-end gap-2"
175 + >
176 + <div class="w-48">
177 + <.input field={@filter_form[:actor]} label="Who" placeholder="handle" />
178 + </div>
179 + <div :if={@repo_options} class="w-48">
180 + <.input
181 + field={@filter_form[:repo]}
182 + type="select"
183 + label="Repository"
184 + prompt="Any"
185 + options={@repo_options}
186 + />
187 + </div>
188 + <button type="submit" class="btn btn-sm mb-2">Filter</button>
189 + <.link
190 + :if={@filters.actor || @filters.repo}
191 + id="audit-filter-clear"
192 + patch={Filters.to_path(@base, %{@filters | actor: nil, repo: nil, cursor: nil})}
193 + class="btn btn-sm btn-ghost mb-2"
194 + >
195 + Clear
196 + </.link>
197 + </.form>
198 +
111 199 <ul id="audit-entries" phx-update="stream" class="divide-y divide-base-300">
112 200 <li id="audit-empty" class="hidden only:block py-8 text-center text-sm opacity-60">
113 − Nothing recorded here yet.
201 + {if @filters.actor || @filters.repo,
202 + do: "Nothing matches that filter.",
203 + else: "Nothing recorded here yet."}
114 204 </li>
115 205 <.entry_row :for={{id, entry} <- @entries} id={id} entry={entry} />
116 206 </ul>
@@ -126,4 +216,9 @@ defmodule GitGudWeb.AuditLive.Page do
126 216 </nav>
127 217 """
128 218 end
219 +
220 + # A category tab keeps the actor and repository, and starts over at
221 + # the newest entry.
222 + defp tab(base, filters, category),
223 + do: Filters.to_path(base, %{filters | category: category, cursor: nil})
129 224 end
modified lib/git_gud_web/live/audit_live/repo.ex
+6 −0
@@ -59,6 +59,10 @@ defmodule GitGudWeb.AuditLive.Repo do
59 59 else: {:noreply, RepoAdmin.deny(socket, repo)}
60 60 end
61 61
62 + @impl true
63 + def handle_event("filter", %{"filter" => params}, socket), do: Page.filter(socket, params)
64 + def handle_event(_event, _params, socket), do: {:noreply, socket}
65 +
62 66 defp allowed?(_repo, nil), do: false
63 67 defp allowed?(_repo, %{is_admin: true}), do: true
64 68 defp allowed?(%{organization_id: nil, owner_id: uid}, %{id: uid}), do: true
@@ -83,7 +87,9 @@ defmodule GitGudWeb.AuditLive.Repo do
83 87 <Page.body
84 88 base={@base}
85 89 filters={@filters}
90 + filter_form={@filter_form}
86 91 categories={@categories}
92 + repo_options={@repo_options}
87 93 entries={@streams.entries}
88 94 older={@older}
89 95 newer={@newer}
added test/git_gud_web/live/audit_live/filters_test.exs
+49 −0
@@ -0,0 +1,49 @@
1 +defmodule GitGudWeb.AuditLive.FiltersTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias GitGudWeb.AuditLive.Filters
5 +
6 + @base "/orgs/acme/settings/audit"
7 + @categories ~w(secrets members)
8 +
9 + defp parse(category, refine, refines \\ [:actor, :repo]),
10 + do: Filters.parse(%{"category" => category, "refine" => refine}, @categories, refines)
11 +
12 + defp path(category, refine) do
13 + {:ok, filters} = parse(category, refine)
14 + Filters.to_path(@base, filters)
15 + end
16 +
17 + test "every refinement, in the canonical order" do
18 + assert path("secrets", ~w(actor alice repo web before 12)) ==
19 + @base <> "/secrets/actor/alice/repo/web/before/12"
20 + end
21 +
22 + test "any order reads the same, and writes the canonical one" do
23 + assert path("secrets", ~w(before 12 repo web actor alice)) ==
24 + @base <> "/secrets/actor/alice/repo/web/before/12"
25 + end
26 +
27 + test "a refinement without a category spells it all" do
28 + assert path(nil, ~w(actor alice)) == @base <> "/all/actor/alice"
29 + assert path("all", ~w(actor alice)) == @base <> "/all/actor/alice"
30 + end
31 +
32 + test "repeats, odd values, unknown keys and refinements the view lacks are errors" do
33 + assert parse("secrets", ~w(actor alice actor bob)) == :error
34 + assert parse("secrets", ~w(before 1 after 2)) == :error
35 + assert parse("secrets", ["actor", "../etc"]) == :error
36 + assert parse("secrets", ~w(actor)) == :error
37 + assert parse("secrets", ~w(label x)) == :error
38 + assert parse("secrets", ~w(repo web), [:actor]) == :error
39 + end
40 +
41 + test "the form: blank is any, a leading @ is dropped, and the cursor goes" do
42 + {:ok, filters} = parse("secrets", ~w(before 12))
43 +
44 + assert {:ok, %Filters{actor: "alice", repo: nil, cursor: nil, category: "secrets"}} =
45 + Filters.from_form(filters, %{"actor" => " @alice ", "repo" => ""})
46 +
47 + assert Filters.from_form(filters, %{"actor" => "al ice"}) == :error
48 + end
49 +end
modified test/git_gud_web/live/audit_live/org_test.exs
+85 −0
@@ -153,6 +153,91 @@ defmodule GitGudWeb.AuditLive.OrgTest do
153 153 end
154 154 end
155 155
156 + describe "who and which repository" do
157 + setup %{org: org, admin: admin} do
158 + other = user_fixture()
159 + {:ok, _} = Organizations.add_member(org, other, "admin")
160 + {:ok, web} = GitGud.Repositories.create_repository_for_org(org, admin, %{"name" => "web"})
161 + {:ok, api} = GitGud.Repositories.create_repository_for_org(org, admin, %{"name" => "api"})
162 +
163 + put = fn user, repo, name ->
164 + Audit.record(Scope.for_user(user), "repo_secret.set", %{
165 + repository: repo,
166 + target_label: name
167 + })
168 + end
169 +
170 + {:ok,
171 + other: other,
172 + web: web,
173 + api: api,
174 + mine_web: put.(admin, web, "A"),
175 + theirs_web: put.(other, web, "B"),
176 + theirs_api: put.(other, api, "C")}
177 + end
178 +
179 + test "an actor deep link", %{conn: conn, admin: admin, path: path, other: other} = ctx do
180 + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/all/actor/#{other.handle}")
181 +
182 + refute has_element?(view, "#audit-entry-#{ctx.mine_web.id}")
183 + assert has_element?(view, "#audit-entry-#{ctx.theirs_web.id}")
184 + assert has_element?(view, "#audit-entry-#{ctx.theirs_api.id}")
185 + end
186 +
187 + test "both, through the form, land on the canonical path",
188 + %{
189 + conn: conn,
190 + admin: admin,
191 + path: path,
192 + other: other
193 + } = ctx do
194 + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/secrets")
195 +
196 + view
197 + |> form("#audit-filter", %{"filter" => %{"actor" => "@" <> other.handle, "repo" => "web"}})
198 + |> render_submit()
199 +
200 + assert_patch(view, path <> "/secrets/actor/#{other.handle}/repo/web")
201 + assert has_element?(view, "#audit-entry-#{ctx.theirs_web.id}")
202 + refute has_element?(view, "#audit-entry-#{ctx.theirs_api.id}")
203 + refute has_element?(view, "#audit-entry-#{ctx.mine_web.id}")
204 +
205 + # A category tab keeps the filter; Clear drops it.
206 + assert has_element?(
207 + view,
208 + ~s(#audit-category-all[href="#{path}/all/actor/#{other.handle}/repo/web"])
209 + )
210 +
211 + view |> element("#audit-filter-clear") |> render_click()
212 + assert_patch(view, path <> "/secrets")
213 + end
214 +
215 + test "out of order comes back in order", %{conn: conn, admin: admin, path: path, other: other} do
216 + canonical = path <> "/all/actor/#{other.handle}/repo/web"
217 +
218 + assert {:error, {:live_redirect, %{to: ^canonical}}} =
219 + live(log_in_user(conn, admin), path <> "/all/repo/web/actor/#{other.handle}")
220 + end
221 +
222 + test "a deleted repository, by its old name", %{conn: conn, admin: admin, path: path} = ctx do
223 + {:ok, _} = GitGud.Repositories.delete_repository(ctx.api)
224 + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/all/repo/api")
225 +
226 + assert has_element?(view, "#audit-entry-#{ctx.theirs_api.id}")
227 + refute has_element?(view, "#audit-entry-#{ctx.theirs_web.id}")
228 + assert has_element?(view, ~s(#audit-filter option[value="api"]))
229 + end
230 +
231 + test "a deleted account, by its old handle", %{conn: conn, admin: admin, path: path} = ctx do
232 + handle = ctx.other.handle
233 + GitGud.Repo.delete!(ctx.other)
234 + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/all/actor/#{handle}")
235 +
236 + assert has_element?(view, "#audit-entry-#{ctx.theirs_web.id}")
237 + refute has_element?(view, "#audit-entry-#{ctx.mine_web.id}")
238 + end
239 + end
240 +
156 241 test "the org page and settings header link here", %{
157 242 conn: conn,
158 243 org: org,
modified test/git_gud_web/live/repo_audit_test.exs
+20 −0
@@ -239,6 +239,26 @@ defmodule GitGudWeb.RepoAuditTest do
239 239 end
240 240 end
241 241
242 + test "filters by who, not by repository", %{conn: conn} do
243 + user = user_fixture()
244 + repo = repo_for(user)
245 + conn = log_in_user(conn, user)
246 +
247 + {:ok, lv, _} = live(conn, rpath(repo, "/settings"))
248 + render_submit(lv, "save", %{"repository" => %{"description" => "x"}})
249 + [mine] = entries(repo)
250 +
251 + base = rpath(repo, "/settings/audit")
252 + {:ok, view, _} = live(conn, base <> "/all/actor/#{user.handle}")
253 + assert has_element?(view, "#audit-entry-#{mine.id}")
254 + refute has_element?(view, "#audit-filter select")
255 +
256 + {:ok, view, _} = live(conn, base <> "/all/actor/someone-else")
257 + refute has_element?(view, "#audit-entry-#{mine.id}")
258 +
259 + assert {:error, {:live_redirect, %{to: ^base}}} = live(conn, base <> "/all/repo/x")
260 + end
261 +
242 262 test "it takes sudo mode", %{conn: conn} do
243 263 user = user_fixture()
244 264 repo = repo_for(user)

Parents: 5dd3a85