Files
LLeMbas/tests/test_sharing.py
T
Jaroslav Beneš 2f978d84d1 Add nullable columns without a default
Deploying knowledge bases showed the migration runner doing the wrong thing:

    ALTER TABLE "documents" ADD COLUMN "base_id" VARCHAR(32) DEFAULT ''

`base_id` is nullable and its absent value is NULL, but the runner derived a
default from the column type and backfilled every existing row with the empty
string. Nothing then matched `base_id IS NULL`, so the startup sweep that files
pre-bases documents into a default base would have skipped all of them and the
documents would have stayed invisible.

Nobody lost anything -- the live instance had no documents yet -- but the fault
is general: any nullable column added from here would arrive as "" rather than
NULL, and every "is this set?" check would be wrong about the rows that predate
it. So a default is now emitted only for NOT NULL columns, where SQLite requires
one.

The sweep also accepts "" as meaning unfiled, since a deployment that upgraded
through the previous release has rows holding it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:03:01 +02:00

305 lines
12 KiB
Python

"""Who can see a document, a note or a skill.
The most consequential tests in the library: everything else is a feature not
working, this is somebody reading somebody else's material.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from lembas.db.models import (
PRINCIPAL_GROUP,
PRINCIPAL_USER,
Group,
Note,
Share,
User,
)
from lembas.security.passwords import hash_password
from lembas.services import sharing
from lembas.services.library import documents as documents_service
from lembas.services.library import notes as notes_service
@pytest.fixture
def people(db):
"""Three accounts: an owner, a stranger, and an administrator."""
made = {}
for name, role in (("frodo", "user"), ("gollum", "user"), ("gandalf", "admin")):
user = User(
name=name, email=f"{name}@shire.test", password_hash=hash_password("x"), role=role
)
db.add(user)
made[name] = user
db.commit()
return made
def _note(db, owner, title="Secret"):
return notes_service.create(db, owner=owner, title=title, body="The ring is in the drawer.")
# --- The rule ----------------------------------------------------------------
def test_the_owner_sees_their_own(db, people):
note = _note(db, people["frodo"])
assert sharing.can_read(db, note, people["frodo"])
assert note in db.scalars(notes_service.visible(db, people["frodo"]))
def test_a_stranger_sees_nothing(db, people):
note = _note(db, people["frodo"])
assert not sharing.can_read(db, note, people["gollum"])
assert note not in db.scalars(notes_service.visible(db, people["gollum"]))
def test_an_administrator_gets_no_free_pass(db, people):
"""Admins bypass permissions elsewhere, deliberately -- an admin can grant
themselves those in two clicks. This is different: nobody made this
available to anyone, and administering a box is not being invited."""
note = _note(db, people["frodo"])
assert not sharing.can_read(db, note, people["gandalf"])
assert note not in db.scalars(notes_service.visible(db, people["gandalf"]))
def test_sharing_with_a_person_lets_them_read(db, people):
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[people["gollum"].id], group_ids=[])
assert sharing.can_read(db, note, people["gollum"])
assert note in db.scalars(notes_service.visible(db, people["gollum"]))
def test_sharing_with_a_group_lets_its_members_read(db, people):
group = Group(name="Fellowship")
group.users.append(people["gollum"])
db.add(group)
db.commit()
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[], group_ids=[group.id])
assert sharing.can_read(db, note, people["gollum"])
def test_leaving_a_group_takes_the_access_with_it(db, people):
group = Group(name="Fellowship")
group.users.append(people["gollum"])
db.add(group)
db.commit()
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[], group_ids=[group.id])
group.users.remove(people["gollum"])
db.commit()
db.refresh(people["gollum"])
assert not sharing.can_read(db, note, people["gollum"])
def test_signed_out_sees_nothing(db, people):
_note(db, people["frodo"])
assert list(db.scalars(notes_service.visible(db, None))) == []
# --- Sharing grants reading only ---------------------------------------------
def test_a_share_does_not_grant_writing(db, people):
"""Two people editing one note with no history and no merge is worse than
the inconvenience of copying it."""
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[people["gollum"].id], group_ids=[])
assert sharing.can_read(db, note, people["gollum"])
assert not sharing.can_write(note, people["gollum"])
assert sharing.can_write(note, people["frodo"])
# --- Managing grants ---------------------------------------------------------
def test_set_grants_replaces_rather_than_adds(db, people):
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[people["gollum"].id], group_ids=[])
sharing.set_grants(db, note, user_ids=[people["gandalf"].id], group_ids=[])
assert not sharing.can_read(db, note, people["gollum"])
assert sharing.can_read(db, note, people["gandalf"])
def test_sharing_with_yourself_is_ignored(db, people):
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[people["frodo"].id], group_ids=[])
assert sharing.grants_for(db, note) == []
def test_deleting_a_note_drops_its_shares(db, people):
"""Shares carry no foreign key to their resource -- one column pointing at
three tables cannot have one -- so nothing cascades."""
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[people["gollum"].id], group_ids=[])
notes_service.delete(db, note)
assert db.scalar(select(Share).where(Share.resource_id == note.id)) is None
def test_forgetting_a_principal_drops_their_shares(db, people):
"""A stale row would grant access to whoever next received that id."""
note = _note(db, people["frodo"])
sharing.set_grants(db, note, user_ids=[people["gollum"].id], group_ids=[])
assert sharing.forget_principal(db, PRINCIPAL_USER, people["gollum"].id) == 1
assert sharing.grants_for(db, note) == []
def test_two_kinds_of_resource_do_not_collide(db, people):
"""One shares table across three resource types, so the type must be part
of the match -- otherwise a note and a base sharing an id would share each
other's access."""
note = _note(db, people["frodo"])
base = documents_service.create_base(db, owner=people["frodo"], name="Papers")
sharing.set_grants(db, note, user_ids=[people["gollum"].id], group_ids=[])
assert sharing.can_read(db, note, people["gollum"])
assert not sharing.can_read(db, base, people["gollum"])
@pytest.mark.parametrize("unshareable", ["Memory", "Document"])
def test_resource_type_refuses_something_unshareable(db, people, unshareable):
"""Memory is not shareable at all -- a record about a person is not content
to hand round. A Document is shared through the base it lives in, so asking
to share one directly is a mistake worth catching loudly."""
import lembas.db.models as models
with pytest.raises(ValueError):
sharing.resource_type(getattr(models, unshareable))
# --- Through the search path -------------------------------------------------
def test_search_does_not_leak_across_owners(db, people):
"""The index is searched first and the visibility filter applied to what it
returned. Getting that order wrong leaks a hit even without the contents."""
notes_service.create(
db, owner=people["frodo"], title="Mallorn", body="A golden tree of Lothlorien."
)
assert notes_service.search(db, people["frodo"], "golden")
assert notes_service.search(db, people["gollum"], "golden") == []
assert notes_service.search(db, people["gandalf"], "golden") == []
def test_sharing_a_base_shares_what_is_in_it(db, people):
"""Documents are shared through their base. "This folder is the team's" is
the granularity people think in, and per-document grants would mean
answering "who can see this?" by checking every file."""
base = documents_service.create_base(db, owner=people["frodo"], name="Trees")
document = documents_service.store_upload(
db,
owner=people["frodo"],
payload=b"The mallorn is a golden tree.",
filename="tree.txt",
base=base,
)
assert documents_service.search(db, people["gollum"], "mallorn") == []
sharing.set_grants(db, base, user_ids=[people["gollum"].id], group_ids=[])
found = documents_service.search(db, people["gollum"], "mallorn")
assert [d.id for d in found] == [document.id]
def test_a_document_in_an_unshared_base_stays_private(db, people):
"""Two bases, one shared: the other must not come with it."""
shared = documents_service.create_base(db, owner=people["frodo"], name="Public")
private = documents_service.create_base(db, owner=people["frodo"], name="Private")
documents_service.store_upload(
db, owner=people["frodo"], payload=b"A mallorn tree.", filename="a.txt", base=shared
)
documents_service.store_upload(
db, owner=people["frodo"], payload=b"A mallorn secret.", filename="b.txt", base=private
)
sharing.set_grants(db, shared, user_ids=[people["gollum"].id], group_ids=[])
found = documents_service.search(db, people["gollum"], "mallorn")
assert [d.base_id for d in found] == [shared.id]
def test_scoping_to_a_base_cannot_be_used_to_reach_one(db, people):
"""Naming a base you cannot see returns nothing rather than granting it."""
private = documents_service.create_base(db, owner=people["frodo"], name="Private")
documents_service.store_upload(
db, owner=people["frodo"], payload=b"A mallorn tree.", filename="a.txt", base=private
)
found = documents_service.search(
db, people["gollum"], "mallorn", base_ids=[private.id]
)
assert found == []
def test_the_shares_table_records_what_was_asked_for(db, people):
group = Group(name="Fellowship")
db.add(group)
db.commit()
note = _note(db, people["frodo"])
sharing.set_grants(
db, note, user_ids=[people["gollum"].id], group_ids=[group.id]
)
kinds = {(s.principal_type, s.principal_id) for s in sharing.grants_for(db, note)}
assert kinds == {
(PRINCIPAL_USER, people["gollum"].id),
(PRINCIPAL_GROUP, group.id),
}
def test_visibility_is_a_query_filter_not_a_python_loop(db, people):
"""visible_to returns a condition so callers can page and order on the
database side; a Python filter would break pagination silently."""
for index in range(3):
_note(db, people["frodo"], title=f"Note {index}")
_note(db, people["gollum"], title="Theirs")
rows = db.scalars(
notes_service.visible(db, people["frodo"]).order_by(Note.title).limit(2)
)
assert [n.title for n in rows] == ["Note 0", "Note 1"]
def test_a_document_follows_its_base(db, people):
document = documents_service.store_upload(
db, owner=people["frodo"], payload=b"x", filename="a.txt"
)
assert document in db.scalars(documents_service.visible(db, people["frodo"]))
assert document not in db.scalars(documents_service.visible(db, people["gollum"]))
assert list(db.scalars(documents_service.visible(db, None))) == []
def test_an_uploaded_document_always_lands_in_a_base(db, people):
"""base_id is nullable only so the column could be added to a table that
already had rows; the service never leaves it unset."""
document = documents_service.store_upload(
db, owner=people["frodo"], payload=b"x", filename="a.txt"
)
assert document.base_id is not None
@pytest.mark.parametrize("absent", [None, ""])
def test_documents_predating_bases_are_filed_at_startup(db, people, absent):
"""Both spellings of "no base": NULL, and the empty string a release that
added the column with a type-derived default left behind.
The empty string has to be written with foreign keys off, because that is
the only way it could ever have got there -- ALTER TABLE ADD COLUMN does not
check existing rows, but an UPDATE would.
"""
from sqlalchemy import text
document = documents_service.store_upload(
db, owner=people["frodo"], payload=b"x", filename="a.txt"
)
db.commit()
db.execute(text("PRAGMA foreign_keys=OFF"))
db.execute(
text("UPDATE documents SET base_id = :value WHERE id = :id"),
{"value": absent, "id": document.id},
)
db.commit()
db.execute(text("PRAGMA foreign_keys=ON"))
db.expire(document)
assert document not in db.scalars(documents_service.visible(db, people["frodo"]))
assert documents_service.sweep_unfiled(db) == 1
db.refresh(document)
assert document.base_id is not None
assert document in db.scalars(documents_service.visible(db, people["frodo"]))