neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Queries: the query page only opens the user's own, and pins only to their visions
b46cd93 · Gabe Morell · 2026-11-04 21:57
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum/configuration.ex
+23
−8
@@ -280,6 +280,11 @@ defmodule RoomSanctum.Configuration do
| 280 | 280 | """ |
| 281 | 281 | def get_query!(id), do: Repo.get!(Query, id) |> Repo.preload(:source) |
| 282 | 282 | |
| 283 | + # Someone else's query raises the same Ecto.NoResultsError a missing one | |
| 284 | + # does, so a page built on this cannot be used to tell the two apart. | |
| 285 | + def get_query!({:user, uid}, id), | |
| 286 | + do: Query |> Repo.get_by!(id: id, user_id: uid) |> Repo.preload(:source) | |
| 287 | + | |
| 283 | 288 | def get_queries!(ids) do |
| 284 | 289 | from(q in Query, where: q.id in ^ids) |> Repo.all() |> Repo.preload(:source) |
| 285 | 290 | end |
@@ -390,12 +395,23 @@ defmodule RoomSanctum.Configuration do
| 390 | 395 | |
| 391 | 396 | def get_vision(id), do: Repo.get(Vision, id) |
| 392 | 397 | |
| 393 | − def get_visions(:query, id) do | |
| 394 | − from(v in Vision, where: ^id in v.query_ids) |> Repo.all() | |
| 398 | + def get_vision({:user, uid}, id), do: Repo.get_by(Vision, id: id, user_id: uid) | |
| 399 | + | |
| 400 | + @doc """ | |
| 401 | + The user's visions a query is on, and (`get_visions_nv/3`) the ones it is | |
| 402 | + not -- the ones it could be pinned to. Both are scoped to the user: the query | |
| 403 | + page lists them, and another user's visions are not theirs to see or pin to. | |
| 404 | + """ | |
| 405 | + def get_visions(:query, id, user_id) do | |
| 406 | + from(v in Vision, where: v.user_id == ^user_id and ^id in v.query_ids) |> Repo.all() | |
| 395 | 407 | end |
| 396 | 408 | |
| 397 | − def get_visions_nv(:query, id) do | |
| 398 | − from(v in Vision, where: ^id not in v.query_ids or is_nil(v.query_ids) or v.query_ids == []) | |
| 409 | + def get_visions_nv(:query, id, user_id) do | |
| 410 | + from(v in Vision, | |
| 411 | + where: | |
| 412 | + v.user_id == ^user_id and | |
| 413 | + (^id not in v.query_ids or is_nil(v.query_ids) or v.query_ids == []) | |
| 414 | + ) | |
| 399 | 415 | |> Repo.all() |
| 400 | 416 | end |
| 401 | 417 |
@@ -735,10 +751,9 @@ defmodule RoomSanctum.Configuration do
| 735 | 751 | @doc """ |
| 736 | 752 | The pythiae a vision is *not* on -- the ones it could be added to. |
| 737 | 753 | |
| 738 | − Scoped to the user, unlike its opposite number for visions | |
| 739 | − (`get_visions_nv/2`), which lists every vision in the database regardless of | |
| 740 | − who owns it. A pythiae is a screen on somebody's wall; offering to put your | |
| 741 | − vision on one belonging to someone else is not a feature. | |
| 754 | + Scoped to the user, like its opposite number for visions | |
| 755 | + (`get_visions_nv/3`). A pythiae is a screen on somebody's wall; offering to | |
| 756 | + put your vision on one belonging to someone else is not a feature. | |
| 742 | 757 | """ |
| 743 | 758 | def get_pythiae_nv(:vision, id, user_id) do |
| 744 | 759 | from(p in Pythiae, |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+20
−7
@@ -85,7 +85,8 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 85 | 85 | |
| 86 | 86 | @impl true |
| 87 | 87 | def handle_params(%{"id" => id}, _, socket) do |
| 88 | − q = Configuration.get_query!(id) | |
| 88 | + # Only the user's own: anyone else's raises the not-found a missing id does. | |
| 89 | + q = Configuration.get_query!(owner(socket), id) | |
| 89 | 90 | |
| 90 | 91 | {:noreply, |
| 91 | 92 | socket |
@@ -216,8 +217,9 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 216 | 217 | defp from_foci(_query, _q), do: [] |
| 217 | 218 | |
| 218 | 219 | def handle_info(:update_sec, socket) do |
| 219 | − visions = Configuration.get_visions(:query, socket.assigns.query_id) | |
| 220 | − nv = Configuration.get_visions_nv(:query, socket.assigns.query_id) | |
| 220 | + user_id = socket.assigns.current_user.id | |
| 221 | + visions = Configuration.get_visions(:query, socket.assigns.query_id, user_id) | |
| 222 | + nv = Configuration.get_visions_nv(:query, socket.assigns.query_id, user_id) | |
| 221 | 223 | {:noreply, socket |> assign(:visions, visions) |> assign(:avail_visions, nv)} |
| 222 | 224 | end |
| 223 | 225 |
@@ -253,12 +255,15 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 253 | 255 | {:noreply, socket |> assign(:avail_sel, !socket.assigns.avail_sel)} |
| 254 | 256 | end |
| 255 | 257 | |
| 258 | + # The vision id comes from the client, so it is looked up among the user's | |
| 259 | + # own: the list only offers those, but nothing stops an event naming another. | |
| 256 | 260 | def handle_event("add-to", %{"vision" => vision}, socket) do |
| 257 | 261 | Process.send_after(self(), :update_sec, 200) |
| 258 | 262 | |
| 259 | − vision | |
| 260 | − |> Configuration.get_vision!() | |
| 261 | − |> pin_query(socket) | |
| 263 | + case Configuration.get_vision(owner(socket), vision) do | |
| 264 | + nil -> {:noreply, put_flash(socket, :error, "No such vision")} | |
| 265 | + vision -> pin_query(vision, socket) | |
| 266 | + end | |
| 262 | 267 | end |
| 263 | 268 | |
| 264 | 269 | # Create a vision around this query, for when the one you want does not exist |
@@ -288,7 +293,13 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 288 | 293 | def handle_event("remove-from", %{"vision" => vision}, socket) do |
| 289 | 294 | Process.send_after(self(), :update_sec, 200) |
| 290 | 295 | |
| 291 | − vision = Configuration.get_vision!(vision) | |
| 296 | + case Configuration.get_vision(owner(socket), vision) do | |
| 297 | + nil -> {:noreply, put_flash(socket, :error, "No such vision")} | |
| 298 | + vision -> unpin_query(vision, socket) | |
| 299 | + end | |
| 300 | + end | |
| 301 | + | |
| 302 | + defp unpin_query(vision, socket) do | |
| 292 | 303 | query_id = String.to_integer(socket.assigns.query_id) |
| 293 | 304 | |
| 294 | 305 | # Both halves again: drop every embed pointing at this query -- there may |
@@ -354,6 +365,8 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 354 | 365 | {:noreply, socket |> assign(:preview_raw, !socket.assigns.preview_raw)} |
| 355 | 366 | end |
| 356 | 367 | |
| 368 | + defp owner(socket), do: {:user, socket.assigns.current_user.id} | |
| 369 | + | |
| 357 | 370 | defp page_title(:show), do: "Query Detail" |
| 358 | 371 | defp page_title(:edit), do: "Modify Query" |
| 359 | 372 |
modified
apps/room_sanctum/test/room_sanctum_web/live/add_to_vision_test.exs
+8
−8
@@ -78,7 +78,7 @@ defmodule RoomSanctumWeb.AddToVisionTest do
| 78 | 78 | add(ctx.conn, b, ctx.vision) |
| 79 | 79 | |
| 80 | 80 | for q <- [a, b] do |
| 81 | − assert length(Configuration.get_visions(:query, to_string(q.id))) == 1, | |
| 81 | + assert length(Configuration.get_visions(:query, to_string(q.id), ctx.user.id)) == 1, | |
| 82 | 82 | "#{q.name} did not show its vision" |
| 83 | 83 | end |
| 84 | 84 | end |
@@ -86,9 +86,9 @@ defmodule RoomSanctumWeb.AddToVisionTest do
| 86 | 86 | test "a vision already holding the query stops offering itself", ctx do |
| 87 | 87 | a = query(ctx, "AAPL") |
| 88 | 88 | |
| 89 | − assert length(Configuration.get_visions_nv(:query, to_string(a.id))) == 1 | |
| 89 | + assert length(Configuration.get_visions_nv(:query, to_string(a.id), ctx.user.id)) == 1 | |
| 90 | 90 | add(ctx.conn, a, ctx.vision) |
| 91 | − assert Configuration.get_visions_nv(:query, to_string(a.id)) == [] | |
| 91 | + assert Configuration.get_visions_nv(:query, to_string(a.id), ctx.user.id) == [] | |
| 92 | 92 | end |
| 93 | 93 | |
| 94 | 94 | test "adding the same query twice does not duplicate it", ctx do |
@@ -130,7 +130,7 @@ defmodule RoomSanctumWeb.AddToVisionTest do
| 130 | 130 | |
| 131 | 131 | create_with(ctx.conn, q, "Morning commute") |
| 132 | 132 | |
| 133 | − visions = Configuration.get_visions(:query, to_string(q.id)) | |
| 133 | + visions = Configuration.get_visions(:query, to_string(q.id), ctx.user.id) | |
| 134 | 134 | assert Enum.map(visions, & &1.name) == ["Morning commute"] |
| 135 | 135 | end |
| 136 | 136 |
@@ -203,21 +203,21 @@ defmodule RoomSanctumWeb.AddToVisionTest do
| 203 | 203 | test "the query page stops listing the vision", ctx do |
| 204 | 204 | q = query(ctx, "AAPL") |
| 205 | 205 | add(ctx.conn, q, ctx.vision) |
| 206 | − assert length(Configuration.get_visions(:query, to_string(q.id))) == 1 | |
| 206 | + assert length(Configuration.get_visions(:query, to_string(q.id), ctx.user.id)) == 1 | |
| 207 | 207 | |
| 208 | 208 | remove(ctx.conn, q, ctx.vision) |
| 209 | 209 | |
| 210 | − assert Configuration.get_visions(:query, to_string(q.id)) == [] | |
| 210 | + assert Configuration.get_visions(:query, to_string(q.id), ctx.user.id) == [] | |
| 211 | 211 | end |
| 212 | 212 | |
| 213 | 213 | test "the vision is offered again afterwards", ctx do |
| 214 | 214 | q = query(ctx, "AAPL") |
| 215 | 215 | add(ctx.conn, q, ctx.vision) |
| 216 | − assert Configuration.get_visions_nv(:query, to_string(q.id)) == [] | |
| 216 | + assert Configuration.get_visions_nv(:query, to_string(q.id), ctx.user.id) == [] | |
| 217 | 217 | |
| 218 | 218 | remove(ctx.conn, q, ctx.vision) |
| 219 | 219 | |
| 220 | − assert length(Configuration.get_visions_nv(:query, to_string(q.id))) == 1 | |
| 220 | + assert length(Configuration.get_visions_nv(:query, to_string(q.id), ctx.user.id)) == 1 | |
| 221 | 221 | end |
| 222 | 222 | |
| 223 | 223 | test "the other queries in that vision are left alone", ctx do |
added
apps/room_sanctum/test/room_sanctum_web/live/query_show_ownership_test.exs
+144
−0
@@ -0,0 +1,144 @@
| 1 | +defmodule RoomSanctumWeb.QueryShowOwnershipTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The query page is the user's own: another user's query is not found, the | |
| 4 | + visions it offers to pin to are the user's, and a vision id sent by the | |
| 5 | + client is checked rather than trusted. | |
| 6 | + """ | |
| 7 | + use RoomSanctumWeb.ConnCase | |
| 8 | + | |
| 9 | + import Phoenix.LiveViewTest | |
| 10 | + | |
| 11 | + alias RoomSanctum.{Accounts, Configuration, Repo} | |
| 12 | + | |
| 13 | + defp user do | |
| 14 | + {:ok, user} = | |
| 15 | + Accounts.register_user(%{ | |
| 16 | + email: "owner#{System.unique_integer([:positive])}@example.com", | |
| 17 | + password: "hello world!hello world!" | |
| 18 | + }) | |
| 19 | + | |
| 20 | + user | |
| 21 | + end | |
| 22 | + | |
| 23 | + defp query_for(user, symbol) do | |
| 24 | + {:ok, source} = | |
| 25 | + Configuration.create_source(%{ | |
| 26 | + name: "Markets #{symbol}", | |
| 27 | + notes: "", | |
| 28 | + type: :bourse, | |
| 29 | + enabled: true, | |
| 30 | + user_id: user.id, | |
| 31 | + config: %{"__type__" => "bourse"} | |
| 32 | + }) | |
| 33 | + | |
| 34 | + {:ok, query} = | |
| 35 | + Configuration.create_query(%{ | |
| 36 | + name: "Query #{symbol}", | |
| 37 | + notes: "", | |
| 38 | + source_id: source.id, | |
| 39 | + user_id: user.id, | |
| 40 | + query: %{"__type__" => "bourse", "symbol" => symbol} | |
| 41 | + }) | |
| 42 | + | |
| 43 | + query | |
| 44 | + end | |
| 45 | + | |
| 46 | + setup %{conn: conn} do | |
| 47 | + me = user() | |
| 48 | + them = user() | |
| 49 | + | |
| 50 | + {:ok, mine} = Configuration.create_vision(%{name: "My dash", user_id: me.id}) | |
| 51 | + {:ok, theirs} = Configuration.create_vision(%{name: "Their dash", user_id: them.id}) | |
| 52 | + | |
| 53 | + %{ | |
| 54 | + conn: log_in_user(conn, me), | |
| 55 | + me: me, | |
| 56 | + them: them, | |
| 57 | + my_query: query_for(me, "AAPL"), | |
| 58 | + their_query: query_for(them, "SECRET"), | |
| 59 | + mine: mine, | |
| 60 | + theirs: theirs | |
| 61 | + } | |
| 62 | + end | |
| 63 | + | |
| 64 | + defp open_panel(conn, query) do | |
| 65 | + {:ok, live, _html} = live(conn, Routes.query_show_path(conn, :show, query)) | |
| 66 | + send(live.pid, :update_sec) | |
| 67 | + _ = render(live) | |
| 68 | + render_click(live, "toggle-sel", %{}) | |
| 69 | + live | |
| 70 | + end | |
| 71 | + | |
| 72 | + test "another user's query is not found, the same as a missing one", ctx do | |
| 73 | + assert_raise Ecto.NoResultsError, fn -> | |
| 74 | + live(ctx.conn, Routes.query_show_path(ctx.conn, :show, ctx.their_query)) | |
| 75 | + end | |
| 76 | + | |
| 77 | + assert_raise Ecto.NoResultsError, fn -> | |
| 78 | + live(ctx.conn, Routes.query_show_path(ctx.conn, :show, ctx.their_query.id + 100_000)) | |
| 79 | + end | |
| 80 | + end | |
| 81 | + | |
| 82 | + test "another user's query cannot be opened for editing either", ctx do | |
| 83 | + assert_raise Ecto.NoResultsError, fn -> | |
| 84 | + live(ctx.conn, Routes.query_show_path(ctx.conn, :edit, ctx.their_query)) | |
| 85 | + end | |
| 86 | + end | |
| 87 | + | |
| 88 | + test "the user's own query opens", ctx do | |
| 89 | + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, ctx.my_query)) | |
| 90 | + | |
| 91 | + assert html =~ ctx.my_query.name | |
| 92 | + end | |
| 93 | + | |
| 94 | + test "only the user's own visions are offered to pin to", ctx do | |
| 95 | + live = open_panel(ctx.conn, ctx.my_query) | |
| 96 | + | |
| 97 | + assert has_element?(live, ~s([phx-click="add-to"][phx-value-vision="#{ctx.mine.id}"])) | |
| 98 | + refute has_element?(live, ~s([phx-click="add-to"][phx-value-vision="#{ctx.theirs.id}"])) | |
| 99 | + end | |
| 100 | + | |
| 101 | + test "pinning to another user's vision by a forged event is refused", ctx do | |
| 102 | + live = open_panel(ctx.conn, ctx.my_query) | |
| 103 | + | |
| 104 | + html = render_click(live, "add-to", %{"vision" => to_string(ctx.theirs.id)}) | |
| 105 | + | |
| 106 | + # Not even its name: the refusal used to be "Could not add this to <name>". | |
| 107 | + refute html =~ ctx.theirs.name | |
| 108 | + | |
| 109 | + theirs = Repo.reload!(ctx.theirs) | |
| 110 | + assert theirs.query_ids in [nil, []] | |
| 111 | + assert theirs.queries == [] | |
| 112 | + end | |
| 113 | + | |
| 114 | + test "unpinning from another user's vision by a forged event is refused", ctx do | |
| 115 | + # Stored before the vision changeset checked ownership: their vision | |
| 116 | + # holding my query. Only its owner gets to take it off. | |
| 117 | + embed = %{ | |
| 118 | + "id" => nil, | |
| 119 | + "type" => "pinned", | |
| 120 | + "data" => %{"order" => 0, "query" => ctx.my_query.id, "__type__" => "pinned"} | |
| 121 | + } | |
| 122 | + | |
| 123 | + {:ok, theirs} = | |
| 124 | + ctx.theirs | |
| 125 | + |> Configuration.change_vision(%{queries: [embed]}) | |
| 126 | + |> Ecto.Changeset.put_change(:query_ids, [ctx.my_query.id]) | |
| 127 | + |> Repo.update() | |
| 128 | + | |
| 129 | + live = open_panel(ctx.conn, ctx.my_query) | |
| 130 | + render_click(live, "remove-from", %{"vision" => to_string(theirs.id)}) | |
| 131 | + | |
| 132 | + after_forgery = Repo.reload!(theirs) | |
| 133 | + assert after_forgery.query_ids == [ctx.my_query.id] | |
| 134 | + assert length(after_forgery.queries) == 1 | |
| 135 | + end | |
| 136 | + | |
| 137 | + test "pinning to the user's own vision still works", ctx do | |
| 138 | + live = open_panel(ctx.conn, ctx.my_query) | |
| 139 | + | |
| 140 | + render_click(live, "add-to", %{"vision" => to_string(ctx.mine.id)}) | |
| 141 | + | |
| 142 | + assert Repo.reload!(ctx.mine).query_ids == [ctx.my_query.id] | |
| 143 | + end | |
| 144 | +end |
Parents: 3a320cd