discourse/spec/models/badge_spec.rb
Régis Hanol aa33eaa798
FIX: Allow badges with SQL queries to be manually granted (#36866)
Previously, badges with custom SQL queries were hidden from the manual
grant dropdown in the admin UI. This was because `manually_grantable?`
returned false for any badge with a query, regardless of whether it was
a system badge or not.

This fix simplifies the logic: only system badges (the built-in
Discourse badges like "Basic User", "Member", etc.) are excluded from
manual granting. Custom badges with SQL queries can now be manually
granted to users.

Additionally, manually granted badges are now protected from
auto-revocation. When a badge's SQL query runs via backfill, it will
only revoke badges that were originally auto-granted (granted_by_id =
-1), leaving manually granted badges intact. This ensures that an
admin's deliberate decision to grant a badge isn't undone by the
automated badge system.

We considered adding a new `allow_manual_grant` setting per badge, which
would give admins explicit control over dropdown visibility. However,
this adds UI complexity and another knob to configure. The simpler
approach covers the common case: if you created a custom badge, you
probably want the option to grant it manually.

Note: Some users may have been relying on the old behavior to hide
badges from the dropdown by adding a dummy SQL query. This workaround
will no longer work - those badges will now appear in the dropdown.

Ref - t/139277
Ref - https://meta.discourse.org/t/296645
2026-01-09 13:58:09 +01:00

398 lines
12 KiB
Ruby

# frozen_string_literal: true
RSpec.describe Badge do
describe "Validations" do
subject(:badge) { Fabricate.build(:badge) }
it { is_expected.to validate_length_of(:name).is_at_most(100) }
it { is_expected.to validate_length_of(:description).is_at_most(500) }
it { is_expected.to validate_length_of(:long_description).is_at_most(1000) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:badge_type) }
it { is_expected.to validate_uniqueness_of(:name) }
end
it "has a valid system attribute for new badges" do
expect(Badge.create!(name: "test", badge_type_id: 1).system?).to be false
end
it "auto translates name" do
badge = Badge.find_by_name("Basic User")
name_english = badge.name
I18n.with_locale(:fr) { expect(badge.display_name).not_to eq(name_english) }
end
it "handles changes on badge description and long description correctly for system badges" do
badge = Badge.find_by_name("Basic User")
badge.description = badge.description.dup
badge.long_description = badge.long_description.dup
badge.save
badge.reload
expect(badge[:description]).to eq(nil)
expect(badge[:long_description]).to eq(nil)
badge.description = "testing"
badge.long_description = "testing it"
badge.save
badge.reload
expect(badge[:description]).to eq("testing")
expect(badge[:long_description]).to eq("testing it")
end
it "can ensure consistency" do
b = Badge.find_by_name("Basic User")
b.grant_count = 100
b.save
UserBadge.create!(
user_id: User.minimum(:id) - 1,
badge_id: b.id,
granted_at: 1.minute.ago,
granted_by_id: -1,
)
UserBadge.create!(
user_id: User.first.id,
badge_id: b.id,
granted_at: 1.minute.ago,
granted_by_id: -1,
)
Badge.ensure_consistency!
b.reload
expect(b.grant_count).to eq(1)
end
it "sanitizes the description" do
xss = "<b onmouseover=alert('Wufff!')>click me!</b><script>alert('TEST');</script>"
badge = Fabricate(:badge)
badge.update!(description: xss)
expect(badge.description).to eq("<b>click me!</b>alert('TEST');")
end
describe "#manually_grantable?" do
fab!(:badge) { Fabricate(:badge, name: "Test Badge") }
subject { badge.manually_grantable? }
context "when system badge" do
before { badge.system = true }
it { is_expected.to be false }
end
context "when has query" do
before { badge.query = "SELECT id FROM users" }
it { is_expected.to be true }
end
context "when not a system badge" do
before { badge.update_columns(system: false) }
it { is_expected.to be true }
end
end
describe "#image_url" do
before do
SiteSetting.enable_s3_uploads = true
SiteSetting.s3_cdn_url = "https://some-s3-cdn.amzn.com"
end
context "when the badge has an existing image" do
it "has a CDN url" do
upload = Fabricate(:upload_s3)
badge = Fabricate(:badge, image_upload_id: upload.id)
expect(badge.image_url).to start_with("https://some-s3-cdn.amzn.com")
end
end
context "when the badge does not have a related image" do
it "does not have a CDN url" do
upload = Fabricate(:upload_s3)
badge = Fabricate(:badge, image_upload_id: upload.id)
store = stub
store.expects(:remove_upload).returns(true)
Discourse.stubs(:store).returns(store)
upload.destroy!
expect(badge.reload.image_url).to eq(nil)
end
end
end
describe ".i18n_name" do
it "transforms to lower case letters, and replaces spaces with underscores" do
expect(Badge.i18n_name("Basic User")).to eq("basic_user")
end
end
describe ".display_name" do
it "fetches from translations when i18n_name key exists" do
expect(Badge.display_name("basic_user")).to eq("Basic")
expect(Badge.display_name("Basic User")).to eq("Basic")
end
it "fallbacks to argument value when translation does not exist" do
expect(Badge.display_name("Not In Translations")).to eq("Not In Translations")
end
end
describe ".find_system_badge_id_from_translation_key" do
let(:translation_key) { "badges.regular.name" }
it "uses a translation key to get a system badge id, mainly to find which badge a translation override corresponds to" do
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(Badge::Regular)
end
context "when the translation key is snake case" do
let(:translation_key) { "badges.crazy_in_love.name" }
it "works to get the badge" do
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(
Badge::CrazyInLove,
)
end
end
context "when a translation key not for a badge is provided" do
let(:translation_key) { "reports.flags.title" }
it "returns nil" do
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(nil)
end
end
context "when translation key doesn't match its class" do
let(:translation_key) { "badges.licensed.long_description" }
it "returns nil" do
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(nil)
end
end
end
describe "First Quote" do
let(:quoted_post_badge) { Badge.find(Badge::FirstQuote) }
it "Awards at the correct award date" do
freeze_time
post1 = create_post
raw = <<~RAW
[quote="#{post1.user.username}, post:#{post1.post_number}, topic:#{post1.topic_id}"]
lorem
[/quote]
RAW
post2 = create_post(raw: raw)
quoted_post = QuotedPost.find_by(post_id: post2.id)
freeze_time 1.year.from_now
quoted_post.update!(created_at: Time.now)
BadgeGranter.backfill(quoted_post_badge)
user_badge = post2.user.user_badges.find_by(badge_id: quoted_post_badge.id)
expect(user_badge.granted_at).to eq_time(post2.created_at)
end
end
describe "WikiEditor badge" do
it "is awarded" do
wiki_editor_badge = Badge.find(Badge::WikiEditor)
post = Fabricate(:post, wiki: true)
revisor = PostRevisor.new(post)
revisor.revise!(post.user, { raw: "I am editing a wiki" }, force_new_version: true)
BadgeGranter.backfill(wiki_editor_badge)
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::WikiEditor).count).to eq(1)
end
end
describe "PopularLink badge" do
let(:popular_link_badge) { Badge.find(Badge::PopularLink) }
before do
popular_link_badge.query = BadgeQueries.linking_badge(2)
popular_link_badge.save!
end
it "is awarded" do
post = create_post(raw: "https://www.discourse.org/")
TopicLinkClick.create_from(
url: "https://www.discourse.org/",
post_id: post.id,
topic_id: post.topic.id,
ip: "192.168.0.100",
)
BadgeGranter.backfill(popular_link_badge)
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::PopularLink).count).to eq(0)
TopicLinkClick.create_from(
url: "https://www.discourse.org/",
post_id: post.id,
topic_id: post.topic.id,
ip: "192.168.0.101",
)
BadgeGranter.backfill(popular_link_badge)
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::PopularLink).count).to eq(1)
end
it "is not awarded for links in a restricted category" do
category = Fabricate(:category)
post = create_post(raw: "https://www.discourse.org/", category: category)
category.set_permissions({})
category.save!
TopicLinkClick.create_from(
url: "https://www.discourse.org/",
post_id: post.id,
topic_id: post.topic.id,
ip: "192.168.0.100",
)
TopicLinkClick.create_from(
url: "https://www.discourse.org/",
post_id: post.id,
topic_id: post.topic.id,
ip: "192.168.0.101",
)
BadgeGranter.backfill(popular_link_badge)
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::PopularLink).count).to eq(0)
end
end
describe "FirstFlag badge" do
fab!(:flagging_user, :user)
fab!(:badge_enabled_category) { Fabricate(:category, allow_badges: true) }
fab!(:flagged_post) do
Fabricate(:post, topic: Fabricate(:topic, category: badge_enabled_category))
end
let(:first_flag_badge) { Badge.find(Badge::FirstFlag) }
context "when using an out-of-the-box flag" do
let!(:flag_post_action) do
Fabricate(:flag_post_action, post: flagged_post, user: flagging_user)
end
it "grants the badge" do
expect { BadgeGranter.backfill(first_flag_badge) }.to change {
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
}.by(1)
end
end
context "when using a custom flag" do
let!(:custom_flag) { Fabricate(:flag, name: "stahp", applies_to: %w[Post]) }
let!(:flag_post_action) do
Fabricate(
:flag_post_action,
post: flagged_post,
user: flagging_user,
post_action_type_id: PostActionType.types[:custom_stahp],
)
end
it "grants the badge" do
expect { BadgeGranter.backfill(first_flag_badge) }.to change {
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
}.by(1)
end
end
context "when the flag requires message" do
let!(:flag_post_action) do
Fabricate(
:flag_post_action,
post: flagged_post,
user: flagging_user,
post_action_type_id: PostActionType.types[:notify_user],
)
end
it "does not grant the badge" do
expect { BadgeGranter.backfill(first_flag_badge) }.not_to change {
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
}
end
end
context "when the flag is a like" do
let!(:flag_post_action) do
Fabricate(
:flag_post_action,
post: flagged_post,
user: flagging_user,
post_action_type_id: PostActionType.types[:like],
)
end
it "does not grant the badge" do
expect { BadgeGranter.backfill(first_flag_badge) }.not_to change {
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
}
end
end
end
describe "#seed" do
let(:badge_id) { Badge.maximum(:id) + 1 }
it "`allow_title` is not updated for existing records" do
Badge.seed do |b|
b.id = badge_id
b.name = "Foo"
b.badge_type_id = BadgeType::Bronze
b.default_allow_title = true
end
badge = Badge.find(badge_id)
expect(badge.allow_title).to eq(true)
badge.update!(allow_title: false)
Badge.seed do |b|
b.id = badge_id
b.name = "Foo"
b.badge_type_id = BadgeType::Bronze
b.default_allow_title = true
end
badge.reload
expect(badge.allow_title).to eq(false)
end
it "`enabled` is not updated for existing records" do
Badge.seed do |b|
b.id = badge_id
b.name = "Foo"
b.badge_type_id = BadgeType::Bronze
b.default_enabled = false
end
badge = Badge.find(badge_id)
expect(badge.enabled).to eq(false)
badge.update!(enabled: true)
Badge.seed do |b|
b.id = badge_id
b.name = "Foo"
b.badge_type_id = BadgeType::Bronze
b.default_enabled = false
end
badge.reload
expect(badge.enabled).to eq(true)
end
end
end