discourse/plugins/discourse-data-explorer/spec/data_explorer_spec.rb
Régis Hanol 72e55caa64
FEATURE: Add current_user_id parameter type to Data Explorer (#36655)
Introduces a new `current_user_id` parameter type that automatically
injects the ID of the user running the query. This enables secure
"personal data" queries in group reports where non-admin users can run
queries filtered to their own data.

Why:
- Members requested queries like "show my recent posts" for group
reports, but there was no secure way to reference the current user
- Passing user_id as a regular parameter would allow users to spoof
other users' IDs

How it works:
- Parameter is injected server-side, ignoring any user-provided value
- Frontend hides input fields for "internal" parameter types
- Supports nullable option for queries that may run without auth

Example usage:
```sql
-- [params] 
-- current_user_id :me 
SELECT * 
FROM posts 
WHERE user_id = :me
```

Here are a few screenshots of how it looks like:

The query from the admin PoV

<img width="1471" height="1092" alt="CleanShot 2025-12-12 at 11 20 10"
src="https://github.com/user-attachments/assets/bc6ce759-ebcb-4550-9035-dbaf7ae034da"
/>

How it looks like from a member of the report's allowed group

<img width="1471" height="1092" alt="CleanShot 2025-12-12 at 11 19 48"
src="https://github.com/user-attachments/assets/10eb1ddb-c93b-4608-988d-e4a8ca13d8ba"
/>
2025-12-12 16:40:14 +01:00

178 lines
5.9 KiB
Ruby

# frozen_string_literal: true
describe DiscourseDataExplorer::DataExplorer do
describe ".run_query" do
fab!(:topic)
it "should run a query that includes PG template patterns" do
sql = <<~SQL
WITH query AS (
SELECT TO_CHAR(created_at, 'yyyy:mm:dd') AS date FROM topics
) SELECT * FROM query
SQL
query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql)
result = described_class.run_query(query)
expect(result[:error]).to eq(nil)
expect(result[:pg_result][0]["date"]).to eq(topic.created_at.strftime("%Y:%m:%d"))
end
it "should run a query containing a question mark in the comment" do
sql = <<~SQL
WITH query AS (
SELECT id FROM topics -- some SQL ? comment ?
) SELECT * FROM query
SQL
query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql)
result = described_class.run_query(query)
expect(result[:error]).to eq(nil)
expect(result[:pg_result][0]["id"]).to eq(topic.id)
end
it "can run a query with params interpolation" do
topic2 = Fabricate(:topic)
sql = <<~SQL
-- [params]
-- int :topic_id = 99999999
WITH query AS (
SELECT
id,
TO_CHAR(created_at, 'yyyy:mm:dd') AS date
FROM topics
WHERE topics.id = :topic_id
) SELECT * FROM query
SQL
query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql)
result = described_class.run_query(query, { "topic_id" => topic2.id.to_s })
expect(result[:error]).to eq(nil)
expect(result[:pg_result].to_a.size).to eq(1)
expect(result[:pg_result][0]["id"]).to eq(topic2.id)
end
describe "current_user_id parameter" do
fab!(:user)
it "injects the current user's id, ignoring user-provided values" do
sql = <<~SQL
-- [params]
-- current_user_id :me
SELECT id FROM users WHERE id = :me
SQL
query = DiscourseDataExplorer::Query.create!(name: "test", sql: sql)
other_user = Fabricate(:user)
result =
described_class.run_query(query, { "me" => other_user.id.to_s }, { current_user: user })
expect(result[:error]).to eq(nil)
expect(result[:pg_result][0]["id"]).to eq(user.id)
end
it "returns an error when not nullable and no current user" do
sql = <<~SQL
-- [params]
-- current_user_id :me
SELECT id FROM users WHERE id = :me
SQL
query = DiscourseDataExplorer::Query.create!(name: "test", sql: sql)
result = described_class.run_query(query, {}, {})
expect(result[:error]).to be_a(DiscourseDataExplorer::ValidationError)
expect(result[:error].message).to include("requires a logged in user")
end
it "allows null when nullable and no current user" do
sql = <<~SQL
-- [params]
-- null current_user_id :me
SELECT COALESCE(:me, -1) AS user_id
SQL
query = DiscourseDataExplorer::Query.create!(name: "test", sql: sql)
result = described_class.run_query(query, {}, {})
expect(result[:error]).to eq(nil)
expect(result[:pg_result][0]["user_id"]).to eq(-1)
end
end
describe ".add_extra_data" do
it "treats any column with payload in the name as 'json'" do
Fabricate(:reviewable_queued_post)
sql = <<~SQL
SELECT id, payload FROM reviewables LIMIT 10
SQL
query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql)
result = described_class.run_query(query)
_, colrender = DiscourseDataExplorer::DataExplorer.add_extra_data(result[:pg_result])
expect(colrender).to eq({ 1 => "json" })
end
it "treats columns with the actual json data type as 'json'" do
ApiKeyScope.create(
resource: "topics",
action: "update",
api_key_id: Fabricate(:api_key).id,
allowed_parameters: {
"category_id" => ["#{topic.category_id}"],
},
)
sql = <<~SQL
SELECT id, allowed_parameters FROM api_key_scopes LIMIT 10
SQL
query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql)
result = described_class.run_query(query)
_, colrender = DiscourseDataExplorer::DataExplorer.add_extra_data(result[:pg_result])
expect(colrender).to eq({ 1 => "json" })
end
describe "serializing models to serializer" do
it "serializes correctly to BasicTopicSerializer for topic relations" do
topic = Fabricate(:topic, locale: "ja")
query = Fabricate(:query, sql: "SELECT id AS topic_id FROM topics WHERE id = #{topic.id}")
pg_result = described_class.run_query(query)[:pg_result]
relations, _ = DiscourseDataExplorer::DataExplorer.add_extra_data(pg_result)
expect {
records = relations[:topic].object
records.map { |t| BasicTopicSerializer.new(t, root: false).as_json }
}.not_to raise_error
json = relations[:topic].as_json
expect(json).to include(BasicTopicSerializer.new(topic, root: false).as_json)
end
it "chooses the correct serializer for tag_group" do
tag_group = Fabricate(:tag_group)
tag1 = Fabricate(:tag)
tag2 = Fabricate(:tag)
tag_group.tags = [tag1, tag2]
query = Fabricate(:query, sql: "SELECT tag_id, tag_group_id FROM tag_group_memberships")
pg_result = described_class.run_query(query)[:pg_result]
relations, colrender = DiscourseDataExplorer::DataExplorer.add_extra_data(pg_result)
expect(colrender).to eq({ 1 => :tag_group })
expect(relations[:tag_group].as_json).to include(
{ "id" => tag_group.id, "name" => tag_group.name },
)
end
end
end
end
end