pdf-quiz-generator/backend/tests/test_category_grants.py
Daniel c22e7f9547 fix: the answer side of a question needs an attempt, or the job of writing it
Asked for: no tutor on a question outside a session. The tutor is handed the
correct answer and told it may explain it, so it is answer-side content — and
once that rule is written down, the same rule catches two bigger holes:

- `GET /questions/bank` returned `correct_answer`, `explanation`,
  `option_explanations`, `key_points` and `attending_tip` for every question in
  the bank, to any signed-in learner. It is the question manager's listing, but
  nothing stopped anyone calling it: the whole answer key, one request away
  from the questions it answers. Stems are still listed to everyone; the answer
  side now goes only to whoever writes that question.
- The explanation image behind a question was readable by the same rule, with
  no attempt behind it.

"Whoever writes it" is one function now — `may_edit_question` — and it means
moderation, authorship, or an editorial grant that reaches where the question
is filed. Everyone else earns the answer by sitting the question, which is what
an attempt is. The bank browse, the search, the session and the review are all
unchanged; the frontend already sends `attempt_id` everywhere it shows an
answer.

The question manager was reachable by a learner with no grant, and would now
load as a bank of stems with every answer field blanked — a broken page rather
than a door that is not theirs. It says so instead.

Also, while looking at where cards surface: the answer review showed neither
the topic reading nor the cards written against a question, though the player
has shown both under the answer for a while — and the review is the one place
a learner goes through everything they got wrong. The list form of that
component fetched its cards and then dropped them on the floor.

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

223 lines
12 KiB
Python

"""Per-category editorial grants: a non-moderator educator edits only their categories.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
No application startup, external services or AI calls; a disposable SQLite database per test.
"""
import os
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
import unittest
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.category_grant import CategoryGrant
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.user import User
from app.routers import question_categories, questions
from app.utils.auth import get_current_user
class CategoryGrantTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
self.educator = User(id=2, name="Educator", email="edu@example.test", hashed_password="unused")
self.outsider = User(id=3, name="Outsider", email="out@example.test", hashed_password="unused")
self.db.add_all([self.mod, self.educator, self.outsider])
# Cardiology > Kawasaki disease · Neurology stands alone.
self.db.add_all([
QuestionCategory(id=1, name="Cardiology", user_id=1),
QuestionCategory(id=2, name="Kawasaki disease", parent_id=1, user_id=1),
QuestionCategory(id=10, name="Neurology", user_id=1),
])
self.db.flush()
for qid, category in [(1, 1), (2, 2), (3, 10), (4, None)]:
self.db.add(Question(id=qid, question_category_id=category, user_id=1,
question_text=f"Question {qid}", question_type="mcq",
options=["yes", "no"], correct_answer="yes", explanation="Because"))
self.db.commit()
self.user = self.mod
app = FastAPI()
app.include_router(questions.router, prefix="/questions")
app.include_router(question_categories.router, prefix="/question-categories")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
self.client.close()
self.db.close()
self.engine.dispose()
def grant(self, category_id=1, user_id=2):
self.db.add(CategoryGrant(category_id=category_id, user_id=user_id, granted_by=1))
self.db.commit()
# ── Granting ───────────────────────────────────────────────────
def test_moderator_grants_and_revokes_a_category(self):
response = self.client.post("/question-categories/1/grants", json={"user_id": 2})
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(response.json()["category_name"], "Cardiology")
self.assertEqual(response.json()["user_email"], "edu@example.test")
listed = self.client.get("/question-categories/grants").json()
self.assertEqual([(g["category_id"], g["user_id"]) for g in listed], [(1, 2)])
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 409)
self.assertEqual(self.client.delete("/question-categories/1/grants/2").status_code, 204)
self.assertEqual(self.client.get("/question-categories/grants").json(), [])
def test_grants_are_moderator_only_and_validate_their_target(self):
self.assertEqual(self.client.post("/question-categories/999/grants", json={"user_id": 2}).status_code, 404)
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 999}).status_code, 404)
# A moderator already manages everything; a grant would be meaningless.
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 1}).status_code, 400)
self.user = self.educator
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 403)
self.assertEqual(self.client.get("/question-categories/grants").status_code, 403)
def test_my_grants_reports_the_scope_including_descendants(self):
self.user = self.educator
self.assertEqual(self.client.get("/question-categories/my-grants").json(),
{"is_moderator": False, "can_manage_questions": False, "categories": []})
self.grant(category_id=1)
scope = self.client.get("/question-categories/my-grants").json()
self.assertTrue(scope["can_manage_questions"])
self.assertEqual([(c["name"], c["direct"]) for c in scope["categories"]],
[("Cardiology", True), ("Kawasaki disease", False)])
self.user = self.mod
self.assertTrue(self.client.get("/question-categories/my-grants").json()["is_moderator"])
# ── Using a grant ──────────────────────────────────────────────
def test_educator_edits_inside_the_grant_and_its_descendants_only(self):
self.grant(category_id=1)
self.user = self.educator
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200)
# Question 2 sits in the child category, still inside the grant.
self.assertEqual(self.client.patch("/questions/2", json={"difficulty": "hard"}).status_code, 200)
# Neurology and the uncategorised question are out of scope.
self.assertEqual(self.client.patch("/questions/3", json={"explanation": "No"}).status_code, 403)
self.assertEqual(self.client.patch("/questions/4", json={"explanation": "No"}).status_code, 403)
self.assertEqual(self.db.get(Question, 3).explanation, "Because")
def test_educator_cannot_move_a_question_out_of_their_scope(self):
self.grant(category_id=1)
self.user = self.educator
self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 10}).status_code, 403)
self.assertEqual(self.client.patch("/questions/1", json={"additional_category_ids": [10]}).status_code, 403)
self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 2}).status_code, 200)
self.assertEqual(self.db.get(Question, 1).question_category_id, 2)
def test_educator_creates_only_inside_their_scope(self):
self.grant(category_id=1)
self.user = self.educator
body = {"question_text": "New question", "question_type": "mcq",
"options": ["yes", "no"], "correct_answer": "yes"}
with patch("app.services.embedding_service.embed_question", return_value=None):
allowed = self.client.post("/questions/create", json={**body, "question_category_id": 2})
self.assertEqual(allowed.status_code, 200, allowed.text)
self.assertEqual(self.client.post("/questions/create",
json={**body, "question_category_id": 10}).status_code, 403)
# Uncategorised creation is out of scope too — it would land outside the grant.
self.assertEqual(self.client.post("/questions/create", json=body).status_code, 403)
def test_bulk_actions_are_confined_to_the_grant(self):
self.grant(category_id=1)
self.user = self.educator
mixed = self.client.post("/questions/bulk",
json={"question_ids": [1, 3], "action": "difficulty", "difficulty": "easy"})
self.assertEqual(mixed.status_code, 403)
self.assertIsNone(self.db.get(Question, 1).difficulty) # nothing applied
ok = self.client.post("/questions/bulk",
json={"question_ids": [1, 2], "action": "difficulty", "difficulty": "easy"})
self.assertEqual(ok.status_code, 200, ok.text)
self.assertEqual(self.db.get(Question, 1).difficulty, "easy")
# Reassigning into an ungranted category is refused.
self.assertEqual(self.client.post("/questions/bulk",
json={"question_ids": [1], "action": "category",
"category_id": 10}).status_code, 403)
def test_additional_category_links_bring_a_question_into_scope(self):
self.grant(category_id=10)
self.db.add(QuestionCategoryLink(question_id=1, category_id=10))
self.db.commit()
self.user = self.educator
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200)
def test_educator_deletes_only_inside_the_grant(self):
self.grant(category_id=1)
self.user = self.educator
self.assertEqual(self.client.delete("/questions/3").status_code, 403)
self.assertEqual(self.client.delete("/questions/1").status_code, 204)
# Deleting hides rather than erases — the row and its id have to
# survive or nothing that points at them can be put back.
self.db.expire_all()
self.assertIsNotNone(self.db.get(Question, 1).deleted_at)
def test_summary_is_scoped_to_the_grant(self):
self.grant(category_id=1)
self.user = self.educator
summary = self.client.get("/questions/manage/summary").json()
self.assertTrue(summary["scoped"])
self.assertEqual(summary["total"], 2) # questions 1 and 2 only
moderator_summary = None
self.user = self.mod
moderator_summary = self.client.get("/questions/manage/summary").json()
self.assertFalse(moderator_summary["scoped"])
self.assertEqual(moderator_summary["total"], 4)
def test_the_bank_listing_hands_the_answer_only_to_whoever_writes_it(self):
"""Any signed-in learner could read the whole answer key from /bank.
The listing is the question manager's, but nothing stopped a learner
calling it, and it returned the correct option, the explanation, the
option explanations and the attending tip for every question in the
bank. The stem is bank content; the answer beside it is not.
"""
self.user = self.outsider
rows = self.client.get("/questions/bank").json()["questions"]
self.assertEqual(len(rows), 4) # every stem is still listed
for row in rows:
self.assertTrue(row["question_text"])
self.assertIsNone(row["correct_answer"])
self.assertIsNone(row["explanation"])
self.assertIsNone(row["attending_tip"])
self.assertIsNone(row["explanation_image_path"])
# An educator sees the answer where their grant reaches, and only there.
self.grant(category_id=1, user_id=3)
self.user = self.outsider
answers = {row["id"]: row["correct_answer"]
for row in self.client.get("/questions/bank").json()["questions"]}
self.assertEqual(answers[1], "yes") # Cardiology
self.assertEqual(answers[2], "yes") # a descendant of it
self.assertIsNone(answers[3]) # Neurology
self.assertIsNone(answers[4]) # filed nowhere
self.user = self.mod
self.assertTrue(all(row["correct_answer"] == "yes"
for row in self.client.get("/questions/bank").json()["questions"]))
def test_ungranted_user_is_locked_out_of_question_management(self):
self.user = self.outsider
self.assertEqual(self.client.get("/questions/manage/summary").status_code, 403)
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "No"}).status_code, 403)
self.assertEqual(self.client.post("/questions/bulk",
json={"question_ids": [1], "action": "delete"}).status_code, 403)
if __name__ == "__main__":
unittest.main()