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
172 lines
8.8 KiB
Python
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()
|