- {{#if @controller.regexpError}}
- {{@controller.regexpError}}
+ {{#if @controller.regexpErrors.length}}
+
+
{{i18n "admin.watched_words.invalid_regex_multiple"}}
+
+ {{#each @controller.regexpErrors as |error|}}
+ -
+ {{error.word}}:
+ {{error.error}}
+
+ {{/each}}
+
+
{{/if}}
diff --git a/frontend/discourse/tests/acceptance/admin-watched-words-test.js b/frontend/discourse/tests/acceptance/admin-watched-words-test.js
index c06750b1e0995..9f20ae3e0499d 100644
--- a/frontend/discourse/tests/acceptance/admin-watched-words-test.js
+++ b/frontend/discourse/tests/acceptance/admin-watched-words-test.js
@@ -215,3 +215,207 @@ acceptance("Admin - Watched Words - Bad regular expressions", function (needs) {
assert.dom(".admin-watched-words .alert-error").exists({ count: 1 });
});
});
+
+acceptance(
+ "Admin - Watched Words - Mixed valid and invalid regex",
+ function (needs) {
+ needs.user();
+ needs.pretender((server, helper) => {
+ server.get("/admin/customize/watched_words.json", () => {
+ return helper.response({
+ actions: ["block", "censor", "require_approval", "flag", "replace"],
+ words: [
+ {
+ id: 1,
+ word: "Hi",
+ regexp: "(\\W|^)(Hi)(?=\\W|$)",
+ replacement: "hello",
+ action: "replace",
+ },
+ {
+ id: 2,
+ word: "test[[",
+ regexp: "(test[[)",
+ replacement: "broken",
+ action: "replace",
+ },
+ {
+ id: 3,
+ word: "bye",
+ regexp: "(\\W|^)(bye)(?=\\W|$)",
+ replacement: "goodbye",
+ action: "replace",
+ },
+ ],
+ compiled_regular_expressions: {
+ block: [],
+ censor: [],
+ require_approval: [],
+ flag: [],
+ replace: [],
+ },
+ });
+ });
+ });
+
+ test("test modal works with replace action when invalid regex present", async function (assert) {
+ await visit("/admin/customize/watched_words/action/replace");
+ await click(".watched-word-test");
+ await fillIn(".d-modal__body textarea", "Hi there, bye!");
+
+ assert
+ .dom(".d-modal__body ul li")
+ .exists(
+ { count: 2 },
+ "Should find matches for both valid words 'Hi' and 'bye'"
+ );
+ });
+ }
+);
+
+acceptance(
+ "Admin - Watched Words - Block action with invalid compiled expression",
+ function (needs) {
+ needs.user();
+ needs.pretender((server, helper) => {
+ server.get("/admin/customize/watched_words.json", () => {
+ return helper.response({
+ actions: ["block", "censor", "require_approval", "flag", "replace"],
+ words: [
+ {
+ id: 1,
+ word: "foo",
+ regexp: "(\\W|^)(foo)(?=\\W|$)",
+ action: "block",
+ },
+ {
+ id: 2,
+ word: "test[[",
+ regexp: "(test[[)",
+ action: "block",
+ },
+ {
+ id: 3,
+ word: "bar",
+ regexp: "(\\W|^)(bar)(?=\\W|$)",
+ action: "block",
+ },
+ ],
+ compiled_regular_expressions: {
+ // Simulate a broken compiled expression that includes valid and invalid regexes
+ block: [
+ {
+ "(\\W|^)(foo|test[[|bar)(?=\\W|$)": { case_sensitive: false },
+ },
+ ],
+ censor: [],
+ require_approval: [],
+ flag: [],
+ replace: [],
+ },
+ });
+ });
+ });
+
+ test("shows error for invalid regex on main page", async function (assert) {
+ await visit("/admin/customize/watched_words/action/block");
+
+ assert.dom(".admin-watched-words .alert-error").exists({ count: 1 });
+ assert
+ .dom(".admin-watched-words .alert-error")
+ .containsText("test[[", "Shows the invalid word in error message");
+ assert
+ .dom(".admin-watched-words .alert-error")
+ .containsText(
+ "Unterminated character class",
+ "Shows the error description"
+ );
+ });
+
+ test("test modal falls back to individual words when compiled expression fails", async function (assert) {
+ await visit("/admin/customize/watched_words/action/block");
+ await click(".watched-word-test");
+ await fillIn(".d-modal__body textarea", "this foo and bar text");
+
+ assert
+ .dom(".d-modal__body")
+ .doesNotContainText(
+ "No matches found",
+ "Should find matches via individual word fallback"
+ );
+
+ assert
+ .dom(".d-modal__body")
+ .containsText("Found matches:", "Should show matches");
+
+ assert
+ .dom(".d-modal__body ul li")
+ .exists({ count: 2 }, "Should find both foo and bar");
+ });
+ }
+);
+
+acceptance("Admin - Watched Words - Unicode flag validation", function (needs) {
+ needs.user();
+ needs.pretender((server, helper) => {
+ server.get("/admin/customize/watched_words.json", () => {
+ return helper.response({
+ actions: ["block", "censor", "require_approval", "flag", "replace"],
+ words: [
+ {
+ id: 1,
+ word: "pattern1",
+ regexp: "(pattern[[nested]])",
+ action: "block",
+ },
+ {
+ id: 2,
+ word: "pattern2",
+ regexp: "(test{incomplete)",
+ action: "block",
+ },
+ ],
+ compiled_regular_expressions: {
+ block: [],
+ censor: [],
+ require_approval: [],
+ flag: [],
+ replace: [],
+ },
+ });
+ });
+ });
+
+ test("detects invalid patterns with unicode flag validation", async function (assert) {
+ await visit("/admin/customize/watched_words/action/block");
+
+ assert
+ .dom(".admin-watched-words .alert-error ul li")
+ .exists(
+ { count: 2 },
+ "Shows errors for both patterns with unicode validation"
+ );
+
+ assert
+ .dom(".admin-watched-words .alert-error")
+ .containsText("pattern1", "Shows first invalid pattern");
+
+ assert
+ .dom(".admin-watched-words .alert-error")
+ .containsText("pattern2", "Shows second invalid pattern");
+
+ assert
+ .dom(".admin-watched-words .alert-error")
+ .containsText(
+ "Lone quantifier brackets",
+ "Shows error for nested brackets"
+ );
+
+ assert
+ .dom(".admin-watched-words .alert-error")
+ .containsText(
+ "Incomplete quantifier",
+ "Shows error for incomplete quantifier"
+ );
+ });
+});
diff --git a/spec/services/word_watcher_spec.rb b/spec/services/word_watcher_spec.rb
index 6ae66ec4a3266..0867d2d2b4f48 100644
--- a/spec/services/word_watcher_spec.rb
+++ b/spec/services/word_watcher_spec.rb
@@ -137,6 +137,42 @@
expect(regexps).to be_empty
end
end
+
+ context "when there's an invalid regex that causes compilation to fail" do
+ before do
+ SiteSetting.watched_words_regular_expressions = true
+ WatchedWord.where(action: WatchedWord.actions[:block]).delete_all
+ Fabricate(:watched_word, word: "test[[", action: WatchedWord.actions[:block])
+ Fabricate(:watched_word, word: "bad", action: WatchedWord.actions[:block])
+ Fabricate(:watched_word, word: "word", action: WatchedWord.actions[:block])
+ end
+
+ it "does not raise an exception and still compiles valid words" do
+ expect { described_class.compiled_regexps_for_action(:block) }.not_to raise_error
+ regexps = described_class.compiled_regexps_for_action(:block)
+ expect(regexps).to be_an(Array)
+ expect(regexps).not_to be_empty
+ expect(regexps.first.inspect).to match(/bad.*word/i)
+ end
+
+ it "still detects matches for valid words even with invalid regex present" do
+ watcher = described_class.new("This is a bad word")
+ expect(watcher.should_block?).to be_truthy
+ expect(watcher.word_matches_for_action?(:block, all_matches: true)).to include(
+ "bad",
+ "word",
+ )
+ end
+
+ it "does not break serialized_regexps_for_action" do
+ expect {
+ described_class.serialized_regexps_for_action(:block, engine: :js)
+ }.not_to raise_error
+ serialized = described_class.serialized_regexps_for_action(:block, engine: :js)
+ expect(serialized).to be_an(Array)
+ expect(serialized).not_to be_empty
+ end
+ end
end
describe "#word_matches_for_action?" do