Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions engine/app/controllers/coplan/api/v1/comments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ def destroy
)
end

unless thread.anchored?
Broadcaster.replace_to(
@plan,
target: "plan-general-comments",
partial: "coplan/plans/general_comments",
locals: { threads: @plan.comment_threads.with_kept_comments.includes(:comments).order(:created_at) }
)
end

render json: { comment_id: comment.id, deleted_at: comment.deleted_at }
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { Controller } from "@hotwired/stimulus"
// Reveals per-viewer edit and delete actions when this comment
// belongs to the signed-in user. Broadcasts render once for all viewers
// with no current_user, so the server emits the affordance for every
// human comment and lets each browser decide whether to show it. The
// human or local_agent comment and lets each browser decide whether to show it. The
// server still enforces auth on submit — this is UX, not security.
export default class extends Controller {
static values = { authorId: String, authorType: String }
static targets = ["delete", "body", "editor"]

connect() {
const me = document.querySelector("meta[name='coplan-current-user-id']")?.content
this.isMine = this.authorTypeValue === "human" &&
this.isMine = ["human", "local_agent"].includes(this.authorTypeValue) &&
!!me &&
this.authorIdValue === me
if (this.isMine && this.hasDeleteTarget) {
Expand Down
4 changes: 2 additions & 2 deletions engine/app/policies/coplan/comment_policy.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
module CoPlan
class CommentPolicy < ApplicationPolicy
def update?
delete? && !record.deleted?
record.author_type == "human" && delete? && !record.deleted?
end

def delete?
record.author_type == "human" && record.author_id == user&.id
user.present? && record.author_type.in?(%w[human local_agent]) && record.author_id == user.id
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Every endpoint, with the guide that explains it. All paths start with `<%= @base
| `GET /api/v1/plans/:id/comments/:thread_id` | Get one thread. | comments |
| `POST /api/v1/plans/:id/comments/:thread_id/reply` | Reply: `body_markdown`. | comments |
| `PATCH /api/v1/plans/:id/comments/:thread_id/resolve` | Resolve a thread. | comments |
| `DELETE /api/v1/plans/:id/comments/:comment_id/delete` | Delete one of your principal's own comments. Comments that an agent posted cannot be deleted. | comments |
| `DELETE /api/v1/plans/:id/comments/:comment_id/delete` | Delete a human or local-agent comment attributed to your principal's account. | comments |

## References and attachments

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ Comment when you have a question, when an edit is not yours to make, or when you
- Copy `anchor_text` exactly from the plan. CoPlan highlights it for readers. Choose a short, unique phrase. If the phrase occurs more than once, add `anchor_occurrence` (1 for the first occurrence).
- Leave out `anchor_text` for a comment on the whole plan.
- `@username` in a comment notifies that person. Usernames that do not exist stay plain text.
- You cannot delete a comment that you posted. Write it carefully. To correct it, reply in the thread.
- You can delete human and local-agent comments attributed to your principal's account, including comments from another token for that account.
- You cannot delete another account's comments. Agent comments cannot be edited. To correct one, reply in the thread or delete it.

To delete a comment, use its comment ID, not its thread ID:

```bash
<%= @curl %> -X DELETE "<%= @base %>/api/v1/plans/$PLAN_ID/comments/$COMMENT_ID/delete" | jq .
```

## Review a plan

Expand Down
10 changes: 7 additions & 3 deletions engine/app/views/coplan/comments/_comment.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<%# Per-viewer affordances (Edit/Delete) are hidden client-side by the
comment_actions Stimulus controller — one broadcast is shared with every
`current_user`, so we ship the button on every human comment and let
the browser remove it for non-authors. Server still enforces auth. %>
`current_user`, so we ship actions on human and local_agent comments and
let the browser reveal them for their owner. Server still enforces auth. %>
<div class="comment <%= 'comment--deleted' if comment.deleted? %> <%= 'comment--agent' if comment.agent? %>"
id="<%= dom_id(comment) %>"
data-controller="coplan--comment-actions"
Expand Down Expand Up @@ -47,8 +47,12 @@
cancel_action: "coplan--comment-actions#cancelEdit" %>
<% end %>
</div>
<% end %>
<% if comment.author_type.in?(%w[human local_agent]) %>
<div class="comment__actions" data-coplan--comment-actions-target="delete" hidden>
<button type="button" class="comment-window__action" data-action="coplan--comment-actions#edit">Edit</button>
<% if comment.author_type == "human" %>
<button type="button" class="comment-window__action" data-action="coplan--comment-actions#edit">Edit</button>
<% end %>
<%= button_to "Delete",
plan_comment_thread_comment_path(comment.comment_thread.plan, comment.comment_thread, comment),
method: :delete,
Expand Down
34 changes: 33 additions & 1 deletion spec/policies/coplan/comment_policy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
expect(described_class.new(plan_author, comment).delete?).to be(false)
end

it "forbids deleting agent comments even when caller matches author_id" do
it "forbids deleting legacy agent comments attributed to a token instead of a user" do
token = create(:api_token, user: author)
comment = create(:comment, comment_thread: thread, author_type: "local_agent", author_id: token.id, agent_name: "Amp")
expect(described_class.new(author, comment).delete?).to be(false)
Expand All @@ -33,5 +33,37 @@
comment = create(:comment, comment_thread: thread, author_type: "human", author_id: author.id)
expect(described_class.new(nil, comment).delete?).to be(false)
end

it "allows deleting an agent comment attributed to the user, but not editing it" do
comment = create(:comment, comment_thread: thread, author_type: "local_agent", author_id: author.id, agent_name: "Agent")
policy = described_class.new(author, comment)
expect(policy.delete?).to be(true)
expect(policy.update?).to be(false)
expect(described_class.new(other_user, comment).delete?).to be(false)
expect(described_class.new(plan_author, comment).delete?).to be(false)
end

%w[cloud_persona system].each do |author_type|
it "does not treat #{author_type} IDs as user attribution" do
comment = build(:comment, author_type: author_type, author_id: author.id)
expect(described_class.new(author, comment).delete?).to be(false)
end
end

it "forbids anonymous deletion of an unattributed agent comment" do
comment = build(:comment, author_type: "local_agent", author_id: nil, agent_name: "Agent")
expect(described_class.new(nil, comment).delete?).to be(false)
end
end

describe "#update?" do
it "allows only the human author to edit a kept comment" do
comment = create(:comment, comment_thread: thread, author_id: author.id)
expect(described_class.new(author, comment).update?).to be(true)
expect(described_class.new(other_user, comment).update?).to be(false)
comment.soft_delete!
expect(described_class.new(author, comment).update?).to be(false)
expect(described_class.new(author, comment).delete?).to be(true)
end
end
end
31 changes: 22 additions & 9 deletions spec/requests/api/v1/comments_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,30 @@
)
end

it "forbids agent (token auth) callers from deleting their own agent comment" do
agent_comment = create(:comment,
comment_thread: thread_record,
author_type: "local_agent",
author_id: alice_token.id,
agent_name: "Amp",
body_markdown: "agent output")
it "soft-deletes an API-created agent comment using another token for the same account" do
post reply_api_v1_plan_comment_path(plan, thread_record),
params: { body_markdown: "Agent output" }, headers: headers, as: :json
expect(response).to have_http_status(:created)
agent_comment = CoPlan::Comment.find(JSON.parse(response.body)["comment_id"])
create(:api_token, user: alice, raw_token: "test-token-alice-other")

delete api_v1_plan_destroy_comment_path(plan, id: agent_comment.id),
headers: headers,
as: :json
headers: { "Authorization" => "Bearer test-token-alice-other" }, as: :json
expect(response).to have_http_status(:ok)
expect(agent_comment.reload.deleted_at).to be_present
expect(JSON.parse(response.body)).to include("comment_id" => agent_comment.id)

expect {
delete api_v1_plan_destroy_comment_path(plan, id: agent_comment.id), headers: headers, as: :json
}.not_to change { plan.plan_events.where(event_type: "comment_deleted").count }
expect(response).to have_http_status(:ok)
end

it "forbids deleting another account's agent comment even as the plan author and admin" do
agent_comment = create(:comment, comment_thread: thread_record,
author_type: "local_agent", author_id: create(:coplan_user).id, agent_name: "Agent")

delete api_v1_plan_destroy_comment_path(plan, id: agent_comment.id), headers: headers, as: :json
expect(response).to have_http_status(:forbidden)
expect(agent_comment.reload.deleted_at).to be_nil
end
Expand Down
22 changes: 22 additions & 0 deletions spec/requests/comments_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ def update_comment(body = "Revised **answer**")
expect(response).to redirect_to(plan_page_path(plan))
end

it "soft-deletes the user's agent comment and returns the replacement inline" do
comment.update!(author_type: "local_agent", agent_name: "Agent")
create(:comment, comment_thread: thread_record, author_id: alice.id, body_markdown: "Keep this reply")

delete plan_comment_thread_comment_path(plan, thread_record, comment),
headers: { "Accept" => "text/vnd.turbo-stream.html" }

expect(response).to have_http_status(:ok)
expect(comment.reload).to be_deleted
expect(response.body).to include('action="replace"', "Comment deleted")
end

it "protects another account's agent comment from the plan author and admin" do
comment.update!(author_type: "local_agent", author_id: create(:coplan_user).id, agent_name: "Agent")

delete plan_comment_thread_comment_path(plan, thread_record, comment)

expect(response).to redirect_to(plan_page_path(plan))
expect(comment.reload).not_to be_deleted
expect(flash[:alert]).to be_present
end

it "redirects with alert when the user is not the comment author" do
bob = create(:coplan_user)
bobs_comment = create(:comment, comment_thread: thread_record, author_type: "human", author_id: bob.id, body_markdown: "alice can't touch this")
Expand Down
94 changes: 94 additions & 0 deletions spec/system/comment_actions_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
require "rails_helper"

RSpec.describe "Comment ownership actions", type: :system do
let(:author) { create(:coplan_user, email: "owner@example.com", name: "Comment Owner") }
let(:other_user) { create(:coplan_user, email: "reviewer@example.com", name: "Other Reviewer") }
let(:plan) { create(:plan, :considering, created_by_user: author, title: "Comment ownership example") }
let(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: author) }

[ "light", "dark" ].each do |theme|
it "deletes only the user's own comments without offering agent edits in #{theme} mode" do
human = create(:comment, comment_thread: thread_record, author_id: author.id, body_markdown: "My review note")
agent = create(:comment, comment_thread: thread_record, author_type: "local_agent",
author_id: author.id, agent_name: "Agent", body_markdown: "My agent's review note")
others = %w[human local_agent].map do |author_type|
create(:comment, comment_thread: thread_record, author_type: author_type,
author_id: other_user.id, agent_name: author_type == "local_agent" ? "Agent" : nil,
body_markdown: "Another account's #{author_type} note")
end

visit sign_in_path
fill_in "Email address", with: author.email
click_button "Sign In"
expect(page).to have_current_path("/owner")
visit plan_page_path(plan)
page.execute_script("document.documentElement.dataset.theme = arguments[0]", theme)
find("#plan-general-comments button").click
panel = find(".thread-popover", visible: true)

within(panel.find("[data-comment-id='#{human.id}']")) do
expect(page).to have_button("Edit", exact: true)
expect(page).to have_button("Delete", exact: true)
end
others.each do |comment|
within(panel.find("[data-comment-id='#{comment.id}']")) do
expect(page).to have_no_button("Edit", exact: true)
expect(page).to have_no_button("Delete", exact: true)
end
end
within(panel.find("[data-comment-id='#{agent.id}']")) do
expect(page).to have_button("Delete", exact: true)
expect(page).to have_no_button("Edit", exact: true)
expect(page).to have_no_css(".comment__editor", visible: :all)
end
page.save_screenshot(Rails.root.join("tmp/comment-actions-#{theme}.png"))

dismiss_confirm("Delete this comment?") do
panel.find("[data-comment-id='#{agent.id}']").click_button "Delete"
end
expect(agent.reload).not_to be_deleted
accept_confirm("Delete this comment?") do
panel.find("[data-comment-id='#{agent.id}']").click_button "Delete"
end
expect(panel).to have_css("[data-comment-id='#{agent.id}']", text: "Comment deleted")
expect(agent.reload).to be_deleted
expect(panel.find("[data-comment-id='#{agent.id}']")).to have_no_button("Delete")
expect(human.reload).not_to be_deleted
expect(others.map { |comment| comment.reload.deleted? }).to eq([ false, false ])
page.save_screenshot(Rails.root.join("tmp/comment-actions-deleted-#{theme}.png"))
end

it "refreshes another viewer's document comments after API deletion in #{theme} mode" do
agent = create(:comment, comment_thread: thread_record, author_type: "local_agent",
author_id: author.id, agent_name: "Agent", body_markdown: "Agent thread opener")
reply = create(:comment, comment_thread: thread_record, author_id: author.id, body_markdown: "Remaining reply")
create(:api_token, user: author, raw_token: "test-comment-deletion")

visit sign_in_path
fill_in "Email address", with: other_user.email
click_button "Sign In"
expect(page).to have_css("meta[name='coplan-current-user-id'][content='#{other_user.id}']", visible: :all)
visit plan_page_path(plan)
page.execute_script("document.documentElement.dataset.theme = arguments[0]", theme)
expect(page).to have_css("turbo-cable-stream-source[connected]", visible: :all)
expect(page).to have_css("#plan-general-comments button", text: "Agent thread opener")

[ agent, reply ].each do |comment|
status = page.evaluate_async_script(<<~JS, api_v1_plan_destroy_comment_path(plan, id: comment.id))
const [url, done] = arguments;
fetch(url, { method: 'DELETE', headers: { Authorization: 'Bearer test-comment-deletion' } })
.then(response => done(response.status));
JS
expect(status).to eq(200)
expect(comment.reload).to be_deleted

if comment == agent
expect(page).to have_css("#plan-general-comments button", text: "Remaining reply")
expect(page).to have_no_css("#plan-general-comments button", text: "Agent thread opener")
else
expect(page).to have_no_css("#plan-general-comments", visible: true)
end
end
end
end
end
Loading