discourse/plugins/discourse-ai/spec/models/reviewable_ai_tool_action_spec.rb
Rafael dos Santos Silva 0bc1216009
FIX: Scope ReviewableAiToolAction to target post's topic and category (#39716)
## Summary

`ReviewableAiToolAction` records were being created without `topic_id`
or `category_id`. `Reviewable#created_new!` only copies those fields
when the target is a `Post`, but the target here is `AiToolAction`, so
every record landed unscoped. As a result, `Reviewable.viewable_by`'s
`category_id IS NULL` allowance made these reviewables visible to
**all** moderators — including ones who couldn't access the restricted
category the underlying action targeted.

This brings `ReviewableAiToolAction` in line with how
`ReviewableFlaggedPost` already behaves for the same shape of data:

- Override `created_new!` to read the target action's post and copy
`topic`/`category_id` onto the reviewable, so category moderation groups
route the queue entry to the right reviewers.
- Memoize a `target_post` accessor for reuse by the model and
serializer.
- Backfill migration updates pre-existing rows by joining
`ai_tool_actions → posts → topics`. Idempotent via `topic_id IS NULL`
guard.

This is intentionally a routing/scoping fix only — the serializer is
unchanged. AI agents using approval-required tools are admin-configured
and opt-in to human review, and reviewers need the full post and tool
parameters to make an informed decision; suppressing those would defeat
the human-in-the-loop the admin enabled.

## Test plan

- [x] `bin/rspec
plugins/discourse-ai/spec/models/reviewable_ai_tool_action_spec.rb`
passes (new `#created_new!` cases covering private-category routing and
the no-post fallback)
- [x] `bin/rspec
plugins/discourse-ai/spec/db/migrate/20260504211108_backfill_reviewable_ai_tool_action_scope_spec.rb`
passes (backfill, no-post stays nil, already-scoped rows untouched)
- [x] `bin/lint` clean on all four files
2026-05-05 10:24:19 -03:00

144 lines
4.4 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe ReviewableAiToolAction do
fab!(:admin)
fab!(:ai_agent)
fab!(:topic)
let(:bot_user) { Discourse.system_user }
before do
enable_current_plugin
SiteSetting.ai_bot_enabled = true
end
def create_tool_action(tool_name: "close_topic", params: nil, post_id: nil)
params ||= { topic_id: topic.id, closed: true, reason: "Off-topic" }
AiToolAction.create!(
tool_name: tool_name,
tool_parameters: params,
ai_agent: ai_agent,
bot_user_id: bot_user.id,
post_id: post_id,
)
end
def create_reviewable(tool_action)
reviewable =
described_class.needs_review!(
target: tool_action,
created_by: bot_user,
reviewable_by_moderator: true,
payload: {
agent_name: "Test Agent",
reason: "Off-topic",
},
)
reviewable.add_score(
Discourse.system_user,
ReviewableScore.types[:needs_approval],
force_review: true,
)
reviewable
end
describe "#created_new!" do
fab!(:private_category_group, :group)
fab!(:private_category) { Fabricate(:private_category, group: private_category_group) }
fab!(:private_topic) { Fabricate(:topic, category: private_category) }
fab!(:private_post) { Fabricate(:post, topic: private_topic) }
it "scopes the reviewable to the target post's topic and category" do
tool_action = create_tool_action(post_id: private_post.id)
reviewable = create_reviewable(tool_action)
expect(reviewable.topic).to eq(private_topic)
expect(reviewable.category).to eq(private_category)
end
it "leaves topic and category blank when the target action has no post" do
tool_action = create_tool_action(post_id: nil)
reviewable = create_reviewable(tool_action)
expect(reviewable.topic).to be_nil
expect(reviewable.category).to be_nil
end
end
describe "#build_actions" do
it "has approve and reject actions when pending" do
tool_action = create_tool_action
reviewable = create_reviewable(tool_action)
actions = Reviewable::Actions.new(reviewable, Guardian.new(admin), {})
reviewable.build_actions(actions, Guardian.new(admin), {})
expect(actions.has?(:approve)).to eq(true)
expect(actions.has?(:reject)).to eq(true)
end
it "returns no actions when not pending" do
tool_action = create_tool_action
reviewable = create_reviewable(tool_action)
reviewable.status = Reviewable.statuses[:approved]
actions = Reviewable::Actions.new(reviewable, Guardian.new(admin), {})
reviewable.build_actions(actions, Guardian.new(admin), {})
expect(actions.has?(:approve)).to eq(false)
expect(actions.has?(:reject)).to eq(false)
end
end
describe "#perform_approve" do
it "executes the tool and transitions to approved" do
tool_action = create_tool_action
reviewable = create_reviewable(tool_action)
result = reviewable.perform(admin, :approve)
expect(result.success?).to eq(true)
expect(result.transition_to).to eq(:approved)
expect(topic.reload.closed).to eq(true)
end
it "raises error when target is missing" do
tool_action = create_tool_action
reviewable = create_reviewable(tool_action)
tool_action.destroy!
reviewable.reload
expect { reviewable.perform(admin, :approve) }.to raise_error(Discourse::InvalidAccess)
end
it "raises error when tool class is not found" do
tool_action = create_tool_action(tool_name: "nonexistent_tool")
reviewable = create_reviewable(tool_action)
expect { reviewable.perform(admin, :approve) }.to raise_error(Discourse::InvalidAccess)
end
it "approves even when tool returns an error result (e.g. stale target)" do
tool_action = create_tool_action(params: { topic_id: -999, closed: true, reason: "test" })
reviewable = create_reviewable(tool_action)
result = reviewable.perform(admin, :approve)
expect(result.success?).to eq(true)
expect(result.transition_to).to eq(:approved)
end
end
describe "#perform_reject" do
it "transitions to rejected without executing the tool" do
tool_action = create_tool_action
reviewable = create_reviewable(tool_action)
result = reviewable.perform(admin, :reject)
expect(result.success?).to eq(true)
expect(result.transition_to).to eq(:rejected)
expect(topic.reload.closed).to eq(false)
end
end
end