Skip to content

Commit 3f8a8cc

Browse files
test(quiz): cross-user node ownership returns 404 (IDOR regression)
Add TestQuizNodeOwnership: user A generating or submitting a quiz against user B's concept node must 404, and submit must write nothing to graph_nodes. The factory models real DB filtering so the tests fail on unscoped reads and pass only once every node read is user-scoped.
1 parent d1562c8 commit 3f8a8cc

1 file changed

Lines changed: 125 additions & 0 deletions

File tree

backend/tests/test_quiz_routes.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,131 @@ def factory(name):
240240
assert r.json()["score"] == 2
241241

242242

243+
# ── Cross-user node ownership (IDOR regression, issue #157) ─────────────────
244+
245+
246+
class TestQuizNodeOwnership:
247+
"""User A must not be able to generate or submit a quiz against a
248+
concept node owned by user B. Both paths must 404 (not 200) so an
249+
attacker can't read a victim's concept content nor corrupt the
250+
victim's mastery fields.
251+
252+
The graph_nodes table is owner-scoped: a SELECT scoped to a user_id
253+
that doesn't own the node returns no rows. The factory below models
254+
real DB behaviour — an *unscoped* read (the bug) still leaks B's node,
255+
so these tests fail on unscoped code and pass only once the route
256+
filters every node read by the caller's user_id.
257+
"""
258+
259+
NODE_OWNER = "user_beatriz" # victim B owns the node
260+
ATTACKER = "user_andres" # attacker A
261+
262+
def _node_row(self) -> dict:
263+
return {
264+
"id": "node_b",
265+
"user_id": self.NODE_OWNER,
266+
"course_id": "course1",
267+
"concept_name": "Beatriz Secret Concept",
268+
"mastery_score": 0.5,
269+
"times_studied": 3,
270+
"mastery_events": [],
271+
}
272+
273+
def _ownership_aware_graph_select(self, columns="*", filters=None, **_):
274+
"""Faithfully model DB row-filtering for the victim's node.
275+
276+
The row comes back when the query matches by id and EITHER no
277+
user_id scope is applied (the *buggy* unscoped read, which leaks
278+
B's node to anyone) OR the scope names the owner. A scope naming
279+
a non-owner (the attacker) returns nothing — so unscoped code
280+
leaks/writes (test fails) and only owner-scoped reads 404 for A.
281+
"""
282+
filters = filters or {}
283+
if filters.get("id") != "eq.node_b":
284+
return []
285+
user_filter = filters.get("user_id")
286+
if user_filter is None or user_filter == f"eq.{self.NODE_OWNER}":
287+
return [self._node_row()]
288+
return []
289+
290+
def test_generate_against_foreign_node_returns_404(self):
291+
"""A POSTs /generate with B's concept_node_id but A's user_id.
292+
The owner-scoped node read misses → 404, before any quiz_attempts
293+
row is created and before the agent ever runs."""
294+
def factory(name):
295+
mock = MagicMock()
296+
if name == "graph_nodes":
297+
mock.select.side_effect = self._ownership_aware_graph_select
298+
else:
299+
mock.select.return_value = []
300+
mock.insert.return_value = []
301+
return mock
302+
303+
agent_run = AsyncMock()
304+
with (
305+
patch("routes.quiz.table", side_effect=factory),
306+
patch("routes.quiz.quiz_agent.run", new=agent_run),
307+
):
308+
r = client.post("/api/quiz/generate", json={
309+
"user_id": self.ATTACKER,
310+
"concept_node_id": "node_b",
311+
"num_questions": 1,
312+
"difficulty": "easy",
313+
"use_shared_context": False,
314+
})
315+
316+
assert r.status_code == 404
317+
# The agent must never run for a foreign node — no content leak.
318+
agent_run.assert_not_called()
319+
320+
def test_submit_against_foreign_node_returns_404_and_writes_nothing(self):
321+
"""A owns the quiz_attempts row (so require_self passes), but the
322+
attempt's concept_node_id points at B's node. The owner-scoped
323+
node read (using the attempt's user_id == A) misses → 404, and no
324+
graph_nodes.update fires, so B's mastery stays intact."""
325+
update_calls: list = []
326+
327+
def factory(name):
328+
mock = MagicMock()
329+
if name == "quiz_attempts":
330+
mock.select.return_value = [{
331+
"id": "quiz_a",
332+
"user_id": self.ATTACKER, # attempt belongs to A
333+
"concept_node_id": "node_b", # but targets B's node
334+
"difficulty": "medium",
335+
"questions_json": SAMPLE_QUESTIONS,
336+
}]
337+
mock.update.return_value = []
338+
elif name == "graph_nodes":
339+
mock.select.side_effect = self._ownership_aware_graph_select
340+
mock.update.side_effect = lambda *a, **k: update_calls.append((a, k)) or []
341+
else:
342+
mock.select.return_value = []
343+
mock.update.return_value = []
344+
return mock
345+
346+
with (
347+
patch("routes.quiz.table", side_effect=factory),
348+
patch("routes.quiz.update_streak"),
349+
patch("routes.quiz.get_quiz_context", return_value={}),
350+
patch("routes.quiz.call_gemini_json", return_value={}),
351+
):
352+
r = client.post("/api/quiz/submit", json={
353+
"quiz_id": "quiz_a",
354+
"answers": [
355+
{"question_id": 1, "selected_label": "A"},
356+
{"question_id": 2, "selected_label": "D"},
357+
],
358+
})
359+
360+
assert r.status_code == 404
361+
# No mastery write to the victim's node (or any node) occurred.
362+
assert update_calls == [], (
363+
"submit_quiz wrote to graph_nodes for a foreign concept node — "
364+
"IDOR regression (issue #157)."
365+
)
366+
367+
243368
# ── POST /api/quiz/generate ──────────────────────────────────────────────────
244369

245370

0 commit comments

Comments
 (0)