Visions: query_ids only take the owner's own queries

5a0c3c4 · Gabe Morell · 2026-11-04 21:46

9 files +122 -7
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/configuration/vision.ex
+19 −7
@@ -1,6 +1,10 @@
1 1 defmodule RoomSanctum.Configuration.Vision do
2 2 use Ecto.Schema
3 3 import Ecto.Changeset
4 + import Ecto.Query, only: [from: 2]
5 +
6 + alias RoomSanctum.Configuration.Query
7 + alias RoomSanctum.Repo
4 8
5 9 schema "cfg_visions" do
6 10 belongs_to :user, RoomSanctum.Accounts.User
@@ -74,16 +78,24 @@ defmodule RoomSanctum.Configuration.Vision do
74 78 end
75 79 end
76 80
81 + # A vision can only point at its owner's queries -- the form's picker only
82 + # offers those. Only the ids this change adds are checked: delete_query/1
83 + # leaves its id behind on any vision it was on, so a stored vision can
84 + # already hold ids with nothing behind them, and it has to stay editable.
77 85 defp validate_queries_have_valid_ids(changeset) do
78 − case get_field(changeset, :query_ids) do
79 − [_ | _] ->
80 − # Validate that all query IDs exist and belong to the user
81 − changeset
82 −
83 − _ ->
84 − changeset
86 + with [_ | _] = ids <- get_change(changeset, :query_ids),
87 + [_ | _] = added <- Enum.uniq(ids) -- (changeset.data.query_ids || []),
88 + user_id when not is_nil(user_id) <- get_field(changeset, :user_id),
89 + [_ | _] <- added -- owned_query_ids(added, user_id) do
90 + add_error(changeset, :query_ids, "must be queries you own")
91 + else
92 + _ -> changeset
85 93 end
86 94 end
95 +
96 + defp owned_query_ids(ids, user_id) do
97 + Repo.all(from q in Query, where: q.id in ^ids and q.user_id == ^user_id, select: q.id)
98 + end
87 99 end
88 100
89 101 defmodule RoomSanctum.Configuration.Vision.Schema do
added apps/room_sanctum/test/room_sanctum/vision_query_ids_test.exs
+103 −0
@@ -0,0 +1,103 @@
1 +defmodule RoomSanctum.VisionQueryIdsTest do
2 + @moduledoc """
3 + A vision can only point at its owner's queries -- the form's picker only
4 + offers those -- and `query_ids` is what every "which visions is this query
5 + on" lookup reads, so an id that is someone else's, or nobody's, does not get
6 + in.
7 + """
8 + use RoomSanctum.DataCase
9 +
10 + alias RoomSanctum.{Accounts, Configuration}
11 +
12 + defp user do
13 + {:ok, user} =
14 + Accounts.register_user(%{
15 + email: "seer#{System.unique_integer([:positive])}@example.com",
16 + password: "hello world!hello world!"
17 + })
18 +
19 + user
20 + end
21 +
22 + defp query(user) do
23 + {:ok, source} =
24 + Configuration.create_source(%{
25 + name: "Markets",
26 + notes: "",
27 + type: :bourse,
28 + enabled: true,
29 + user_id: user.id,
30 + config: %{"__type__" => "bourse"}
31 + })
32 +
33 + {:ok, query} =
34 + Configuration.create_query(%{
35 + name: "AAPL",
36 + source_id: source.id,
37 + user_id: user.id,
38 + query: %{"__type__" => "bourse", "symbol" => "AAPL"}
39 + })
40 +
41 + query
42 + end
43 +
44 + test "the owner's own queries are accepted" do
45 + me = user()
46 + q = query(me)
47 +
48 + assert {:ok, vision} =
49 + Configuration.create_vision(%{name: "Mine", user_id: me.id, query_ids: [q.id]})
50 +
51 + assert vision.query_ids == [q.id]
52 + end
53 +
54 + test "another user's query is refused" do
55 + me = user()
56 + theirs = query(user())
57 +
58 + assert {:error, changeset} =
59 + Configuration.create_vision(%{name: "Mine", user_id: me.id, query_ids: [theirs.id]})
60 +
61 + assert %{query_ids: [_]} = errors_on(changeset)
62 + end
63 +
64 + test "an id with no query behind it is refused" do
65 + me = user()
66 + q = query(me)
67 +
68 + assert {:error, changeset} =
69 + Configuration.create_vision(%{
70 + name: "Mine",
71 + user_id: me.id,
72 + query_ids: [q.id, q.id + 100_000]
73 + })
74 +
75 + assert %{query_ids: [_]} = errors_on(changeset)
76 + end
77 +
78 + test "adding to a vision checks only what is being added" do
79 + me = user()
80 + q = query(me)
81 +
82 + {:ok, vision} =
83 + Configuration.create_vision(%{name: "Mine", user_id: me.id, query_ids: [q.id]})
84 +
85 + # delete_query/1 leaves the id behind on any vision it was pinned to, so
86 + # stored visions can already hold ids with nothing behind them. They have
87 + # to stay editable.
88 + {:ok, _} = Configuration.delete_query(q)
89 + assert {:ok, renamed} = Configuration.update_vision_ni(vision, %{name: "Renamed"})
90 +
91 + other = query(me)
92 +
93 + assert {:ok, _} =
94 + Configuration.update_vision_ni(renamed, %{query_ids: renamed.query_ids ++ [other.id]})
95 +
96 + theirs = query(user())
97 +
98 + assert {:error, _} =
99 + Configuration.update_vision_ni(renamed, %{
100 + query_ids: renamed.query_ids ++ [theirs.id]
101 + })
102 + end
103 +end

Parents: c7ea72b