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
223 lines
12 KiB
Python
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()
|