@@ -41,12 +41,62 @@ def _run(coro):
4141# ── search_course_materials ───────────────────────────────────────────────
4242
4343
44+ class TestSearchCourseMaterialsUserScope :
45+ """#125: documents are user-scoped within a shared course. The query must
46+ filter on user_id, or another enrolled student's private summary/concept
47+ notes get decrypted into this user's LLM context."""
48+
49+ def test_does_not_return_other_users_documents (self ):
50+ doc_mine = {
51+ "id" : "doc_mine" ,
52+ "file_name" : "my_notes.pdf" ,
53+ "summary" : "my private summary about recursion" ,
54+ "concept_notes" : [],
55+ }
56+ doc_other = {
57+ "id" : "doc_other" ,
58+ "file_name" : "their_notes.pdf" ,
59+ "summary" : "another student's private summary about recursion" ,
60+ "concept_notes" : [],
61+ }
62+
63+ def _row_scoped_select (* _args , ** kwargs ):
64+ # Faithful row-scoped store: both docs sit in the same course, owned
65+ # by different users. A query scoped to me returns only mine; an
66+ # UNSCOPED query (pre-fix — no user_id filter) leaks the whole class.
67+ f = kwargs .get ("filters" , {})
68+ uid = f .get ("user_id" )
69+ if uid == "eq.user_mine" :
70+ return [doc_mine ]
71+ if "user_id" not in f :
72+ return [doc_mine , doc_other ]
73+ return []
74+
75+ with patch ("agents.tools.chat_context.table" ) as t , patch (
76+ "agents.tools.chat_context.decrypt_if_present" , side_effect = lambda v : v
77+ ):
78+ t .return_value .select .side_effect = _row_scoped_select
79+ result = _run (
80+ search_course_materials ("course_cs101" , "recursion" , user_id = "user_mine" )
81+ )
82+
83+ ids = {m .document_id for m in result }
84+ assert "doc_mine" in ids
85+ # Pre-fix this leaked the other student's doc into the result (and thus
86+ # the LLM context); the user_id scope keeps it out.
87+ assert "doc_other" not in ids
88+ # And the mechanism that keeps it out: the DB query is scoped to the
89+ # caller's user_id, not course_id alone.
90+ filters = t .return_value .select .call_args .kwargs ["filters" ]
91+ assert filters .get ("user_id" ) == "eq.user_mine"
92+
93+
4494class TestSearchCourseMaterials :
4595 def test_returns_empty_when_course_id_is_none (self ):
4696 # No table call should happen at all — cross-course search is a
4797 # data-leak risk we explicitly avoid.
4898 with patch ("agents.tools.chat_context.table" ) as t :
49- result = _run (search_course_materials (None , "recursion" ))
99+ result = _run (search_course_materials (None , "recursion" , user_id = "user_andres" ))
50100
51101 assert result == []
52102 t .assert_not_called ()
@@ -82,7 +132,9 @@ def test_scores_by_keyword_overlap_and_caps_at_limit(self):
82132 ):
83133 t .return_value .select .return_value = rows
84134 result = _run (
85- search_course_materials ("course_cs101" , "recursion base case" , limit = 2 )
135+ search_course_materials (
136+ "course_cs101" , "recursion base case" , limit = 2 , user_id = "user_andres"
137+ )
86138 )
87139
88140 assert len (result ) == 2
@@ -113,7 +165,9 @@ def test_drops_empty_entries(self):
113165 "agents.tools.chat_context.decrypt_if_present" , side_effect = lambda v : v
114166 ):
115167 t .return_value .select .return_value = rows
116- result = _run (search_course_materials ("course_cs101" , "pointer" ))
168+ result = _run (
169+ search_course_materials ("course_cs101" , "pointer" , user_id = "user_andres" )
170+ )
117171
118172 assert [m .document_id for m in result ] == ["doc_good" ]
119173
@@ -145,7 +199,9 @@ def fake_decrypt_json(value):
145199 "agents.tools.chat_context.decrypt_json" , side_effect = fake_decrypt_json
146200 ) as dec_json :
147201 t .return_value .select .return_value = rows
148- result = _run (search_course_materials ("course_cs101" , "foo" ))
202+ result = _run (
203+ search_course_materials ("course_cs101" , "foo" , user_id = "user_andres" )
204+ )
149205
150206 # Both decrypt helpers were invoked at the boundary.
151207 assert dec_str .called , "decrypt_if_present must run on summary"
@@ -309,8 +365,10 @@ def test_search_tool_passes_course_id_from_deps(self):
309365 inner .return_value = []
310366 _run (search_course_materials_tool (self ._ctx (), "recursion" , limit = 3 ))
311367
312- # course_id pulled from deps, not from the LLM.
313- inner .assert_awaited_once_with ("course_cs101" , "recursion" , 3 )
368+ # course_id AND user_id pulled from deps, not from the LLM (#125).
369+ inner .assert_awaited_once_with (
370+ "course_cs101" , "recursion" , 3 , user_id = "user_andres"
371+ )
314372
315373 def test_history_tool_passes_session_id_from_deps (self ):
316374 with patch (
0 commit comments