Skip to content

Commit a71eb41

Browse files
authored
FIX: user autocomplete search cache pollution (#34208)
The current chat autocomplete pipeline can mutate the user object selected for autocomplete here: https://github.com/discourse/discourse/blob/071e82140cf375e2875b87d1664ae596c19320dd/plugins/chat/assets/javascripts/discourse/components/chat-composer.gjs#L485 Specifically this occurs when we attempt to create a record in the store service where it already exists, and the logic for fetching the right record there deletes some properties from the input object: https://github.com/discourse/discourse/blob/216df43502e25fcb9a228bfc09f90548712d7f49/app/assets/javascripts/discourse/app/services/store.js#L426-L435 This mutates the object that is eventually used to replace the search term (and therefore causes an error as it is now missing all its properties except for `id`). This also pollutes the upstream cache in the userSearch JS module. This PR fixes both issues by cloning the user objects before they get passed into the processing functions.
1 parent 7514dc8 commit a71eb41

File tree

4 files changed

+125
-3
lines changed

4 files changed

+125
-3
lines changed

app/assets/javascripts/discourse/app/lib/user-search.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { camelCaseToSnakeCase } from "discourse/lib/case-converter";
55
import discourseDebounce from "discourse/lib/debounce";
66
import { isTesting } from "discourse/lib/environment";
77
import discourseLater from "discourse/lib/later";
8+
import { cloneJSON } from "discourse/lib/object";
89
import { userPath } from "discourse/lib/url";
910
import { emailValid } from "discourse/lib/utilities";
1011
import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete";
@@ -41,7 +42,7 @@ function performSearch(
4142
) {
4243
let cached = cache[term];
4344
if (cached) {
44-
resultsFn(cached);
45+
resultsFn(cloneJSON(cached));
4546
return;
4647
}
4748

@@ -105,7 +106,7 @@ function performSearch(
105106
})
106107
.finally(function () {
107108
oldSearch = null;
108-
resultsFn(returnVal);
109+
resultsFn(cloneJSON(returnVal));
109110
});
110111
}
111112

app/assets/javascripts/discourse/tests/unit/lib/user-search-test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,100 @@ module("Unit | Utility | user-search", function (hooks) {
262262
);
263263
});
264264
});
265+
266+
test("it protects cached results from mutation by consumers", async function (assert) {
267+
pretender.get("/u/search/users", (request) => {
268+
if (request.url.includes("term=test_cache")) {
269+
return response({
270+
users: [
271+
{
272+
id: 123,
273+
username: "test_cache_user",
274+
name: "Test Cache User",
275+
avatar_template:
276+
"https://avatars.discourse.org/v3/letter/t/41988e/{size}.png",
277+
},
278+
],
279+
groups: [],
280+
});
281+
}
282+
return response({ users: [], groups: [] });
283+
});
284+
285+
// First userSearch call - this will populate the cache
286+
let firstResults = await userSearch({ term: "test_cache" });
287+
assert.strictEqual(
288+
firstResults.length,
289+
1,
290+
"First call should return 1 result"
291+
);
292+
293+
const userFromFirstCall = firstResults[0];
294+
assert.strictEqual(
295+
userFromFirstCall.username,
296+
"test_cache_user",
297+
"First call should have username"
298+
);
299+
assert.strictEqual(
300+
userFromFirstCall.name,
301+
"Test Cache User",
302+
"First call should have name"
303+
);
304+
assert.present(
305+
userFromFirstCall.avatar_template,
306+
"First call should have avatar_template"
307+
);
308+
309+
const originalKeys = Object.keys(userFromFirstCall);
310+
// Simulate what can happen in consumers of the cache result that mutate that object
311+
Object.keys(userFromFirstCall).forEach((key) => {
312+
if (key === "id") {
313+
return;
314+
}
315+
delete userFromFirstCall[key];
316+
});
317+
318+
// Verify the mutation happened
319+
assert.present(userFromFirstCall.id, "User object should still have id");
320+
assert.strictEqual(
321+
Object.keys(userFromFirstCall).length,
322+
1,
323+
"User object should be mutated to only have id"
324+
);
325+
["username", "name", "avatar_template"].forEach((key) => {
326+
assert.blank(
327+
userFromFirstCall[key],
328+
`User object should not have ${key} after mutation`
329+
);
330+
});
331+
332+
// Second userSearch call - this should return fresh objects from cache that are not mutated
333+
let secondResults = await userSearch({ term: "test_cache" });
334+
assert.strictEqual(
335+
secondResults.length,
336+
1,
337+
"Second call should return 1 result"
338+
);
339+
340+
const userFromSecondCall = secondResults[0];
341+
assert.strictEqual(
342+
userFromSecondCall.username,
343+
"test_cache_user",
344+
"Second call should have username (cache should be protected from mutation)"
345+
);
346+
assert.strictEqual(
347+
userFromSecondCall.name,
348+
"Test Cache User",
349+
"Second call should have name (cache should be protected from mutation)"
350+
);
351+
assert.present(
352+
userFromSecondCall.avatar_template,
353+
"Second call should have avatar_template (cache should be protected from mutation)"
354+
);
355+
assert.strictEqual(
356+
Object.keys(userFromSecondCall).length,
357+
originalKeys.length,
358+
"Second call should have all original properties"
359+
);
360+
});
265361
});

plugins/chat/assets/javascripts/discourse/components/chat-composer.gjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ export default class ChatComposer extends Component {
482482
autoSelectFirstSuggestion: true,
483483
transformComplete: (obj) => {
484484
if (obj.isUser) {
485-
this.#addMentionedUser(obj);
485+
this.#addMentionedUser(cloneJSON(obj));
486486
}
487487

488488
return obj.username || obj.name;

plugins/chat/spec/system/chat_composer_spec.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,31 @@
321321
end
322322
end
323323

324+
context "when using user autocomplete" do
325+
fab!(:autocomplete_user) do
326+
Fabricate(:user, username: "ac_test_user", name: "Autocomplete Test User")
327+
end
328+
329+
before do
330+
SiteSetting.enable_mentions = true
331+
channel_1.add(autocomplete_user)
332+
end
333+
334+
it "fails on subsequent autocomplete selection due to cache pollution" do
335+
chat_page.visit_channel(channel_1)
336+
337+
find(".chat-composer__input").send_keys("@#{autocomplete_user.username}")
338+
find(".autocomplete.ac-user", text: "ac_test_user").click
339+
expect(channel_page.composer).to have_value("@#{autocomplete_user.username} ")
340+
find(".chat-composer__input").set("")
341+
342+
# 2nd autocomplete attempt to ensure user object is not mutated by existing store record behaviour
343+
find(".chat-composer__input").send_keys("@#{autocomplete_user.username}")
344+
find(".autocomplete.ac-user", text: "ac_test_user").click
345+
expect(channel_page.composer).to have_value("@#{autocomplete_user.username} ")
346+
end
347+
end
348+
324349
context "with floatkit autocomplete disabled" do
325350
before { SiteSetting.floatkit_autocomplete_composer = false }
326351

0 commit comments

Comments
 (0)