pdf-quiz-generator/backend/tests/test_question_detail_access.py
Daniel cdf4ab80b4
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 34s
Tests / e2e (push) Has been cancelled
fix: the figures route handed out the answer side to anybody signed in
GET /questions/detail/{id}/figures had no check at all, sitting next to
a detail route that has one. Any signed-in account could ask for any
question's figures by id and be handed its explanation images: the
paths, and the library record that now rides on them — whose titles run
to "Neonatal Herpes Simplex · Q874". No attempt required, and the answer
in the title. Found by walking today's surfaces as a real learner
account rather than reading the guards.

Same rule as the route beside it: the stem is readable in the bank, the
answer side belongs to whoever writes the question.

Also, refining an article no longer breaks the links into it. The refine
path replaces the whole section list and the model was never shown the
existing ids, so it invented fresh ones — silently breaking every
`[[95#id]]` pointing at a section, from another article, a question's
key point or a study plan's reading. The id travels in the heading now
and the prompt says to return it unchanged for any section kept. The
model is also told to leave existing `[[123|links]]` exactly as written
and never to invent one, because a guessed number points at nothing.

And the section strip is centred. Widening its box to 1600px let the
links spread but `flex: 1` on the strip — right for every other strip on
the site — filled the whole box with the links against its left edge:
measured at 1500px, they began 70px left of the page content while the
strip ran 180px past its right. Content-sized and centred now, still
scrolling when the links genuinely outrun the window. Diagnosis from the
ped-ai session; verified at 1500 and 1920 with no arrows at either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:29:29 +02:00

93 lines
4 KiB
Python

"""The answer side of a question, and who is handed it.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
"""
import unittest
import test_quiz_builder as fixtures
class QuestionDetailAccessTests(unittest.TestCase):
"""Being able to reach a question is not the same as having earned its answer.
The bank listing has always nulled the answer side for anybody who does not
write the question. /questions/detail/{id} did not: anybody signed in could
ask for a question by id and be handed the correct option, the explanation,
the per-option reasoning and the key points for a question they had never
sat. Same rule, both routes.
"""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
def tearDown(self):
self.bank.tearDown()
def detail(self, question_id=4):
response = self.client.get(f"/questions/detail/{question_id}")
self.assertEqual(response.status_code, 200, response.text)
return response.json()
def test_a_reader_gets_the_stem_and_none_of_the_answer(self):
# Question 4 belongs to the peer; the owner may read it in the bank and
# has no business writing it.
self.bank.user = self.bank.owner
body = self.detail(4)
self.assertEqual(body["question_text"], "Question 4")
self.assertEqual(body["options"], ["yes", "no"])
self.assertEqual(body["image_path"], "q.png")
for field in ("correct_answer", "explanation", "option_explanations",
"key_points", "attending_tip", "explanation_image_path"):
self.assertIsNone(body[field], field)
# And no explanation figure, which is answer-side by definition.
self.assertTrue(all(f["role"] == "stem" for f in body["figures"]), body["figures"])
def test_whoever_writes_it_gets_all_of_it(self):
self.bank.user = self.bank.mod
body = self.detail(4)
self.assertEqual(body["correct_answer"], "yes")
self.assertEqual(body["explanation"], "Full explanation")
self.assertEqual(body["explanation_image_path"], "answer.png")
def test_the_figures_route_withholds_the_answer_side_too(self):
"""It had no check at all, next to a route that has one.
Any signed-in account could ask for any question's figures by id and
be handed its explanation images — the paths, and the library record
that rides on them, whose titles run to "Neonatal Herpes Simplex ·
Q874". No attempt, and the answer in the title.
"""
from app.models.media import MediaAsset
from app.models.question_media import QuestionMedia
self.bank.db.add(MediaAsset(id=41, path="stem.png", title="A stem picture"))
self.bank.db.add(MediaAsset(id=42, path="answer.png", title="The answer, named"))
self.bank.db.flush()
self.bank.db.add(QuestionMedia(question_id=4, media_id=41, role="stem", position=0))
self.bank.db.add(QuestionMedia(question_id=4, media_id=42, role="explanation", position=0))
self.bank.db.commit()
self.bank.user = self.bank.owner
rows = self.client.get("/questions/detail/4/figures").json()
self.assertEqual([row["role"] for row in rows], ["stem"])
self.assertNotIn("answer.png", str(rows))
self.bank.user = self.bank.mod
roles = sorted(row["role"] for row in self.client.get("/questions/detail/4/figures").json())
self.assertEqual(roles, ["explanation", "stem"])
def test_writing_a_question_does_not_make_it_yours(self):
"""Authorship is not a claim the bank honours.
Question 3 carries the owner's id, from before the bank stopped
belonging to people. Reading its answer takes the admin role, a grant,
or an attempt — the same as anybody else's.
"""
self.bank.user = self.bank.owner
self.assertIsNone(self.detail(3)["correct_answer"])
if __name__ == "__main__":
unittest.main()