pdf-quiz-generator/backend/tests/test_access.py
Daniel 12453ba473
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 34s
fix: a role is not set here while the provider holds it
Two endpoints still set a role locally — PUT /access/{id}/role and PUT
/admin/users/{id}/role. With OIDC_ROLE_CLAIM configured, my sync applies
the person's groups on every sign-in, so a role set in the app lasts
until they next sign in and is then overwritten. That is the worst kind
of setting: it appears to work, holds for an afternoon, and quietly
undoes itself.

Both now answer 409 naming where the switch actually is. With no mapping
configured they behave exactly as before, which is what a site with no
provider needs.

The two existing tests now patch is_configured explicitly rather than
inheriting whatever the container's .env happens to say — a test that
reads differently on two machines is not a test.

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

172 lines
8.8 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):
# With no provider mapping configured, the role is set here. The
# patch is explicit because the container's own .env may have
# OIDC_ROLE_CLAIM set, and a test must not read differently on two
# machines.
uid = self.peer().id
with patch("app.services.sso_roles.is_configured", return_value=False):
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):
with patch("app.services.sso_roles.is_configured", return_value=False):
self.assertEqual(
self.client.put(f"/access/{self.bank.mod.id}/role", json={"role": "user"}).status_code, 400)
def test_a_role_is_not_set_here_when_the_provider_holds_it(self):
"""A local change would be overwritten at their next sign-in.
Which is the worst kind of setting: it appears to work, holds for an
afternoon, and quietly undoes itself. The endpoint says where the
switch actually is instead.
"""
with patch("app.services.sso_roles.is_configured", return_value=True):
response = self.client.put(f"/access/{self.peer().id}/role", json={"role": "moderator"})
self.assertEqual(response.status_code, 409)
self.assertIn("identity provider", response.json()["detail"])
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_writing_an_article_does_not_make_it_yours(self):
"""Authorship was a way in. It is not one any more.
An article belongs to the library, and who may change it is the admin
role or a grant over the branch it is filed under — not whoever
happened to type it. The address that wrote most of this library is not
even the working administrator any more, which is the whole argument.
"""
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.assertFalse(can_edit_article(self.db, self.peer(), own))
# A grant over where it is filed is a way in, and so is moderation.
self.db.add(CategoryGrant(category_id=4, user_id=self.peer().id))
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()