mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Keep CCMA usable when the member store is mounted read-only
The encrypted volume holding the member data can be mounted without write access, but starting against such a store failed: the housekeeper takes a lock file before doing anything, so its startup pass died with a PermissionError and took the whole start with it. The store is now probed with an actual write once at startup -- permissions, mount options and filesystem state all matter, and only an attempt covers them together -- and a read-only store opens as a read-only session. The housekeeper is skipped rather than attempted, every write inside the repository goes through one guard that reports ReadOnlyStoreError (a RepositoryError, so the dialogs already handle it) instead of letting an OS error surface, and the services that archive into the member file check before they start sending or rendering. The session says so permanently: a warning banner above the tabs, "NUR LESEN" in the window title and status bar, and refused actions explaining why. Program settings still save -- they live in the user's config directory -- while the store-backed ones are skipped with a notice. "Erneut prüfen" picks up a volume that was remounted writable without restarting. A store that was never initialized still fails, but says that creating one needs write access. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1972dbabb3
commit
be042949a2
@@ -0,0 +1,171 @@
|
||||
import os
|
||||
import stat
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.documents import DocumentService
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
from ccma.storage.repository import MemberRepository, ReadOnlyStoreError
|
||||
|
||||
# Root ignores the permission bits this test relies on, so the read-only mount it
|
||||
# stands in for cannot be simulated there.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permissions"
|
||||
)
|
||||
|
||||
|
||||
def _set_writable(root, writable: bool) -> None:
|
||||
mode_add = stat.S_IWUSR
|
||||
for path in sorted(root.rglob("*"), reverse=True) + [root]:
|
||||
current = stat.S_IMODE(path.stat().st_mode)
|
||||
path.chmod(current | mode_add if writable else current & ~mode_add)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_only_store(tmp_path):
|
||||
root = tmp_path / "store"
|
||||
repository = MemberRepository(root)
|
||||
repository.initialize()
|
||||
member = repository.create_member(
|
||||
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||
)
|
||||
repository.save_contributions(
|
||||
member.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "claim-1",
|
||||
"claim_key": "overdue",
|
||||
"title": "Offene Forderung",
|
||||
"amount": "100.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
_set_writable(root, False)
|
||||
yield MemberRepository(root), member
|
||||
_set_writable(root, True)
|
||||
|
||||
|
||||
def test_a_writable_store_is_not_reported_as_read_only(tmp_path):
|
||||
repository = MemberRepository(tmp_path / "store")
|
||||
repository.initialize()
|
||||
|
||||
assert repository.read_only is False
|
||||
|
||||
|
||||
def test_store_directory_that_does_not_exist_yet_counts_as_writable(tmp_path):
|
||||
assert MemberRepository(tmp_path / "not" / "created" / "yet").read_only is False
|
||||
|
||||
|
||||
def test_read_only_store_opens_and_stays_readable(read_only_store):
|
||||
repository, member = read_only_store
|
||||
|
||||
assert repository.read_only is True
|
||||
repository.initialize()
|
||||
assert repository.validate() == []
|
||||
assert [item.member_id for item in repository.list_members()] == [member.member_id]
|
||||
assert repository.get_member(member.member_id).first_name == "Ada"
|
||||
assert repository.get_contributions(member.member_id).claims
|
||||
assert repository.get_events(member.member_id)
|
||||
assert repository.get_configuration()["organization"]["name"]
|
||||
|
||||
|
||||
def test_every_write_is_refused_with_one_clear_message(read_only_store):
|
||||
repository, member = read_only_store
|
||||
stored = repository.get_member(member.member_id)
|
||||
|
||||
for action in (
|
||||
lambda: repository.save_member(stored),
|
||||
lambda: repository.create_member(first_name="Grace", last_name="Hopper"),
|
||||
lambda: repository.create_manual_claim(
|
||||
member.member_id, title="Beitrag", amount="10.00", due_date="2026-09-30"
|
||||
),
|
||||
lambda: repository.save_contributions(member.member_id, ContributionData()),
|
||||
lambda: repository.append_event(
|
||||
member.member_id, event_type="comment", summary="Test", actor_type="user"
|
||||
),
|
||||
lambda: repository.save_organization({"name": "Test", "iban": "", "bic": ""}),
|
||||
lambda: repository.create_asset(label="Beamer"),
|
||||
):
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
action()
|
||||
|
||||
|
||||
def test_housekeeper_refuses_to_run_instead_of_failing_on_its_lock(read_only_store):
|
||||
repository, _member = read_only_store
|
||||
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
Housekeeper(repository).run(today=date(2026, 2, 10))
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
Housekeeper(repository).delete_task("some-key")
|
||||
assert not (repository.root / ".housekeeper.lock").exists()
|
||||
|
||||
|
||||
def test_documents_and_mails_refuse_before_touching_the_member_file(read_only_store, tmp_path):
|
||||
repository, member = read_only_store
|
||||
|
||||
service = DocumentService(repository)
|
||||
template = next(item for item in service.list_templates() if item.name == "Mitglied")
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
service.generate(template, member.member_id, output_name="Test")
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
"reminder-1",
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Mahnung.eml",
|
||||
sender_name="Verwaltung",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
|
||||
def test_uninitialized_read_only_store_reports_why_it_cannot_be_opened(tmp_path):
|
||||
root = tmp_path / "empty-store"
|
||||
root.mkdir()
|
||||
root.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
||||
try:
|
||||
with pytest.raises(ReadOnlyStoreError, match="repository.json"):
|
||||
MemberRepository(root).initialize()
|
||||
finally:
|
||||
root.chmod(stat.S_IRWXU)
|
||||
|
||||
|
||||
def test_remounting_writable_is_picked_up_without_a_restart(read_only_store):
|
||||
repository, _member = read_only_store
|
||||
assert repository.read_only is True
|
||||
|
||||
_set_writable(repository.root, True)
|
||||
|
||||
assert repository.read_only is True, "the cached answer must stay put until refreshed"
|
||||
assert repository.refresh_read_only() is False
|
||||
repository.append_event(
|
||||
_member.member_id,
|
||||
event_type="comment",
|
||||
summary="Wieder beschreibbar",
|
||||
actor_type="user",
|
||||
)
|
||||
|
||||
|
||||
def test_startup_sequence_completes_without_the_housekeeper(read_only_store):
|
||||
"""Mirrors what the splash screen's worker thread does -- this is the sequence
|
||||
that used to abort the whole start with a PermissionError."""
|
||||
repository, member = read_only_store
|
||||
|
||||
repository.initialize()
|
||||
errors = repository.validate()
|
||||
members = repository.list_members()
|
||||
findings = [] if repository.read_only else Housekeeper(repository).run()
|
||||
|
||||
assert errors == []
|
||||
assert [item.member_id for item in members] == [member.member_id]
|
||||
assert findings == []
|
||||
assert repository.member_count() == 1
|
||||
Reference in New Issue
Block a user