mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 03:05:45 +08:00
## 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
144 lines
4.4 KiB
Ruby
Vendored
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
|