0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-07 13:19:19 +08:00
discourse/spec/requests/bookmarks_controller_spec.rb
discoursebot da6a89a985
SECURITY: Enforce first-post visibility for topic bookmarks [backport 2026.7] (#42353)
Backport of #42321 to release/2026.7.

---

## Summary

Topic bookmark creation, listing, search, and reminder eligibility now
require the first post to be visible to the user. The fix adds an inner
join on the first post with hidden-post filtering to the list query and
delegates creation and visibility checks to `guardian.can_see_post?` on
the first post, preventing an authenticated user from bookmarking a
topic or searching its metadata after the first post is hidden.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/1530

Co-authored-by: discourse-patch-triage
<272280883+discourse-patch-triage[bot]@users.noreply.github.com>

---------

Co-authored-by: Chris Alberti <christo@discourse.org>
2026-08-05 12:53:16 -05:00

253 lines
8.3 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe BookmarksController do
let(:current_user) { Fabricate(:user) }
let(:user_2) { Fabricate(:user) }
let(:bookmark_post) { Fabricate(:post) }
let(:bookmark_post_2) { Fabricate(:post) }
let(:bookmark_topic) { Fabricate(:topic_with_op) }
let(:bookmark_user) { current_user }
describe "#create" do
before { sign_in(current_user) }
it "rate limits creates" do
SiteSetting.max_bookmarks_per_day = 1
RateLimiter.enable
post "/bookmarks.json",
params: {
bookmarkable_id: bookmark_post.id,
bookmarkable_type: "Post",
reminder_at: 1.day.from_now.iso8601,
}
expect(response.status).to eq(200)
post "/bookmarks.json",
params: {
bookmarkable_id: bookmark_post.id,
bookmarkable_type: "Post",
}
expect(response.status).to eq(429)
end
context "if the user reached the max bookmark limit" do
before { SiteSetting.max_bookmarks_per_user = 1 }
it "returns failed JSON with a 400 error" do
post "/bookmarks.json",
params: {
bookmarkable_id: bookmark_post.id,
bookmarkable_type: "Post",
reminder_at: 1.day.from_now.iso8601,
}
post "/bookmarks.json",
params: {
bookmarkable_id: bookmark_post.id,
bookmarkable_type: "Post",
}
expect(response.status).to eq(400)
user_bookmarks_url = "#{Discourse.base_url}/my/activity/bookmarks"
expect(response.parsed_body["errors"]).to include(
I18n.t(
"bookmarks.errors.too_many",
user_bookmarks_url: user_bookmarks_url,
limit: SiteSetting.max_bookmarks_per_user,
),
)
end
end
context "if the user already has bookmarked the record" do
before do
Fabricate(:bookmark, bookmarkable: bookmark_post, user: bookmark_user)
Fabricate(:bookmark, bookmarkable: bookmark_topic, user: bookmark_user)
end
it "returns failed JSON with a 400 error" do
post "/bookmarks.json",
params: {
bookmarkable_id: bookmark_post.id,
bookmarkable_type: "Post",
reminder_at: 1.day.from_now.iso8601,
}
expect(response.status).to eq(400)
expect(response.parsed_body["errors"]).to include(
I18n.t("bookmarks.errors.already_bookmarked", type: "Post"),
)
post "/bookmarks.json",
params: {
bookmarkable_id: bookmark_topic.id,
bookmarkable_type: "Topic",
reminder_at: 1.day.from_now.iso8601,
}
expect(response.status).to eq(400)
expect(response.parsed_body["errors"]).to include(
I18n.t("bookmarks.errors.already_bookmarked", type: "Topic"),
)
end
end
it "returns a 403 when the first post of a topic is hidden" do
hidden_topic = Fabricate(:topic_with_op)
hidden_topic.first_post.update!(hidden: true)
post "/bookmarks.json",
params: {
bookmarkable_id: hidden_topic.id,
bookmarkable_type: "Topic",
}
expect(response.status).to eq(403)
expect(Bookmark.find_by(user: current_user, bookmarkable: hidden_topic)).to be_nil
end
end
describe "#destroy" do
before { sign_in(current_user) }
let!(:bookmark) { Fabricate(:bookmark, bookmarkable: bookmark_post, user: bookmark_user) }
it "destroys the bookmark" do
delete "/bookmarks/#{bookmark.id}.json"
expect(Bookmark.find_by(id: bookmark.id)).to eq(nil)
end
it "returns an indication of whether there are still bookmarks in the topic" do
delete "/bookmarks/#{bookmark.id}.json"
expect(Bookmark.find_by(id: bookmark.id)).to eq(nil)
expect(response.parsed_body["topic_bookmarked"]).to eq(false)
bm2 =
Fabricate(
:bookmark,
user: bookmark_user,
bookmarkable: Fabricate(:post, topic: bookmark_post.topic),
)
bm3 = Fabricate(:bookmark, user: bookmark_user, bookmarkable: bookmark_post.topic)
delete "/bookmarks/#{bm2.id}.json"
expect(Bookmark.find_by(id: bm2.id)).to eq(nil)
expect(response.parsed_body["topic_bookmarked"]).to eq(true)
delete "/bookmarks/#{bm3.id}.json"
expect(Bookmark.find_by(id: bm3.id)).to eq(nil)
expect(response.parsed_body["topic_bookmarked"]).to eq(false)
end
context "if the bookmark has already been destroyed" do
it "returns failed JSON with a 403 error" do
bookmark.destroy!
delete "/bookmarks/#{bookmark.id}.json"
expect(response.status).to eq(404)
expect(response.parsed_body["errors"].first).to include(I18n.t("not_found"))
end
end
context "if the bookmark does not belong to the user" do
let(:bookmark_user) { Fabricate(:user) }
it "returns failed JSON with a 403 error" do
delete "/bookmarks/#{bookmark.id}.json"
expect(response.status).to eq(403)
expect(response.parsed_body["errors"].first).to include(I18n.t("invalid_access"))
end
end
end
describe "#bulk" do
it "needs you to be logged in" do
put "/bookmarks/bulk.json"
expect(response.status).to eq(403)
end
describe "when logged in" do
before { sign_in(bookmark_user) }
let!(:bookmark) { Fabricate(:bookmark, bookmarkable: bookmark_post, user: bookmark_user) }
let!(:bookmark_2) { Fabricate(:bookmark, bookmarkable: bookmark_post_2, user: bookmark_user) }
let!(:operation) { { type: "clear_reminder" } }
let!(:bookmark_ids) { [bookmark.id, bookmark_2.id] }
it "requires a list of bookmark_ids" do
put "/bookmarks/bulk.json", params: { operation: operation }
expect(response.status).to eq(400)
end
it "requires an operation param" do
put "/bookmarks/bulk.json", params: { bookmark_ids: bookmark_ids }
expect(response.status).to eq(400)
end
it "can clear reminder for the given bookmarks" do
expect do
put "/bookmarks/bulk.json",
params: {
operation: {
type: "clear_reminder",
},
bookmark_ids: [bookmark.id],
}
expect(response.status).to eq(200)
end.to change { Bookmark.find(bookmark.id).reminder_set_at }.to(nil)
end
it "can delete bookmarks" do
expect do
put "/bookmarks/bulk.json",
params: {
operation: {
type: "delete",
},
bookmark_ids: [bookmark.id, bookmark_2.id],
}
expect(response.status).to eq(200)
end.to change { Bookmark.where(id: [bookmark, bookmark_2]).count }.from(2).to(0)
end
end
describe "can't update other user's bookmarks" do
before { sign_in(user_2) }
let!(:bookmark) { Fabricate(:bookmark, bookmarkable: bookmark_post, user: bookmark_user) }
let!(:bookmark_2) { Fabricate(:bookmark, bookmarkable: bookmark_post_2, user: bookmark_user) }
let!(:operation) { { type: "clear_reminder" } }
let!(:bookmark_ids) { [bookmark.id, bookmark_2.id] }
it "CAN'T clear reminder if the bookmark does not belong to the user" do
expect do
put "/bookmarks/bulk.json",
params: {
operation: {
type: "clear_reminder",
},
bookmark_ids: [bookmark.id, bookmark_2.id],
}
expect(response.status).to eq(403)
expect(response.parsed_body["errors"].first).to include(I18n.t("invalid_access"))
end.to_not change { Bookmark.find(bookmark.id).reminder_set_at }
end
it "CAN'T delete bookmarks that does not belong to the user" do
expect do
put "/bookmarks/bulk.json",
params: {
operation: {
type: "delete",
},
bookmark_ids: [bookmark.id, bookmark_2.id],
}
expect(response.status).to eq(403)
expect(response.parsed_body["errors"].first).to include(I18n.t("invalid_access"))
end.to_not change { Bookmark.where(id: [bookmark, bookmark_2]).count }.from(2)
end
end
end
end