diff --git a/backend/spec.md b/backend/spec.md index d776104..b9ac27e 100644 --- a/backend/spec.md +++ b/backend/spec.md @@ -1095,6 +1095,62 @@ It does not return a `pending_approval` flag in this spec. Pending approval is represented only by pending registrations, not by a partially live user state. +## Committee Collections + +Lists of items a committee manages from its own page, such as the cards in +Het Fotoboek (`/leden/fotos`), edited by FOCUS. + +Each collection is its own table with **one row per item**: key = item id, +value = the item's JSON without the id. Items are saved and deleted one at a +time, so editing one item never touches the others. + +Who may do what is fixed in code, in `COLLECTIONS` +(`data/features/committee_collections.py`): + +| Collection | Table | Read | Write | Item shape | +|---|---|---|---|---| +| `fotos` | `collection_fotos` | `member` | `focus` | `validate_foto_card` in `data/features/fotos.py` | + +Admins can always read and write every collection. Admins configure nothing +per collection: granting a member the committee's permission tag is enough. + +**Security boundary:** a committee permission only grants writing to the +tables of the collections that name it as `write_role`. The collection routes +read and write nothing but the requested collection's own table, and a +committee permission unlocks no other route. So a committee can't change +users, registrations, permissions, other committees' data or anything else. + +### Routes + +The same three routes serve every collection; the body names it. + +- `POST /members/collections/list/` `{collection}`: returns `{items}`, each + item as `{id, created_at, ...fields}`. Needs the read role or `admin`. +- `POST /members/collections/save/` `{collection, item}`: creates the item, or + replaces the item with the same `item.id`. Without an id the backend + generates one. Returns `{item}`. Needs the write role or `admin`. + - `id` and `created_at` are owned by the backend: `created_at` (unix + seconds) is set when the item is created and kept when it is replaced; a + `created_at` sent by the client is ignored + - 400 if the item fails the collection's shape check or the id is invalid + (1-64 letters, digits, `-`, `_`) + - 413 if the encoded item exceeds the collection's `max_item_bytes` +- `POST /members/collections/delete/` `{collection, id}`: needs the write role + or `admin`. 404 if no item has that id. + +All three return 404 for an unknown collection and 403 without the right +permission. They accept the primary or the secondary session cookie. + +### Adding a collection for another committee + +1. Write a validator for one item's shape (like `validate_foto_card`). +2. Add an entry to `COLLECTIONS`: table name, read role, write role, + validator, max item size. The table is created automatically because + `COLLECTION_TABLES` is part of `DB_TABLES`. +3. Build the page in the frontend on the three routes above. + +Concurrent edits of the **same** item are last-write-wins. + ## Email Notifications There are two categories of email. @@ -1364,4 +1420,5 @@ window. | `session_cache` | `session_token` | session JSON | Cache of validated Faroe sessions | | `birthdays` | `user_id` | birthday JSON | Dodeka-owned projection rebuilt from applied Volta data | | `private` | string key | `{"role": permission, "value": JSON}` | Permission-gated private key-value data | +| `collection_` | item id | item JSON | One table per committee collection, see "Committee Collections" | | `tokens` | `{kind}:{email}` | auxiliary token data | Local testing/tooling mirror for verification codes | diff --git a/backend/src/apiserver/app.py b/backend/src/apiserver/app.py index 4fe9d0f..d6c4357 100644 --- a/backend/src/apiserver/app.py +++ b/backend/src/apiserver/app.py @@ -48,6 +48,11 @@ ) from apiserver.handlers.acceptance import start_outbox_dispatcher from apiserver.handlers.features.birthdays import birthdays_handler +from apiserver.handlers.features.committee_collections import ( + delete_collection_item_handler, + list_collection_handler, + save_collection_item_handler, +) from apiserver.handlers.features.private_kv import ( admin_get_private_handler, admin_list_private_handler, @@ -185,6 +190,15 @@ def h_birthdays(): def h_get_private(): return get_private_handler(req, headers, auth_client, store_queue) + def h_list_collection(): + return list_collection_handler(req, headers, auth_client, store_queue) + + def h_save_collection_item(): + return save_collection_item_handler(req, headers, auth_client, store_queue) + + def h_delete_collection_item(): + return delete_collection_item_handler(req, headers, auth_client, store_queue) + def h_admin_get_private(): return admin_get_private_handler(req, store_queue) @@ -275,6 +289,27 @@ def h_admin_list_private(): requires_credentials=True, ) }, + # Committee collections (fotoboek, ...): the handlers check the + # collection's read/write role from COLLECTIONS themselves. + "/members/collections/list/": { + "POST": RouteEntry( + h_list_collection, PermissionConfig.public(), requires_credentials=True + ) + }, + "/members/collections/save/": { + "POST": RouteEntry( + h_save_collection_item, + PermissionConfig.public(), + requires_credentials=True, + ) + }, + "/members/collections/delete/": { + "POST": RouteEntry( + h_delete_collection_item, + PermissionConfig.public(), + requires_credentials=True, + ) + }, "/admin/private_kv/get/": { "POST": RouteEntry(h_admin_get_private, PermissionConfig.require("admin")) }, diff --git a/backend/src/apiserver/data/__init__.py b/backend/src/apiserver/data/__init__.py index f2a1821..3ea4cb9 100644 --- a/backend/src/apiserver/data/__init__.py +++ b/backend/src/apiserver/data/__init__.py @@ -6,6 +6,7 @@ """ from apiserver.data.features.birthdays import BIRTHDAYS_TABLE +from apiserver.data.features.committee_collections import COLLECTION_TABLES from apiserver.data.features.private_kv import PRIVATE_TABLE from apiserver.data.outbox import OUTBOX_TABLE from apiserver.data.registrations import ( @@ -34,4 +35,6 @@ PRIVATE_TABLE, SYSTEM_USERS_TABLE, CODES_TABLE, + # One table per committee collection (fotos, ...) + *COLLECTION_TABLES, ] diff --git a/backend/src/apiserver/data/features/committee_collections.py b/backend/src/apiserver/data/features/committee_collections.py new file mode 100644 index 0000000..7efd1af --- /dev/null +++ b/backend/src/apiserver/data/features/committee_collections.py @@ -0,0 +1,108 @@ +"""Committee collections: committees write their own data to the backend. + +A collection is a list of items one committee manages from its own page, e.g. +the cards of Het Fotoboek (FOCUS). Each collection is its own table with one +row per item (key = item id, value = the item's JSON). + +Security boundary: a committee permission (``focus``, ``comcom``, ...) only +ever grants writing to the tables of the collections that name it as +``write_role``. The collection routes touch nothing but the requested +collection's own table, so a committee can't reach users, registrations, +other committees' data or anything else. Who may read and write is fixed here +in code; admins only grant members the committee's tag. + +To add a collection for another committee: + +1. write a ``validate_`` function describing one item's shape + (see ``fotos.py``), +2. add an entry to ``COLLECTIONS`` below, +3. build the page in the frontend; it uses the generic + ``/members/collections/*`` routes with ``{"collection": ""}``. + +The table is created automatically (``COLLECTION_TABLES`` is part of +``DB_TABLES``). No route or admin setup is needed. +""" + +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from freetser import Storage + +from apiserver.data.features.fotos import validate_foto_card + + +@dataclass(frozen=True) +class Collection: + table: str + """freetser table holding this collection, one row per item.""" + read_role: str + """Permission needed to list the items. Admins can always read.""" + write_role: str + """Permission needed to create, change or delete items. Admins always can.""" + validate: Callable[[dict[str, Any]], list[str]] + """Returns the problems with one item (without its id); empty = valid.""" + max_item_bytes: int + """Upper bound on one encoded item, so a single row can't grow unbounded.""" + + +COLLECTIONS: dict[str, Collection] = { + # Het Fotoboek (/leden/fotos): every member sees the cards, FOCUS edits. + "fotos": Collection( + table="collection_fotos", + read_role="member", + write_role="focus", + validate=validate_foto_card, + max_item_bytes=1_000_000, + ), +} + +COLLECTION_TABLES = [c.table for c in COLLECTIONS.values()] + +# Fields the backend owns: clients can't set them, they are added on read. +SERVER_FIELDS = frozenset({"id", "created_at"}) + + +def list_items(store: Storage, collection: Collection) -> list[dict[str, Any]]: + """All items as ``{"id": ..., "created_at": ..., **fields}``, sorted by id.""" + items: list[dict[str, Any]] = [] + for item_id in sorted(store.list_keys(collection.table)): + result = store.get(collection.table, item_id) + if result is None: + continue + value_bytes, _ = result + items.append({"id": item_id, **json.loads(value_bytes.decode("utf-8"))}) + return items + + +def item_exists(store: Storage, collection: Collection, item_id: str) -> bool: + return store.get(collection.table, item_id) is not None + + +def put_item( + store: Storage, + collection: Collection, + item_id: str, + fields: dict[str, Any], + timestamp: int, +) -> int: + """Create the item, or replace it if the id already exists. + + Stores ``created_at`` (unix seconds) next to the fields: ``timestamp`` for + a new item, the original value when replacing one. Returns it. + """ + created_at = timestamp + existing = store.get(collection.table, item_id) + if existing is not None: + stored_created_at = json.loads(existing[0].decode("utf-8")).get("created_at") + if isinstance(stored_created_at, int): + created_at = stored_created_at + item_bytes = json.dumps({**fields, "created_at": created_at}).encode("utf-8") + store.overwrite(collection.table, item_id, item_bytes, expires_at=0) + return created_at + + +def delete_item(store: Storage, collection: Collection, item_id: str) -> bool: + """Delete the item; False if it did not exist.""" + return store.delete(collection.table, item_id) diff --git a/backend/src/apiserver/data/features/fotos.py b/backend/src/apiserver/data/features/fotos.py new file mode 100644 index 0000000..b562720 --- /dev/null +++ b/backend/src/apiserver/data/features/fotos.py @@ -0,0 +1,147 @@ +"""Shape of a card in Het Fotoboek (/leden/fotos), edited by FOCUS. + +One card is one event with a cover image and links to its photo albums. The +frontend counterpart is ``PhotoEvent`` in +``dodekafrontend/src/pages/leden/fotos/fotos.tsx``; keep the two in sync. +""" + +import re +from datetime import date +from typing import Any + +EVENT_TYPES = frozenset({"Wedstrijd", "Gezelligheid", "Training", "Reis", "Overig"}) + +MAX_TYPES = 3 +MAX_NAME_LENGTH = 200 +MAX_LINKS = 20 +MAX_LINK_LABEL_LENGTH = 50 +MAX_URL_LENGTH = 2000 + +ALLOWED_FIELDS = frozenset( + {"name", "date", "end_date", "types", "in_progress", "seizoen", "image", "links"} +) +SEASON_PATTERN = re.compile(r"^\d{2}/\d{2}$") + + +def parse_iso_date(value: Any) -> date | None: + """The date for a ``YYYY-MM-DD`` string; None if it isn't one.""" + if not isinstance(value, str): + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def is_web_url(value: str) -> bool: + return value.startswith(("https://", "http://")) and len(value) <= MAX_URL_LENGTH + + +def check_name(item: dict[str, Any]) -> list[str]: + name = item.get("name") + if not isinstance(name, str) or not name.strip(): + return ["name is required"] + if len(name) > MAX_NAME_LENGTH: + return [f"name is longer than {MAX_NAME_LENGTH} characters"] + return [] + + +def check_dates(item: dict[str, Any]) -> list[str]: + """``date`` is the (first) day of the event. ``end_date`` is optional, for + events spanning several days (e.g. a trainingsweekend), and can't be + earlier than ``date``.""" + problems: list[str] = [] + start = parse_iso_date(item.get("date")) + if start is None: + problems.append("date must be YYYY-MM-DD") + if "end_date" in item: + end = parse_iso_date(item["end_date"]) + if end is None: + problems.append("end_date must be YYYY-MM-DD") + elif start is not None and end < start: + problems.append("end_date can't be before date") + return problems + + +def check_types(item: dict[str, Any]) -> list[str]: + """An event can be several kinds at once, e.g. Wedstrijd + Gezelligheid.""" + types = item.get("types") + if ( + isinstance(types, list) + and 1 <= len(types) <= MAX_TYPES + and len(set(types)) == len(types) + and all(t in EVENT_TYPES for t in types) + ): + return [] + return [ + f"types must be 1-{MAX_TYPES} different values from " + f"{', '.join(sorted(EVENT_TYPES))}" + ] + + +def check_flags(item: dict[str, Any]) -> list[str]: + """``in_progress`` ("Wordt aan gewerkt"): FOCUS is still editing the photos. + ``seizoen`` overrides the season derived from the date.""" + problems: list[str] = [] + if "in_progress" in item and not isinstance(item["in_progress"], bool): + problems.append("in_progress must be true or false") + seizoen = item.get("seizoen") + if seizoen is not None and ( + not isinstance(seizoen, str) or not SEASON_PATTERN.match(seizoen) + ): + problems.append("seizoen must look like 25/26") + return problems + + +def check_image(item: dict[str, Any]) -> list[str]: + """Images are resized in the browser and stored inline as a data URL; the + collection's max item size keeps them small.""" + image = item.get("image") + if image is not None and ( + not isinstance(image, str) + or not (image.startswith("data:image/") or is_web_url(image)) + ): + return ["image must be an image data URL or a web URL"] + return [] + + +def is_valid_link(link: Any) -> bool: + return ( + isinstance(link, dict) + and set(link) == {"label", "url"} + and isinstance(link["label"], str) + and bool(link["label"].strip()) + and len(link["label"]) <= MAX_LINK_LABEL_LENGTH + and isinstance(link["url"], str) + and is_web_url(link["url"]) + ) + + +def check_links(item: dict[str, Any]) -> list[str]: + links = item.get("links", []) + if not isinstance(links, list) or len(links) > MAX_LINKS: + return [f"links must be a list of at most {MAX_LINKS} links"] + if not all(is_valid_link(link) for link in links): + return ["each link needs a short label and a web url"] + return [] + + +def validate_foto_card(item: dict[str, Any]) -> list[str]: + """Return the problems with a card's fields; an empty list means valid. + + ``item`` is the card without the server-owned ``id`` and ``created_at``. + """ + problems: list[str] = [] + unknown = set(item) - ALLOWED_FIELDS + if unknown: + problems.append(f"unknown fields: {', '.join(sorted(unknown))}") + for check in ( + check_name, + check_dates, + check_types, + check_flags, + check_image, + check_links, + ): + problems.extend(check(item)) + return problems diff --git a/backend/src/apiserver/handlers/features/committee_collections.py b/backend/src/apiserver/handlers/features/committee_collections.py new file mode 100644 index 0000000..2e8cfa7 --- /dev/null +++ b/backend/src/apiserver/handlers/features/committee_collections.py @@ -0,0 +1,205 @@ +"""Handlers for committee collections (see ``data/features/committee_collections``). + +Three generic routes serve every collection; the body names the collection: + +- ``POST /members/collections/list/`` ``{collection}`` +- ``POST /members/collections/save/`` ``{collection, item}`` +- ``POST /members/collections/delete/`` ``{collection, id}`` + +Access comes from the collection's entry in ``COLLECTIONS``: its read role +(or admin) may list, its write role (or admin) may save and delete. These +handlers only read and write the requested collection's own table: that is +the whole reach of a committee permission. Items are saved and deleted one at +a time, so one edit never touches the others. +""" + +import json +import logging +import re +import secrets +import time +from typing import Any + +from freetser import Request, Response, Storage +from freetser.server import StorageQueue + +from apiserver.data.client import AuthClient +from apiserver.data.features.committee_collections import ( + COLLECTIONS, + SERVER_FIELDS, + Collection, + delete_item, + item_exists, + list_items, + put_item, +) +from apiserver.data.permissions import Permissions +from apiserver.server import ( + SESSION_COOKIE_PRIMARY, + SESSION_COOKIE_SECONDARY, + AccessGranted, + check_session_for_access, + get_cookie_value, +) + +logger = logging.getLogger("apiserver.handlers.features.committee_collections") + +ITEM_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + + +def session_has_any_role( + headers: dict[str, str], + roles: list[str], + auth_client: AuthClient, + store_queue: StorageQueue, +) -> bool: + """True if the primary or secondary session cookie grants one of ``roles``. + + Makes blocking auth calls, so never call this inside ``store_queue.execute``. + """ + tokens = [ + get_cookie_value(headers, SESSION_COOKIE_PRIMARY), + get_cookie_value(headers, SESSION_COOKIE_SECONDARY), + ] + for token in tokens: + if token is None: + continue + for role in roles: + result = check_session_for_access( + token, frozenset({role}), auth_client, store_queue + ) + if isinstance(result, AccessGranted): + return True + return False + + +def parse_collection_body(req: Request) -> tuple[Collection, dict[str, Any]] | Response: + """Decode the JSON body and look up its ``collection``. + + Returns ``(collection, body)``, or an error response to send back as-is. + """ + try: + body = json.loads(req.body.decode("utf-8")) + except (json.JSONDecodeError, ValueError) as e: + return Response.text(f"Invalid request: {e}", status_code=400) + if not isinstance(body, dict): + return Response.text("Body must be a JSON object", status_code=400) + name = body.get("collection") + collection = COLLECTIONS.get(name) if isinstance(name, str) else None + if collection is None: + return Response.text(f"Unknown collection: {name!r}", status_code=404) + return collection, body + + +def list_collection_handler( + req: Request, + headers: dict[str, str], + auth_client: AuthClient, + store_queue: StorageQueue, +) -> Response: + """Handle POST /members/collections/list/ — returns ``{"items": [...]}``.""" + parsed = parse_collection_body(req) + if isinstance(parsed, Response): + return parsed + collection, _ = parsed + + allowed = [collection.read_role, Permissions.ADMIN] + if not session_has_any_role(headers, allowed, auth_client, store_queue): + return Response.text("Forbidden", status_code=403) + + def fetch(store: Storage) -> list[dict[str, Any]]: + return list_items(store, collection) + + return Response.json({"items": store_queue.execute(fetch)}) + + +def save_collection_item_handler( + req: Request, + headers: dict[str, str], + auth_client: AuthClient, + store_queue: StorageQueue, +) -> Response: + """Handle POST /members/collections/save/ — body ``{collection, item}``. + + Creates the item, or replaces the one with the same ``item.id``. Without an + id the backend generates one. ``created_at`` is set by the backend (kept + when replacing); a ``created_at`` sent by the client is ignored. Returns + ``{"item": {"id": ..., "created_at": ..., **fields}}``. + """ + parsed = parse_collection_body(req) + if isinstance(parsed, Response): + return parsed + collection, body = parsed + + # Blocking auth call, so outside store_queue.execute. + allowed = [collection.write_role, Permissions.ADMIN] + if not session_has_any_role(headers, allowed, auth_client, store_queue): + return Response.text("Forbidden", status_code=403) + + item = body.get("item") + if not isinstance(item, dict): + return Response.text("item must be a JSON object", status_code=400) + fields = {k: v for k, v in item.items() if k not in SERVER_FIELDS} + item_id = item.get("id") + if item_id is None: + item_id = secrets.token_urlsafe(9) + elif not isinstance(item_id, str) or not ITEM_ID_PATTERN.match(item_id): + return Response.text( + "id must be 1-64 letters, digits, '-' or '_'", status_code=400 + ) + + problems = collection.validate(fields) + if problems: + return Response.text("Invalid item: " + "; ".join(problems), status_code=400) + item_bytes = json.dumps(fields).encode("utf-8") + if len(item_bytes) > collection.max_item_bytes: + return Response.text( + f"Item is too large ({len(item_bytes)} bytes, " + f"max {collection.max_item_bytes})", + status_code=413, + ) + + timestamp = int(time.time()) + + def write(store: Storage) -> int: + return put_item(store, collection, item_id, fields, timestamp) + + created_at = store_queue.execute(write) + logger.info( + f"collection save: {collection.table}/{item_id} ({len(item_bytes)} bytes)" + ) + return Response.json({"item": {"id": item_id, "created_at": created_at, **fields}}) + + +def delete_collection_item_handler( + req: Request, + headers: dict[str, str], + auth_client: AuthClient, + store_queue: StorageQueue, +) -> Response: + """Handle POST /members/collections/delete/ — body ``{collection, id}``. + + 404 if no item has that id. + """ + parsed = parse_collection_body(req) + if isinstance(parsed, Response): + return parsed + collection, body = parsed + + item_id = body.get("id") + if not isinstance(item_id, str) or not ITEM_ID_PATTERN.match(item_id): + return Response.text("Missing or invalid id", status_code=400) + + allowed = [collection.write_role, Permissions.ADMIN] + if not session_has_any_role(headers, allowed, auth_client, store_queue): + return Response.text("Forbidden", status_code=403) + + def remove(store: Storage) -> bool: + if not item_exists(store, collection, item_id): + return False + return delete_item(store, collection, item_id) + + if not store_queue.execute(remove): + return Response.text(f"No item with id {item_id}", status_code=404) + logger.info(f"collection delete: {collection.table}/{item_id}") + return Response.json({"success": True}) diff --git a/backend/tests/test_committee_collections.py b/backend/tests/test_committee_collections.py new file mode 100644 index 0000000..53d5c5c --- /dev/null +++ b/backend/tests/test_committee_collections.py @@ -0,0 +1,192 @@ +"""Integration tests for committee collections, using the fotoboek ("fotos"). + +FOCUS members (and admins) edit cards one at a time; every member reads them. +Admins configure nothing: granting the focus tag is enough. +""" + +from dataclasses import dataclass + +import pytest +from test_spec import ( + ADMIN_COOKIE_HEADERS_BY_CREDS, + complete_signup_from_registration, + get_admin_cookie_headers, + get_user_by_email, + post_json, +) + +from apiserver.data.features.fotos import validate_foto_card + + +@dataclass +class Members: + admin: dict[str, str] + focus: dict[str, str] + plain: dict[str, str] + + +@pytest.fixture(scope="module") +def members(command, backend_url, auth_client) -> Members: + """Admin headers plus one member with ``focus`` and one without.""" + ADMIN_COOKIE_HEADERS_BY_CREDS.clear() + admin = get_admin_cookie_headers(command, auth_client) + + _, focus_token = complete_signup_from_registration( + command, auth_client, backend_url, admin, "focus.lid@example.com" + ) + _, plain_token = complete_signup_from_registration( + command, auth_client, backend_url, admin, "gewoon.lid@example.com" + ) + focus_user = get_user_by_email(backend_url, admin, "focus.lid@example.com") + added = post_json( + backend_url, + "/admin/add_permission/", + {"user_id": focus_user["user_id"], "permission": "focus"}, + admin, + ) + assert added.status_code == 200 + + return Members( + admin=admin, + focus={"Cookie": f"session_token={focus_token}"}, + plain={"Cookie": f"session_token={plain_token}"}, + ) + + +def card(**overrides) -> dict: + base = { + "name": "NSK Baan", + "date": "2026-06-14", + "types": ["Wedstrijd"], + "links": [{"label": "dump", "url": "https://example.com/dump"}], + } + return {**base, **overrides} + + +def list_fotos(backend_url: str, headers: dict[str, str]): + return post_json( + backend_url, "/members/collections/list/", {"collection": "fotos"}, headers + ) + + +def save_foto(backend_url: str, headers: dict[str, str], item: dict): + return post_json( + backend_url, + "/members/collections/save/", + {"collection": "fotos", "item": item}, + headers, + ) + + +def delete_foto(backend_url: str, headers: dict[str, str], item_id: str): + return post_json( + backend_url, + "/members/collections/delete/", + {"collection": "fotos", "id": item_id}, + headers, + ) + + +def test_validate_foto_card() -> None: + assert validate_foto_card(card()) == [] + assert validate_foto_card(card(image="data:image/jpeg;base64,AAAA")) == [] + assert validate_foto_card(card(seizoen="25/26")) == [] + # Several days, several types, still being edited + assert ( + validate_foto_card( + card( + date="2026-06-13", + end_date="2026-06-14", + types=["Wedstrijd", "Gezelligheid"], + in_progress=True, + ) + ) + == [] + ) + + assert validate_foto_card(card(name=" ")) + assert validate_foto_card(card(date="14-06-2026")) + assert validate_foto_card(card(end_date="2026-06-12")) # before date + assert validate_foto_card(card(types=[])) + assert validate_foto_card(card(types=["Feest"])) + assert validate_foto_card(card(types=["Reis", "Reis"])) + assert validate_foto_card(card(in_progress="ja")) + assert validate_foto_card(card(seizoen="2025")) + assert validate_foto_card(card(image="javascript:alert(1)")) + assert validate_foto_card(card(links=[{"label": "x", "url": "ftp://x"}])) + assert validate_foto_card(card(extra="field")) + + +def test_focus_member_manages_cards_one_at_a_time(backend_url, members) -> None: + assert list_fotos(backend_url, members.plain).json() == {"items": []} + + # Create two cards: one with a client id, one with a server-generated id + first = save_foto(backend_url, members.focus, card(id="nsk-baan")) + assert first.status_code == 200, first.text + assert first.json()["item"]["id"] == "nsk-baan" + second = save_foto( + backend_url, members.focus, card(name="Borrel", types=["Gezelligheid"]) + ) + assert second.status_code == 200, second.text + second_id = second.json()["item"]["id"] + + created_at = first.json()["item"]["created_at"] + assert isinstance(created_at, int) + + # Editing one card leaves the other untouched; created_at is kept and a + # client-sent created_at is ignored + edited = save_foto( + backend_url, members.focus, card(id="nsk-baan", name="NSK", created_at=1) + ) + assert edited.status_code == 200, edited.text + assert edited.json()["item"]["created_at"] == created_at + items = {i["id"]: i for i in list_fotos(backend_url, members.plain).json()["items"]} + assert items["nsk-baan"]["name"] == "NSK" + assert items[second_id]["name"] == "Borrel" + + # Deleting one card leaves the other + assert delete_foto(backend_url, members.focus, second_id).status_code == 200 + items = list_fotos(backend_url, members.plain).json()["items"] + assert [i["id"] for i in items] == ["nsk-baan"] + assert delete_foto(backend_url, members.focus, second_id).status_code == 404 + + +def test_admin_can_edit_without_focus_tag(backend_url, members) -> None: + saved = save_foto(backend_url, members.admin, card(id="admin-card")) + assert saved.status_code == 200, saved.text + assert delete_foto(backend_url, members.admin, "admin-card").status_code == 200 + + +def test_only_focus_or_admin_may_write(backend_url, members) -> None: + assert save_foto(backend_url, members.plain, card()).status_code == 403 + assert save_foto(backend_url, {}, card()).status_code == 403 + assert delete_foto(backend_url, members.plain, "nsk-baan").status_code == 403 + # Not logged in: can't read either + assert list_fotos(backend_url, {}).status_code == 403 + + +def test_focus_tag_unlocks_nothing_outside_its_collection(backend_url, members) -> None: + """A committee tag only writes its own collection, nothing else.""" + attempts = [ + ("/admin/private_kv/set/", {"key": "hordes_join", "value": {}}), + ("/admin/add_permission/", {"user_id": "x", "permission": "focus"}), + ("/admin/delete_user/", {"user_id": "x"}), + ] + for path, payload in attempts: + response = post_json(backend_url, path, payload, members.focus) + assert response.status_code in (401, 403), (path, response.status_code) + + +def test_invalid_requests_are_rejected(backend_url, members) -> None: + assert save_foto(backend_url, members.focus, card(date="nope")).status_code == 400 + assert save_foto(backend_url, members.focus, card(id="bad id!")).status_code == 400 + too_big = card(image="data:image/jpeg;base64," + "A" * 1_100_000) + assert save_foto(backend_url, members.focus, too_big).status_code == 413 + + unknown = post_json( + backend_url, + "/members/collections/list/", + {"collection": "nope"}, + members.focus, + ) + assert unknown.status_code == 404