pdf-quiz-generator/backend/tests/test_access.py
Daniel 994733581e feat: access as one tree — branches, libraries, and an honest "everything"
Access lived in three screens over two tables: category grants in the
question manager, media-library grants in the image bank, and nothing at
all for articles. Nobody could see what one person actually held.

/access is one surface over the same tables. A person on the left,
everything they have on the right. A granted branch shows its children
as covered rather than as separately tickable — a checkbox that changes
nothing is where a permissions screen starts lying — and the count of
categories a grant actually reaches is stated, not implied.

"Everything" is the moderator role, and the page says so instead of
inventing a wildcard grant that would silently mean the same thing and
be impossible to audit. While it is on, the branches below are hidden,
because they no longer apply. Nobody can change their own access.

The gap this closes: an educator granted a branch could edit its
questions but not the articles filed under it — articles were
moderator-or-author only. An article is filed under a category, so a
grant over that branch now covers its reading too. No new table: the
inheritance that category grants already had does the work.

Backend 242/242, frontend 284/284.

Also: the split-view test now focuses the link rather than hovering it.
Hover starts a 350ms timer; focus reveals at once, because the component
does not make a keyboard reader wait. That takes the wall clock out of a
test about the split view. Earlier failures were it losing CPU to the
backend suite running alongside it.

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

143 lines
7.2 KiB
Python

"""Who may edit what, read and written in one place.
Disposable SQLite. The interesting cases are the ones a permissions screen
gets wrong: a grant that silently means everything, a checkbox that changes
nothing because a parent already covers it, and an article an educator can
see but not edit in a branch they were given.
"""
import sys
import unittest
from types import ModuleType
from unittest.mock import patch
import test_quiz_builder as fixtures
from app.models.article import Article
from app.models.category_grant import CategoryGrant
from app.models.media import MediaLibrary, MediaLibraryGrant
from app.routers import access
from app.utils.category_grants import can_edit_article, granted_category_scope
with patch.dict(sys.modules, {"app.services.quiz_service": ModuleType("app.services.quiz_service")}):
pass
class AccessTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.client.app.include_router(access.router, prefix="/access")
self.db.add(MediaLibrary(id=1, name="Rashes"))
self.db.add(MediaLibrary(id=2, name="ECGs"))
# Categories from the fixture: 1 Root > 2 Child > 3 Leaf, and 4 Empty.
self.db.add(Article(id=50, slug="root-reading", title="Root reading", sections=[],
category_id=2, user_id=3, status="published"))
self.db.add(Article(id=51, slug="elsewhere", title="Elsewhere", sections=[],
category_id=4, user_id=3, status="published"))
self.db.commit()
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def peer(self):
return self.bank.peer
# ── reading ───────────────────────────────────────────────────────────────
def test_the_overview_says_what_each_person_actually_reaches(self):
self.db.add(CategoryGrant(category_id=1, user_id=self.peer().id))
self.db.add(MediaLibraryGrant(library_id=1, user_id=self.peer().id))
self.db.commit()
rows = {row["id"]: row for row in self.client.get("/access/").json()["users"]}
row = rows[self.peer().id]
self.assertEqual(row["categories"], [1])
self.assertEqual(row["libraries"], [1])
# Root, Child and Leaf — the branch, not just the row that was ticked.
self.assertEqual(row["categories_covered"], 3)
self.assertFalse(row["everything"])
self.assertTrue(rows[self.bank.mod.id]["everything"])
def test_the_tree_carries_what_is_in_each_branch(self):
tree = self.client.get("/access/tree").json()
by_id = {c["id"]: c for c in tree["categories"]}
self.assertEqual(by_id[2]["articles"], 1)
self.assertEqual({lib["name"] for lib in tree["libraries"]}, {"Rashes", "ECGs"})
def test_only_a_moderator_may_look(self):
self.bank.user = self.peer()
self.assertEqual(self.client.get("/access/").status_code, 403)
self.assertEqual(self.client.get("/access/tree").status_code, 403)
# ── writing ───────────────────────────────────────────────────────────────
def test_granting_a_branch_and_taking_it_back(self):
uid = self.peer().id
response = self.client.post(f"/access/{uid}/grants", json={"kind": "category", "target_id": 1})
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(self.db.query(CategoryGrant).filter_by(user_id=uid).count(), 1)
# Twice is refused rather than duplicated.
self.assertEqual(self.client.post(f"/access/{uid}/grants",
json={"kind": "category", "target_id": 1}).status_code, 409)
self.assertEqual(self.client.delete(f"/access/{uid}/grants/category/1").status_code, 204)
self.assertEqual(self.db.query(CategoryGrant).filter_by(user_id=uid).count(), 0)
def test_a_grant_to_someone_who_already_has_everything_is_refused(self):
response = self.client.post(f"/access/{self.bank.mod.id}/grants",
json={"kind": "category", "target_id": 1})
self.assertEqual(response.status_code, 400)
self.assertIn("already has everything", response.json()["detail"])
def test_everything_is_a_role_change_and_is_named_as_one(self):
uid = self.peer().id
response = self.client.put(f"/access/{uid}/role", json={"role": "moderator"})
self.assertEqual(response.status_code, 200, response.text)
self.db.expire_all()
self.assertEqual(self.db.get(type(self.peer()), uid).role, "moderator")
self.assertTrue({r["id"]: r for r in self.client.get("/access/").json()["users"]}[uid]["everything"])
def test_you_cannot_change_your_own_access(self):
self.assertEqual(
self.client.put(f"/access/{self.bank.mod.id}/role", json={"role": "user"}).status_code, 400)
def test_an_unknown_kind_is_refused_rather_than_ignored(self):
uid = self.peer().id
self.assertEqual(self.client.post(f"/access/{uid}/grants",
json={"kind": "everything", "target_id": 1}).status_code, 400)
self.assertEqual(self.client.delete(f"/access/{uid}/grants/everything/1").status_code, 400)
# ── what a grant actually buys ────────────────────────────────────────────
def test_a_branch_covers_the_articles_filed_under_it(self):
peer = self.peer()
root = self.db.get(Article, 50) # category 2, inside the branch
far = self.db.get(Article, 51) # category 4, outside it
self.assertFalse(can_edit_article(self.db, peer, root))
self.db.add(CategoryGrant(category_id=1, user_id=peer.id))
self.db.commit()
self.assertEqual(granted_category_scope(self.db, peer), {1, 2, 3})
# The reading in the branch, but not the reading outside it.
self.assertTrue(can_edit_article(self.db, peer, root))
self.assertFalse(can_edit_article(self.db, peer, far))
def test_an_author_keeps_their_own_article_and_a_moderator_has_all_of_them(self):
own = Article(id=52, slug="mine", title="Mine", sections=[], category_id=4,
user_id=self.peer().id, status="draft")
self.db.add(own)
self.db.commit()
self.assertTrue(can_edit_article(self.db, self.peer(), own))
self.assertTrue(can_edit_article(self.db, self.bank.mod, self.db.get(Article, 51)))
def test_an_article_filed_nowhere_is_not_reachable_by_any_branch(self):
loose = Article(id=53, slug="loose", title="Loose", sections=[], category_id=None,
user_id=3, status="published")
self.db.add(loose)
self.db.add(CategoryGrant(category_id=1, user_id=self.peer().id))
self.db.commit()
self.assertFalse(can_edit_article(self.db, self.peer(), loose))
if __name__ == "__main__":
unittest.main()