Queries: the query page only opens the user's own, and pins only to their visions

b46cd93 · Gabe Morell · 2026-11-04 21:57

14 files +195 -23
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