diff --git a/Makefile b/Makefile index 6d37aac..5d76852 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases +.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases check-communication .DEFAULT_GOAL := help @@ -36,3 +36,6 @@ test-browser: ## Browser journeys against isolated test app check: lint ## Full lint/regression gate, including every established browser journey uv run pytest --verify-usecases -m '' --junitxml=var/test-results/check.xml + +check-communication: ## Send-free owner retry/privacy checks; set EMAIL_CONNECT_ROOT + uv run python scripts/check-communication.py --owner "$(EMAIL_CONNECT_ROOT)" diff --git a/coulomb_social/apps/core/urls.py b/coulomb_social/apps/core/urls.py index 4f0cc6e..2f0df2e 100644 --- a/coulomb_social/apps/core/urls.py +++ b/coulomb_social/apps/core/urls.py @@ -8,4 +8,6 @@ urlpatterns = [ path("", views.landing, name="landing"), path("app/", views.app_home, name="app_home"), path("account/session/", views.account_session, name="account_session"), + path("account/data/", views.account_data, name="account_data"), + path("account/export/", views.account_export, name="account_export"), ] diff --git a/coulomb_social/apps/core/views.py b/coulomb_social/apps/core/views.py index 99b10f9..fe0d6d7 100644 --- a/coulomb_social/apps/core/views.py +++ b/coulomb_social/apps/core/views.py @@ -1,8 +1,19 @@ +from django.contrib import messages +from django.contrib.auth import logout from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods, require_safe +from coulomb_social.apps.members.portability import ( + delete_local_member, + deletion_blockers, + export_member, + has_foreign_ownership, + retained_spaces, +) from coulomb_social.apps.spaces.assets import local_asset_url +from coulomb_social.apps.spaces.pageops import PageOpsError from coulomb_social.apps.spaces.services import spaces_for_member from .principal import build_principal @@ -57,3 +68,43 @@ def account_session(request: HttpRequest) -> HttpResponse: def healthz(_request: HttpRequest) -> JsonResponse: return JsonResponse({"status": "ok", "service": "coulomb-social"}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def account_data(request): + principal = build_principal(request, authz_resource_id="account_data") + member = principal.get("member") + if not principal["authz_allow"] or member is None: + return HttpResponseForbidden("Local account access unavailable.") + if request.method == "POST": + try: + if request.POST.get("confirmation") != "DELETE": + raise PageOpsError("Type DELETE to confirm local account removal") + delete_local_member(member) + except PageOpsError as exc: + messages.error(request, exc.message) + else: + logout(request) + messages.success(request, "Your local account was removed. Shared content is retained.") + return redirect("core:landing") + response = render(request, "core/account_data.html", { + "principal": principal, "member": member, + "retained_spaces": retained_spaces(member), "blockers": deletion_blockers(member), + "ownership_review": has_foreign_ownership(member), + }) + response["Cache-Control"] = "private, no-store" + return response + + +@login_required +@require_safe +def account_export(request): + principal = build_principal(request, authz_resource_id="account_export") + member = principal.get("member") + if not principal["authz_allow"] or member is None: + return HttpResponseForbidden("Local account access unavailable.") + response = JsonResponse(export_member(member), json_dumps_params={"indent": 2}) + response["Content-Disposition"] = 'attachment; filename="coulomb-account.json"' + response["Cache-Control"] = "private, no-store" + return response diff --git a/coulomb_social/apps/members/portability.py b/coulomb_social/apps/members/portability.py new file mode 100644 index 0000000..e3f2a52 --- /dev/null +++ b/coulomb_social/apps/members/portability.py @@ -0,0 +1,82 @@ +"""App-local portability. Platform identity and shared content stay with their owners.""" + +from django.db import transaction +from django.db.models import Q + +from coulomb_social.apps.spaces import pageops +from coulomb_social.apps.spaces.models import Space, SpaceMembership +from coulomb_social.apps.spaces.pageops import PageOpsError + +from .models import Member + + +def retained_spaces(member): + """Include archived spaces: archiving does not abandon their ownership.""" + return Space.objects.filter( + Q(created_by=member) | Q(memberships__member=member, memberships__role="owner"), + tenant_id=member.tenant_id, + ).distinct().order_by("pk") + + +def has_foreign_ownership(member): + return Space.objects.filter(created_by=member).exclude(tenant_id=member.tenant_id).exists() + + +def deletion_blockers(member): + blockers = [] + for space in retained_spaces(member): + successor = SpaceMembership.objects.filter( + space=space, role="owner", member__tenant_id=space.tenant_id, + member__user__is_active=True, + ).exclude(member=member).exists() + if not successor: + blockers.append(space) + return blockers + + +def export_member(member): + """Only the requesting member's personal records; no tokens or other members.""" + grants = SpaceMembership.objects.filter(member=member, space__tenant_id=member.tenant_id) + return { + "schema_version": 1, + "scope": "coulomb-social app-local records", + "member": { + field: str(getattr(member, field) or "") for field in ( + "id", "tenant_id", "issuer", "subject", "user_engine_user_id", + "display_name", "email", "created_at", "updated_at", "last_login_at", + ) + }, + "user": {field: str(getattr(member.user, field) or "") for field in ( + "id", "username", "email", "full_name", "date_joined", + )}, + "memberships": [ + {"space_id": str(g.space_id), "space_slug": g.space.slug, "role": g.role, + "created_at": g.created_at.isoformat()} for g in grants.select_related("space") + ], + "created_spaces": [ + {"id": str(s.pk), "slug": s.slug, "title": s.title, "is_active": s.is_active} + for s in Space.objects.filter(created_by=member, tenant_id=member.tenant_id) + ], + "retention": ( + "Shared pages and assets remain in their spaces. Backups follow operator retention." + ), + "platform_identity": "NetKingdom and user-engine records are managed separately.", + } + + +@pageops.content_writer +@transaction.atomic +def delete_local_member(member): + # Serialize against sharing on the retained spaces, then against grants to + # this member. The FK cascades remove all grants and the local session user. + ids = list(retained_spaces(member).values_list("pk", flat=True)) + list(Space.objects.filter(pk__in=ids).order_by("pk").select_for_update()) + member = Member.objects.select_for_update().filter(pk=member.pk).first() + if member is None: + return + if has_foreign_ownership(member): + raise PageOpsError("An inconsistent ownership record needs operator review before removal") + if deletion_blockers(member): + raise PageOpsError("Give another active member owner access to every retained space first") + Space.objects.filter(created_by=member).update(created_by=None) + member.user.delete() diff --git a/coulomb_social/apps/spaces/admin.py b/coulomb_social/apps/spaces/admin.py index 4c23fc0..19990b5 100644 --- a/coulomb_social/apps/spaces/admin.py +++ b/coulomb_social/apps/spaces/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin +from . import pageops from .models import Space, SpaceMembership @@ -11,6 +12,11 @@ class SpaceMembershipInline(admin.TabularInline): @admin.register(Space) class SpaceAdmin(admin.ModelAdmin): + def save_model(self, request, obj, form, change): + with pageops.content_write_lock(): + obj.full_clean() + super().save_model(request, obj, form, change) + list_display = ( "title", "slug", diff --git a/coulomb_social/apps/spaces/import_tree.py b/coulomb_social/apps/spaces/import_tree.py index 023728d..f930980 100644 --- a/coulomb_social/apps/spaces/import_tree.py +++ b/coulomb_social/apps/spaces/import_tree.py @@ -90,6 +90,10 @@ def validate_space_tree( raise pageops.PageOpsError("Destination must not be a symlink") if dest.exists() and not replace: raise pageops.PageOpsError("Destination already exists; pass replace=True to overwrite") + if dest.exists() and pageops.page_aliases(space_slug): + raise pageops.PageOpsError( + "Retained page addresses need reconciliation before replacing content" + ) return tree, space_slug @@ -97,6 +101,22 @@ def import_space_tree( tree: Path, *, tenant_id: str, slug: str | None = None, member: Member | None = None, title: str | None = None, replace: bool = False, memberships: list[tuple[Member, str]] | None = None, +) -> ImportReport: + # Invalid proposals remain write-free, including lock-file creation. + validate_space_tree(tree, tenant_id=tenant_id, slug=slug, member=member, + replace=replace, title=title) + for mapped, role in memberships or []: + if mapped.tenant_id != tenant_id or role not in SpaceMembership.Role.values: + raise pageops.PageOpsError("Invalid membership tenant or role") + return _import_space_tree(tree, tenant_id=tenant_id, slug=slug, member=member, + title=title, replace=replace, memberships=memberships) + + +@pageops.content_writer +def _import_space_tree( + tree: Path, *, tenant_id: str, slug: str | None = None, + member: Member | None = None, title: str | None = None, + replace: bool = False, memberships: list[tuple[Member, str]] | None = None, ) -> ImportReport: """Stage content, then publish with DB writes; restore on ordinary errors. diff --git a/coulomb_social/apps/spaces/models.py b/coulomb_social/apps/spaces/models.py index f3ec814..7196334 100644 --- a/coulomb_social/apps/spaces/models.py +++ b/coulomb_social/apps/spaces/models.py @@ -84,6 +84,13 @@ class Space(models.Model): def __str__(self) -> str: return f"{self.title} ({self.slug})@{self.tenant_id}" + def clean(self): + super().clean() + if Space.objects.filter(slug=self.slug).exclude(pk=self.pk).exists(): + raise ValidationError({"slug": "This content instance already uses that slug"}) + if self.created_by_id and self.created_by.tenant_id != self.tenant_id: + raise ValidationError({"created_by": "Creator belongs to another tenant"}) + @property def has_content_binding(self) -> bool: return bool(self.forgejo_owner and self.forgejo_repo) @@ -103,7 +110,7 @@ class Space(models.Model): class SpaceMembership(models.Model): - """Optional explicit membership; v1 list also allows tenant-wide visibility.""" + """Explicit tenant-matched access; ungranted tenant members cannot read.""" class Role(models.TextChoices): OWNER = "owner", "Owner" @@ -130,3 +137,8 @@ class SpaceMembership(models.Model): def __str__(self) -> str: return f"{self.member_id}@{self.space.slug}:{self.role}" + + def clean(self): + super().clean() + if self.space_id and self.member_id and self.space.tenant_id != self.member.tenant_id: + raise ValidationError({"member": "Member belongs to another tenant"}) diff --git a/coulomb_social/apps/spaces/pageops.py b/coulomb_social/apps/spaces/pageops.py index 8692cf9..7e1ca6c 100644 --- a/coulomb_social/apps/spaces/pageops.py +++ b/coulomb_social/apps/spaces/pageops.py @@ -12,12 +12,20 @@ The app DB holds Space rows as an index; bodies live on disk. from __future__ import annotations +import fcntl +import hashlib import json import logging +import os import re import shutil +import tempfile +import threading +from contextlib import contextmanager from dataclasses import dataclass, field +from functools import wraps from pathlib import Path +from urllib.parse import quote, unquote, urlsplit from django.conf import settings from django.utils.text import slugify as django_slugify @@ -25,6 +33,67 @@ from django.utils.text import slugify as django_slugify logger = logging.getLogger(__name__) _SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_WRITER_LOCK = threading.RLock() +_WRITER_STATE = threading.local() + + +@contextmanager +def content_write_lock(): + """Serialize cooperating app/import workers on one mounted content instance.""" + with _WRITER_LOCK: + if getattr(_WRITER_STATE, "active", False): + yield + return + root = content_root() + root.mkdir(parents=True, exist_ok=True) + fd = os.open(root / ".writer.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + _WRITER_STATE.active = True + yield + finally: + _WRITER_STATE.active = False + os.close(fd) + + +def content_writer(function): + @wraps(function) + def locked(*args, **kwargs): + with content_write_lock(): + return function(*args, **kwargs) + return locked + + +def _fsync_directory(path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _remember(space_slug, page_slug, data): + """Immutable content-addressed versions are part of the paired content backup.""" + directory = content_root() / ".revisions" / space_slug / page_slug + directory.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(data).hexdigest() + path = directory / f"{digest}.md" + if path.exists(): + if path.read_bytes() != data: + raise PageOpsError("Stored revision integrity check failed") + return + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=directory, delete=False) as stream: + temporary = Path(stream.name) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + _fsync_directory(directory) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) class PageOpsError(Exception): @@ -42,6 +111,7 @@ class PageDoc: body: str = "" meta: dict = field(default_factory=dict) path: str = "" + revision: str = "" @property def exists(self) -> bool: @@ -58,6 +128,8 @@ def content_root() -> Path: def space_dir(space_slug: str) -> Path: + if not _SLUG_RE.fullmatch(space_slug): + raise PageOpsError("Invalid space slug") return content_root() / "spaces" / space_slug @@ -70,8 +142,15 @@ def assets_dir(space_slug: str) -> Path: def page_file(space_slug: str, page_slug: str) -> Path: + if not _SLUG_RE.fullmatch(page_slug): + raise PageOpsError("Invalid page slug") name = "index.md" if page_slug == "index" else f"{page_slug}.md" - return pages_dir(space_slug) / name + path = pages_dir(space_slug) / name + for component in (content_root() / "spaces", space_dir(space_slug), + pages_dir(space_slug), path): + if component.is_symlink(): + raise PageOpsError("Content paths must not be symlinks") + return path def normalize_slug(value: str, *, allow_index: bool = True) -> str: @@ -123,6 +202,7 @@ def parse_frontmatter(text: str) -> tuple[dict, str]: def dump_frontmatter(meta: dict, body: str) -> str: + body = body.replace("\r\n", "\n").replace("\r", "\n") lines = ["---"] for key, value in meta.items(): if value is None or value == "": @@ -141,11 +221,59 @@ def dump_frontmatter(meta: dict, body: str) -> str: def ensure_space_tree(space_slug: str) -> Path: root = space_dir(space_slug) + page_file(space_slug, "index") + if assets_dir(space_slug).is_symlink(): + raise PageOpsError("Content paths must not be symlinks") pages_dir(space_slug).mkdir(parents=True, exist_ok=True) assets_dir(space_slug).mkdir(parents=True, exist_ok=True) return root +def page_aliases(space_slug): + page_file(space_slug, "index") # Validate the trusted tree boundaries. + path = space_dir(space_slug) / ".page-aliases.json" + if path.is_symlink(): + raise PageOpsError("Content paths must not be symlinks") + if not path.exists(): + return {} + try: + aliases = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(aliases, dict): + raise ValueError + for source, target in aliases.items(): + if (not _SLUG_RE.fullmatch(source) or not isinstance(target, list) + or len(target) != 2 or not all( + isinstance(part, str) and _SLUG_RE.fullmatch(part) for part in target + )): + raise ValueError + return aliases + except (ValueError, TypeError) as exc: + raise PageOpsError("Page-address history needs operator review") from exc + + +@content_writer +def remember_page_address(source_space, source_page, dest_space, dest_page): + source_page = normalize_slug(source_page) + dest_page = normalize_slug(dest_page) + space_dir(dest_space) + aliases = page_aliases(source_space) + aliases[source_page] = [dest_space, dest_page] + directory = space_dir(source_space) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=directory, delete=False) as stream: + temporary = Path(stream.name) + stream.write((json.dumps(aliases, sort_keys=True) + "\n").encode("utf-8")) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, directory / ".page-aliases.json") + _fsync_directory(directory) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +@content_writer def write_page( space_slug: str, page_slug: str, @@ -155,6 +283,7 @@ def write_page( visual: str = "", body: str = "", extra_meta: dict | None = None, + expected_revision: str | None = None, ) -> PageDoc: page_slug = normalize_slug(page_slug) if page_slug != "index" else "index" ensure_space_tree(space_slug) @@ -167,7 +296,29 @@ def write_page( if extra_meta: meta.update(extra_meta) path = page_file(space_slug, page_slug) - path.write_text(dump_frontmatter(meta, body or ""), encoding="utf-8") + exists = path.is_file() + current = path.read_bytes() if exists else b"" + if not exists and page_slug in page_aliases(space_slug): + raise PageOpsError("This page address is reserved by a renamed or moved page") + revision = hashlib.sha256(current).hexdigest() if exists else "" + if expected_revision is not None and expected_revision != revision: + raise PageOpsError("This page changed. Reopen it and merge your edits before saving") + data = dump_frontmatter(meta, body or "").encode("utf-8") + if exists: + _remember(space_slug, page_slug, current) + _remember(space_slug, page_slug, data) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream: + temporary = Path(stream.name) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + _fsync_directory(path.parent) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) return read_page(space_slug, page_slug) @@ -176,7 +327,8 @@ def read_page(space_slug: str, page_slug: str) -> PageDoc: path = page_file(space_slug, page_slug) if not path.is_file(): return PageDoc(slug=page_slug, path="") - text = path.read_text(encoding="utf-8") + data = path.read_bytes() + text = data.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") meta, body = parse_frontmatter(text) return PageDoc( slug=page_slug, @@ -186,6 +338,7 @@ def read_page(space_slug: str, page_slug: str) -> PageDoc: body=body, meta=meta, path=str(path), + revision=hashlib.sha256(data).hexdigest(), ) @@ -202,14 +355,81 @@ def list_pages(space_slug: str) -> list[PageDoc]: return docs +@content_writer def delete_page(space_slug: str, page_slug: str) -> None: if page_slug == "index": raise PageOpsError("Cannot delete the space index page") path = page_file(space_slug, normalize_slug(page_slug)) if path.is_file(): + _remember(space_slug, page_slug, path.read_bytes()) path.unlink() + _fsync_directory(path.parent) +_BODY_ASSET = re.compile( + r"(?P\]\(\s*(?:\.\./|\./)?assets/[^\s)>]+)" +) + + +def _copy_assets(source_space, dest_space, dest_page, visual, body): + """Copy local Visual and Markdown link/image destinations as one staged set.""" + from .assets import asset_parts, open_local_asset + + copied = {} + created = [] + + def copy_reference(reference, *, optional=False): + parsed = urlsplit(reference) + path = unquote(parsed.path) + for prefix in ("../assets/", "./assets/", "assets/"): + if path.startswith(prefix): + path = path[len(prefix):] + break + else: + raise PageOpsError("Local attachments must be inside the source space assets") + try: + parts = asset_parts(path) + if path not in copied: + try: + source_file = open_local_asset(source_space, path) + except FileNotFoundError: + if optional: + return reference + raise + # Include the relative-path hash to distinguish nested equal filenames. + suffix = ( + "" if len(parts) == 1 else f"-{hashlib.sha256(path.encode()).hexdigest()[:12]}" + ) + dest_name = f"{dest_page}{suffix}-{parts[-1]}" + with source_file: + with open_local_asset(dest_space, dest_name, create=True) as stream: + created.append(assets_dir(dest_space) / dest_name) + shutil.copyfileobj(source_file, stream) + stream.flush() + os.fsync(stream.fileno()) + copied[path] = f"assets/{quote(dest_name)}" + suffix = (f"?{parsed.query}" if parsed.query else "") + suffix += f"#{parsed.fragment}" if parsed.fragment else "" + return copied[path] + suffix + except (OSError, ValueError) as exc: + raise PageOpsError( + "Cannot copy the destination Visual or body attachment safely" + ) from exc + + try: + if visual and not visual.startswith(("http", "/")): + visual = copy_reference(visual, optional=True) + body = _BODY_ASSET.sub( + lambda match: match["prefix"] + copy_reference(match["url"]), body, + ) + return visual, body, created + except BaseException: + for path in created: + path.unlink(missing_ok=True) + raise + + +@content_writer def copy_page( source_space: str, source_page: str, @@ -222,76 +442,49 @@ def copy_page( if not src.exists: raise PageOpsError("Source page not found") dest_page = normalize_slug(dest_page) - if page_file(dest_space, dest_page).is_file(): + if (page_file(dest_space, dest_page).is_file() + or dest_page in page_aliases(dest_space)): raise PageOpsError("Destination page already exists") ensure_space_tree(dest_space) - # copy visual asset if relative - visual = src.visual - if visual and not visual.startswith("http") and not visual.startswith("/"): - from .assets import open_local_asset - - if not visual.startswith("assets/"): - raise PageOpsError("Visual must be inside the source space assets") - try: - source_file = open_local_asset(source_space, visual[len("assets/"):]) - except FileNotFoundError: - source_file = None - except (OSError, ValueError) as exc: - raise PageOpsError("Cannot copy this Visual safely") from exc - if source_file is not None: - dest_name = f"{dest_page}-{Path(visual).name}" - with source_file: - try: - with open_local_asset(dest_space, dest_name, create=True) as destination_file: - shutil.copyfileobj(source_file, destination_file) - except OSError as exc: - raise PageOpsError("Cannot write the destination Visual safely") from exc - visual = f"assets/{dest_name}" - new_title = title if title is not None else (src.title or dest_page) - if new_title == src.title and source_space == dest_space: - new_title = f"{src.title} (copy)" if src.title else f"{dest_page}" - return write_page( - dest_space, - dest_page, - title=new_title, - abstractor=src.abstractor, - visual=visual, - body=src.body, - extra_meta={ - k: v for k, v in src.meta.items() - if k not in {"title", "abstractor", "visual", "space"} - }, + visual, body, created = _copy_assets( + source_space, dest_space, dest_page, src.visual, src.body, ) + new_title = title if title is not None else (src.title or dest_page) + if title is None and new_title == src.title and source_space == dest_space: + new_title = f"{src.title} (copy)" if src.title else f"{dest_page}" + try: + return write_page( + dest_space, dest_page, title=new_title, abstractor=src.abstractor, + visual=visual, body=body, expected_revision="", + extra_meta={k: v for k, v in src.meta.items() + if k not in {"title", "abstractor", "visual", "space"}}, + ) + except BaseException: + if not page_file(dest_space, dest_page).exists(): + for path in created: + path.unlink(missing_ok=True) + raise +@content_writer def transfer_page( source_space: str, source_page: str, dest_space: str, dest_page: str | None = None, ) -> PageDoc: - dest_page = dest_page or source_page + source_page = normalize_slug(source_page) + dest_page = normalize_slug(dest_page or source_page) if source_page == "index": raise PageOpsError("Cannot transfer the space index page") - doc = copy_page(source_space, source_page, dest_space, dest_page, title=None) - # restore original title (copy_page may suffix) src = read_page(source_space, source_page) - write_page( - dest_space, - dest_page, - title=src.title, - abstractor=src.abstractor, - visual=doc.visual, - body=src.body, - extra_meta={ - k: v for k, v in src.meta.items() - if k not in {"title", "abstractor", "visual", "space"} - }, - ) + copy_page(source_space, source_page, dest_space, dest_page, title=src.title) + remember_page_address(source_space, source_page, dest_space, dest_page) delete_page(source_space, source_page) return read_page(dest_space, dest_page) +@content_writer def init_space_content( space_slug: str, *, @@ -313,6 +506,7 @@ def init_space_content( ) +@content_writer def rename_space_tree(old_slug: str, new_slug: str) -> None: old = space_dir(old_slug) new = space_dir(new_slug) @@ -324,6 +518,7 @@ def rename_space_tree(old_slug: str, new_slug: str) -> None: old.rename(new) +@content_writer def archive_space_tree(space_slug: str) -> None: """Soft-archive: rename tree to .archived- if present.""" root = space_dir(space_slug) @@ -331,7 +526,7 @@ def archive_space_tree(space_slug: str) -> None: return dest = content_root() / "spaces" / f".archived-{space_slug}" if dest.exists(): - shutil.rmtree(dest) + raise PageOpsError("A retained archive already exists; resolve it before archiving again") root.rename(dest) diff --git a/coulomb_social/apps/spaces/services.py b/coulomb_social/apps/spaces/services.py index da39ea6..849fd4b 100644 --- a/coulomb_social/apps/spaces/services.py +++ b/coulomb_social/apps/spaces/services.py @@ -2,8 +2,9 @@ from __future__ import annotations +from django.core.exceptions import ValidationError from django.db import transaction -from django.db.models import Q, QuerySet +from django.db.models import Count, Q, QuerySet from coulomb_social.apps.members.models import Member @@ -16,7 +17,10 @@ def spaces_for_member(member: Member | None) -> QuerySet[Space]: if member is None: return Space.objects.none() - qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True) + ambiguous = Space.objects.values("slug").annotate(n=Count("pk")).filter(n__gt=1) + qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True).exclude( + slug__in=ambiguous.values("slug") + ) return ( qs.filter(Q(memberships__member=member) | Q(created_by=member)) .distinct() @@ -34,17 +38,61 @@ def can_write_space(member: Member | None, space: Space) -> bool: """Require an exact tenant/space grant, not a same-slug visibility match.""" if member is None or member.tenant_id != space.tenant_id or not space.is_active: return False + if Space.objects.filter(slug=space.slug).exclude(pk=space.pk).exists(): + return False membership = SpaceMembership.objects.filter(space=space, member=member).first() if membership is not None: return membership.role in {SpaceMembership.Role.OWNER, SpaceMembership.Role.MEMBER} return space.created_by_id == member.pk +def can_manage_space(member: Member | None, space: Space) -> bool: + if not can_write_space(member, space): + return False + grant = SpaceMembership.objects.filter(space=space, member=member).first() + return grant.role == SpaceMembership.Role.OWNER if grant else space.created_by_id == member.pk + + +@transaction.atomic +def set_space_access(member: Member, space: Space, target_id, role: str) -> None: + """Owners grant access to an existing, exact local member; never invite by email.""" + space = Space.objects.select_for_update().get(pk=space.pk) + if not can_manage_space(member, space): + raise pageops.PageOpsError("Only a space owner can manage sharing") + try: + target = Member.objects.select_for_update().get(pk=target_id, tenant_id=space.tenant_id) + except (Member.DoesNotExist, ValidationError, ValueError, TypeError): + raise pageops.PageOpsError("Choose an existing member in this tenant") from None + if role not in {*SpaceMembership.Role.values, "remove"}: + raise pageops.PageOpsError("Choose a valid access role") + # A creator has an implicit grant. Do not claim to revoke that grant by + # deleting a membership that would simply re-enable the fallback. + if target.pk == space.created_by_id and role != SpaceMembership.Role.OWNER: + raise pageops.PageOpsError("The creator must retain owner access") + current = SpaceMembership.objects.filter(space=space, member=target).first() + if current and current.role == "owner" and role != "owner": + others = SpaceMembership.objects.filter( + space=space, role="owner", member__tenant_id=space.tenant_id, + ).exclude(member=target) + if not others.exists() and not ( + space.created_by_id and space.created_by_id != target.pk + and space.created_by.tenant_id == space.tenant_id + ): + raise pageops.PageOpsError("Keep at least one owner") + if role == "remove": + SpaceMembership.objects.filter(space=space, member=target).delete() + else: + SpaceMembership.objects.update_or_create( + space=space, member=target, defaults={"role": role}, + ) + + def require_space_write(member: Member | None, space: Space) -> None: if not can_write_space(member, space): raise pageops.PageOpsError("You have read-only access to this space") +@pageops.content_writer @transaction.atomic def create_space( member: Member, @@ -54,6 +102,8 @@ def create_space( slug: str | None = None, visual: str = "", ) -> Space: + if member is None: + raise pageops.PageOpsError("Sign in with a local member profile before creating a space") title = (title or "").strip() if not title: raise pageops.PageOpsError("Title is required") @@ -85,6 +135,7 @@ def create_space( return space +@pageops.content_writer @transaction.atomic def update_space( member: Member, @@ -93,8 +144,14 @@ def update_space( title: str | None = None, abstractor: str | None = None, visual: str | None = None, + expected_revision: str | None = None, ) -> Space: require_space_write(member, space) + index = pageops.read_page(space.slug, "index") + if expected_revision is not None and index.revision != expected_revision: + raise pageops.PageOpsError( + "This space changed. Reopen it and merge your edits before saving" + ) if title is not None: space.title = title.strip() or space.title if abstractor is not None: @@ -103,7 +160,6 @@ def update_space( space.visual = visual space.save() # Keep index.md in sync - index = pageops.read_page(space.slug, "index") body = index.body if index.exists else f"# {space.title}\n" pageops.write_page( space.slug, @@ -112,21 +168,21 @@ def update_space( abstractor=space.description, visual=space.visual, body=body, - extra_meta={"bubble_type": "space"}, + extra_meta={**{k: v for k, v in index.meta.items() + if k not in {"title", "abstractor", "visual", "space"}}, + "bubble_type": "space"}, + expected_revision=expected_revision, ) return space +@pageops.content_writer @transaction.atomic def archive_space(member: Member, space: Space) -> Space: require_space_write(member, space) space.is_active = False space.save(update_fields=["is_active", "updated_at"]) - try: - pageops.archive_space_tree(space.slug) - except Exception: - # Tree archive is best-effort - pass + pageops.archive_space_tree(space.slug) return space @@ -134,6 +190,7 @@ def list_space_pages(space: Space) -> list[pageops.PageDoc]: return pageops.list_pages(space.slug) +@pageops.content_writer def put_space_page( member: Member, space: Space, @@ -144,6 +201,7 @@ def put_space_page( visual: str = "", body: str = "", extra_meta: dict | None = None, + expected_revision: str | None = None, ) -> pageops.PageDoc: require_space_write(member, space) metadata = (pageops.read_page(space.slug, page_slug).meta @@ -157,6 +215,7 @@ def put_space_page( body=body, extra_meta={k: v for k, v in metadata.items() if k not in {"title", "abstractor", "visual", "space"}}, + expected_revision=expected_revision, ) @@ -181,6 +240,27 @@ def copy_space_page( ) +@pageops.content_writer +def save_space_page(member, space, source_slug, target_slug, *, expected_revision, **fields): + require_space_write(member, space) + source = pageops.read_page(space.slug, source_slug) + if expected_revision is None or source.revision != expected_revision: + raise pageops.PageOpsError( + "This page changed. Reopen it and merge your edits before saving" + ) + if source_slug == "index" and target_slug != "index": + raise pageops.PageOpsError("The space index file is fixed") + if target_slug != source_slug and pageops.page_file(space.slug, target_slug).exists(): + raise pageops.PageOpsError("A page with that slug already exists") + doc = put_space_page(member, space, target_slug, extra_meta=source.meta, + expected_revision=expected_revision if source_slug == target_slug else "", + **fields) + if source_slug != target_slug: + pageops.remember_page_address(space.slug, source_slug, space.slug, target_slug) + pageops.delete_page(space.slug, source_slug) + return doc + + def transfer_space_page( member: Member, space: Space, diff --git a/coulomb_social/apps/spaces/urls.py b/coulomb_social/apps/spaces/urls.py index 1240df3..41991f2 100644 --- a/coulomb_social/apps/spaces/urls.py +++ b/coulomb_social/apps/spaces/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path("new/", views.space_create, name="create"), path("/assets/", views.space_asset, name="asset"), path("/edit/", views.space_edit, name="edit"), + path("/sharing/", views.space_sharing, name="sharing"), path("/archive/", views.space_archive, name="archive"), path("/refresh/", views.space_refresh, name="refresh"), path("/pages/new/", views.page_new, name="page_new"), diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py index 7990bb4..2f2056a 100644 --- a/coulomb_social/apps/spaces/views.py +++ b/coulomb_social/apps/spaces/views.py @@ -31,15 +31,18 @@ from .content import ( forgejo_repo_url, load_space_page, ) -from .models import Space +from .models import Space, SpaceMembership from .services import ( archive_space, + can_manage_space, can_write_space, copy_space_page, create_space, get_space_for_member, list_space_pages, put_space_page, + save_space_page, + set_space_access, spaces_for_member, transfer_space_page, update_space, @@ -48,6 +51,12 @@ from .services import ( logger = logging.getLogger(__name__) +def _mutation_error(exc): + if isinstance(exc, pageops.PageOpsError): + return exc.message + return "The content write could not be confirmed. Reopen the page before retrying." + + @login_required @require_safe def space_asset(request: HttpRequest, slug: str, asset_path: str) -> HttpResponse: @@ -101,8 +110,8 @@ def space_create(request: HttpRequest) -> HttpResponse: ) messages.success(request, f"Space “{space.title}” created.") return redirect("spaces:detail", slug=space.slug) - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) return render( request, "spaces/form.html", @@ -130,17 +139,23 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse: return HttpResponseForbidden("You have read-only access to this space.") if request.method == "POST": try: + if "revision" not in request.POST: + raise pageops.PageOpsError("Reopen this space before saving") update_space( member, space, title=(request.POST.get("title") or "").strip(), abstractor=(request.POST.get("abstractor") or "").strip(), visual=(request.POST.get("visual") or "").strip(), + expected_revision=request.POST.get("revision"), ) messages.success(request, "Space updated.") return redirect("spaces:detail", slug=space.slug) - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) + space.title = request.POST.get("title", space.title) + space.description = request.POST.get("abstractor", space.description) + space.visual = request.POST.get("visual", space.visual) return render( request, "spaces/form.html", @@ -150,6 +165,8 @@ def space_edit(request: HttpRequest, slug: str) -> HttpResponse: "form_title": f"Edit {space.title}", "space": space, "submit_label": "Save", + "revision": (request.POST.get("revision", "") if request.method == "POST" + else pageops.read_page(space.slug, "index").revision), }, ) @@ -169,8 +186,8 @@ def space_archive(request: HttpRequest, slug: str) -> HttpResponse: try: archive_space(member, space) messages.success(request, f"Space “{space.title}” archived.") - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) return redirect("core:app_home") @@ -185,6 +202,33 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse: return HttpResponseNotFound("Space not found.") page = request.GET.get("page") or "index" + # Old incoming links survive rename/move, but every hop still requires an + # active exact tenant/space grant. Never redirect an unauthorized reader. + try: + page = pageops.normalize_slug(page) + target_space, target_page = space, page + visited = set() + while not pageops.read_page(target_space.slug, target_page).exists: + key = (target_space.slug, target_page) + if key in visited or len(visited) >= 16: + return HttpResponseNotFound("Page not found.") + visited.add(key) + alias = pageops.page_aliases(target_space.slug).get(target_page) + if alias is None: + break + target_space = get_space_for_member(member, alias[0]) + if target_space is None: + return HttpResponseNotFound("Page not found.") + target_page = alias[1] + if (target_space.pk, target_page) != (space.pk, page): + if not pageops.read_page(target_space.slug, target_page).exists: + return HttpResponseNotFound("Page not found.") + target = reverse("spaces:detail", kwargs={"slug": target_space.slug}) + response = redirect(f"{target}?page={target_page}") + response["Cache-Control"] = "private, no-store" + return response + except pageops.PageOpsError: + return HttpResponseNotFound("Page not found.") rendered = load_space_page(space, page) pages = list_space_pages(space) return render( @@ -198,6 +242,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse: "space_visual_url": local_asset_url(space.slug, space.visual), "pages": pages, "can_write": can_write_space(member, space), + "can_manage": can_manage_space(member, space), "forgejo_repo_url": forgejo_repo_url(space), "forgejo_edit_url": forgejo_edit_url(space, page), "forgejo_blob_url": forgejo_blob_url(space, page), @@ -205,6 +250,36 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse: ) +@login_required +@require_http_methods(["GET", "POST"]) +def space_sharing(request, slug): + principal, forbid = _principal_or_forbid(request, "space_sharing") + if forbid: + return forbid + member = principal.get("member") + space = get_space_for_member(member, slug) + if space is None: + return HttpResponseNotFound("Space not found.") + if not can_manage_space(member, space): + return HttpResponseForbidden("Only a space owner can manage sharing.") + if request.method == "POST": + try: + set_space_access(member, space, request.POST.get("member_id"), + request.POST.get("role", "")) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) + else: + messages.success(request, "Space access updated.") + return redirect("spaces:sharing", slug=slug) + response = render(request, "spaces/sharing.html", { + "space": space, "roles": SpaceMembership.Role.choices, + "grants": space.memberships.filter(member__tenant_id=space.tenant_id) + .select_related("member"), + }) + response["Cache-Control"] = "private, no-store" + return response + + @login_required @require_http_methods(["GET", "POST"]) def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> HttpResponse: @@ -221,8 +296,8 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http if page_slug != "index": try: page_slug = pageops.normalize_slug(page_slug) - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) return redirect("spaces:detail", slug=slug) doc = pageops.read_page(space.slug, page_slug) @@ -237,41 +312,18 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http target_slug = "index" else: target_slug = pageops.normalize_slug(new_slug) - if target_slug != page_slug and page_slug != "index": - # rename: write new, delete old - if pageops.page_file(space.slug, target_slug).is_file(): - raise pageops.PageOpsError("A page with that slug already exists") - put_space_page( - member, - space, - target_slug, - title=title, - abstractor=abstractor, - visual=visual, - body=body, - extra_meta=doc.meta, - ) - pageops.delete_page(space.slug, page_slug) - page_slug = target_slug - else: - put_space_page( - member, - space, - target_slug, - title=title, - abstractor=abstractor, - visual=visual, - body=body, - ) - page_slug = target_slug + save_space_page(member, space, page_slug, target_slug, + expected_revision=request.POST.get("revision"), + title=title, abstractor=abstractor, visual=visual, body=body) + page_slug = target_slug clear_content_cache_for_space(space) messages.success(request, "Page saved.") target = reverse("spaces:detail", kwargs={"slug": space.slug}) if page_slug != "index": target = f"{target}?page={page_slug}" return redirect(target) - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) doc = pageops.PageDoc( slug=page_slug, title=title, @@ -279,6 +331,7 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http visual=visual, body=body, path=doc.path, + revision=request.POST.get("revision", ""), ) return render( @@ -289,6 +342,7 @@ def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> Http "display_name": principal["display_name"], "space": space, "doc": doc, + "submitted_slug": request.POST.get("slug", page_slug), "is_new": not doc.exists, "form_title": "Edit or rename page" if doc.exists else "New page", }, @@ -331,13 +385,14 @@ def page_new(request: HttpRequest, slug: str) -> HttpResponse: abstractor=abstractor, visual=visual, body=body, + expected_revision="", ) clear_content_cache_for_space(space) messages.success(request, "Page created.") target = reverse("spaces:detail", kwargs={"slug": space.slug}) return redirect(f"{target}?page={page_slug}") - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) return render( request, @@ -377,8 +432,8 @@ def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse pageops.delete_page(space.slug, page_slug) clear_content_cache_for_space(space) messages.success(request, f"Page “{page_slug}” deleted.") - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) return redirect("spaces:detail", slug=slug) @@ -417,8 +472,8 @@ def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse: if doc.slug != "index": target = f"{target}?page={doc.slug}" return redirect(target) - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) return render( request, "spaces/page_copy.html", @@ -468,8 +523,8 @@ def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpRespon if doc.slug != "index": target = f"{target}?page={doc.slug}" return redirect(target) - except pageops.PageOpsError as exc: - messages.error(request, exc.message) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) return render( request, "spaces/page_transfer.html", diff --git a/coulomb_social/templates/base.html b/coulomb_social/templates/base.html index 3794a7f..e34d7f0 100644 --- a/coulomb_social/templates/base.html +++ b/coulomb_social/templates/base.html @@ -154,6 +154,7 @@ diff --git a/coulomb_social/templates/core/account_data.html b/coulomb_social/templates/core/account_data.html new file mode 100644 index 0000000..ce2e007 --- /dev/null +++ b/coulomb_social/templates/core/account_data.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Your app data — {{ site_name }}{% endblock %} +{% block content %} +

Your app data

+

Your member ID: {{ member.id }}. Share this ID with a space owner to request access.

+

Export my account records

+

The export includes your local profile, identity references and memberships. Pages and assets remain in their spaces.

+

Leaving the app

+

Removing your local account removes its profile and space grants and signs you out. +Shared pages and assets stay with their spaces. Existing backups follow the operator's retention policy. +Your platform identity and mailbox remain active; signing in again creates a new local profile without your old grants.

+

Spaces you are responsible for

+
    {% for space in retained_spaces %}
  • {{ space.title }}{% if not space.is_active %} (archived){% endif %}
  • {% empty %}
  • None
  • {% endfor %}
+{% if ownership_review %} +

An inconsistent ownership record needs operator review before removal.

+{% elif blockers %} +

Before removal, give another active member owner access to each of these spaces:

+
    {% for space in blockers %}
  • {{ space.title }}
  • {% endfor %}
+{% else %} +
{% csrf_token %} + + + +
+{% endif %} +{% endblock %} diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html index 79b2650..14f3571 100644 --- a/coulomb_social/templates/spaces/detail.html +++ b/coulomb_social/templates/spaces/detail.html @@ -97,6 +97,7 @@
Edit space New page + {% if can_manage %}Share space{% endif %}
{% endif %} diff --git a/coulomb_social/templates/spaces/form.html b/coulomb_social/templates/spaces/form.html index 4514353..0998383 100644 --- a/coulomb_social/templates/spaces/form.html +++ b/coulomb_social/templates/spaces/form.html @@ -34,6 +34,7 @@
{% csrf_token %} + {% if space %}{% endif %} diff --git a/coulomb_social/templates/spaces/page_form.html b/coulomb_social/templates/spaces/page_form.html index 43ccd26..349e8c5 100644 --- a/coulomb_social/templates/spaces/page_form.html +++ b/coulomb_social/templates/spaces/page_form.html @@ -34,6 +34,7 @@ {% csrf_token %} + {% else %} diff --git a/coulomb_social/templates/spaces/sharing.html b/coulomb_social/templates/spaces/sharing.html new file mode 100644 index 0000000..b9c5b6e --- /dev/null +++ b/coulomb_social/templates/spaces/sharing.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Share {{ space.title }} — {{ site_name }}{% endblock %} +{% block content %} +

← {{ space.title }}

+

Share space

+

Ask the person for the member ID shown in Your app data. They must already have signed in to this tenant. +Viewers can read and copy content. Members can edit. Owners can also manage sharing. +Access covers every page and image in this space. This action does not send email.

+{% csrf_token %} + + + + +

+
+

Current access

+
    {% for grant in grants %}
  • {{ grant.member.display_name|default:grant.member.subject }} — {{ grant.get_role_display }} — {{ grant.member_id }}
  • {% empty %}
  • The creator has owner access.
  • {% endfor %}
+{% endblock %} diff --git a/docs/release/communication-regressions-2026-10-08.json b/docs/release/communication-regressions-2026-10-08.json new file mode 100644 index 0000000..ba930e7 --- /dev/null +++ b/docs/release/communication-regressions-2026-10-08.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "owner": "email-connect", + "passed": true, + "network": "all socket connects and DNS resolution denied", + "tests": 39, + "skipped": 0, + "failed": 0, + "missing_required_tests": [], + "source_sha256": { + "src/email_connect/__init__.py": "0f1ae5e9251f80b0bd5220b1d7bf91735d6ea273e07aa26cb7852a7950be7087", + "src/email_connect/adapter_contract.py": "5059b2eaaac2408f4e247bb468a97960eb3f823e63c3eeb151a1c2250e1f3bbf", + "src/email_connect/cli.py": "6ac6ae8acd091ba2d2102589be3b75fe3a48392309e40e61f8383deb3329ad42", + "src/email_connect/config.py": "6ca89fb7c3e1f35bc4faedfba8c3313d4366b2cb7830ef88ed3f7d2313f7867e", + "src/email_connect/evidence.py": "42cca03234a79dc78033ec1fbaf0b1785e6089ef3f3128e2e9d666ed239a3582", + "src/email_connect/mailbox.py": "7f0fe7e19ce1f814269609c4236152042f0f49bf3b517114b3b3f6f482471362", + "src/email_connect/models.py": "7f8136646b663a097280286dccce76a90abb7190ea0712fff7da32d47526521c", + "src/email_connect/parser.py": "786b291256ddfac5a8622c743622508cdd9f45f06156d0f5487ce399fad1287c", + "src/email_connect/recipients.py": "68792f44f0d2ba204649542aa8bd6b0f209d89c675baa4fc0767f1250a29045f", + "src/email_connect/reporting.py": "8f8ce5f98e4347b778893d08a9e58f2bab3b44b80c159fcf52d0b5772429737c", + "src/email_connect/scanner.py": "6266cbad2dbc610c1f6df1c051ded2ee55b43d7ff30af6eab38721924931c1ed", + "src/email_connect/storage.py": "57fbd6d3b2e7541c0bf52d87a4f7361a4ff4a03511620d2a1c3789632b73afeb", + "src/email_connect/transactional.py": "e917616911d11b00ec60d9ff129a7db804184051a24b8aa23d8809c8c2195cb2", + "tests/test_transactional.py": "7b443defb15b25427257045def348af0d7e5819c27659a52b7a53d78599301e6" + }, + "contract_sha256": "7bcd65c29e572c0abbd3784e7ad1e8341f963aa12345c901f7946eac4e999c1d", + "acceptance_complete": false, + "remaining_acceptance": [ + "CSOC-UC-009 isolated issuer reset, inbox and app browser journey", + "CSOC-UC-010/011 isolated registration and invitation browser journeys", + "Independent caller authentication, tenant/template scope, limits and scoped status", + "Platform sender and independent ledger backup custody admission", + "Named inbound/reply/bounce operations, alignment and retention", + "Per-flow productive user acceptance" + ] +} diff --git a/docs/release/sharing-portability-review-2026-10-08.md b/docs/release/sharing-portability-review-2026-10-08.md new file mode 100644 index 0000000..e8ddca3 --- /dev/null +++ b/docs/release/sharing-portability-review-2026-10-08.md @@ -0,0 +1,118 @@ +# WP-0008 / WP-0010 implementation review — 2026-10-08 + +This is a local source review. The current production release remains revision15; +these changes have not been packaged or deployed. Both workplans remain active. +Technical implementation decision: `9ffb5125-5551-4101-9025-5b6f72f40940`. + +## Product behavior now implemented + +Space owners can open **Share space**, enter an existing tenant member's ID and +grant Viewer, Member or Owner access, or remove a grant. The member finds their +ID under **Your app data**. No directory/email enumeration or notification send +is involved. Viewers can read/copy content; members can write; only owners manage +access. Creator access and the last owner cannot be revoked through this flow. +Revocation denies subsequent page/image reads immediately. + +**Your app data** exports the requesting person's local profile, platform identity +references, memberships and created-space metadata as a private JSON download. +It excludes credentials, session tokens and other people's profiles. It does not +export platform-owned account records or claim that shared pages belong to one +person. Shared content is retained when a member leaves. + +The exit screen explains retained content/backups and lists ownership obligations, +including archived spaces. Removal requires another active same-tenant owner for +every retained space and typing DELETE. It clears creator attribution, deletes +the app-local Member/User and their grants, and signs out; old sessions can no +longer authenticate. NetKingdom/user-engine/mailbox identity remains separate. +Signing in again creates a new local profile without the old grants. Corrupt +cross-tenant creator links require operator review without disclosing foreign +space titles. No live profile was removed while testing. + +## Storage, revision and link behavior + +The existing slug-keyed content layout is retained. App reads, lists and service +writes fail closed on any duplicate slug, including inactive/foreign index rows. +App creation/import and admin metadata saves serialize through the same mounted +content writer lock; admin validation rejects global slug collisions and +cross-tenant creator/grant links. Ordinary sharing cannot create a cross-tenant +grant. Raw database/filesystem maintenance remains a paused-writer operation; +this increment does not introduce a database-wide global slug constraint or +tenant-directory migration. Production tenancy/cohort reconciliation stays T04. + +Cooperating threads/processes use a reentrant Python lock and Linux `flock` on +`CONTENT_ROOT/.writer.lock`. App/import filesystem writes are serialized. Local +account removal also takes that lock so new owned spaces cannot race its exit +check. The existing one-instance persistent volume and paused-writer backup +protocol remain required; this is not a distributed multi-node writer protocol. + +Page/space edit forms carry an exact byte-hash revision. A stale or missing +revision preserves the submitted text and asks the writer to reopen/merge instead +of overwriting another save. New-page/copy destination collisions also remain +protected under the lock. Writes stage complete files, fsync, atomically replace +the active file and fsync the directory. Browser CRLF is normalized to LF. +Original imported metadata, including source identifiers, survives page rename +and space-index edits. + +Exact previous/new Markdown versions live at +`CONTENT_ROOT/.revisions///.md`, including before deletion. +They are private content-plane recovery material covered by the paired backup; +they are not pushed to Forgejo. Retention/capacity admission and acceptance of +this revision mechanism remain T02/T05 obligations. No automatic purge exists. +An existing archived tree is retained rather than overwritten by a later archive. + +Copy/move stages local Visual and Markdown inline/reference-linked attachments, +rewrites their destination URLs, distinguishes equal nested filenames and +preserves query/fragment suffixes. Destination collisions, missing linked assets, +traversal and symlinks refuse the operation; partial newly created assets are +removed when publication fails. The source remains on failed destination writes. +Uncertain completed writes preserve the destination for review. Product errors +do not disclose private filesystem diagnostics. Raw HTML and arbitrary custom +Markdown conventions still require corpus reconciliation. + +Rename/move retains `.page-aliases.json` in the original space. Old incoming page +addresses resolve to the current page only after checking active exact grants at +every hop, including both source and destination spaces. Old addresses cannot be +reused for unrelated content. Denied destinations, loops and missing targets +fail closed. Content replacement/import refuses retained aliases until their +disposition is explicitly reconciled. Multi-file moves are not crash-atomic; +paired recovery and attended maintenance remain the recovery protocol. + +## Communication regression implementation + +UC-009–012 are registered with synthetic fixtures and `established: false`. +UC-009 preserves its recorded personal recovery acceptance; the other cases +remain candidates. Empty browser bindings are explicit gaps rather than claimed +end-to-end protection. UC-008's existing mandatory tests remain registered. + +`make check-communication EMAIL_CONNECT_ROOT=/path/to/email-connect` copies only +owner Python source and its synthetic transactional test module to a private +temporary directory. It strips inherited credentials, denies network connects +and DNS, disables plugin autoload, and verifies 21 exact required test functions +without skip/xfail. The original owner checkout remains untouched. A redacted +receipt hashes the actual executed source/test/contract and reports results plus +outstanding owner/user acceptance. No production account, provider or mailbox is +used. See `communication-regressions-2026-10-08.json`. + +## Validation and remaining live gates + +`make check`: **182 passed**, repository-wide Ruff clean, both real Chromium +journeys run. New cases cover sharing/revocation, personal export, CSRF, last-owner +and archived-content retention, old-session denial, ambiguous storage, concurrent +threads/processes, stale forms, blank imported pages, symlinks, linked attachments, +incoming-address access and failed destination publication. Communication runner +tests prove environment isolation, network denial and refused missing/disabled +coverage. Owner gate: **39 passed, zero skips**. Django system check passes; +`makemigrations --check --dry-run` finds no schema changes. + +WP-0008-T01/T02/T03/T04/T05/T06/T08/T09 remain open for the relevant pinned release, +deployment/operational capacity, offsite custody/retention and recovery admission, +actual corpus/tenancy/privacy reconciliation, service contract and productive +per-journey user acceptance. Existing RPF-WP-0053 admission/activation handoffs +remain the live backup dependency; no new offsite capture or timer is activated. + +WP-0010-T04/T05/T06 remain open for independent caller authentication, approved +tenant/template limits and scoped status, platform sender custody, ledger backup +admission (EMAIL-WP-0007-T03/T04 and RPF-WP-0053-T05), named reply/bounce ownership, +inbound access/alignment/retention and isolated owner-spanning browser journeys. +No external owner admission, new caller, email send, mailbox scan, runtime change, +productive transfer acceptance or Bubble retirement is inferred from these tests. diff --git a/docs/usecases/catalog.md b/docs/usecases/catalog.md index 203bcc9..1470ee4 100644 --- a/docs/usecases/catalog.md +++ b/docs/usecases/catalog.md @@ -36,11 +36,11 @@ acceptance. Do not mark transfer accepted on the strength of tests alone. |---|---|---|---|---| | CSOC-UC-001 | Returning/new member signs in and reaches their work | Login/signup strings and historical identity evidence; original transfer journey inferred | OIDC + registration entry implemented; current cohort onboarding and live Case B need review | WP-0008-T01/T03; pending | | CSOC-UC-002 | Writer finds an existing space and opens a page to continue work | `vw_spaces`, `vw_pages`, space/page fields; inferred; one historical import rehearsal | Space/page navigation and markdown implemented; nominated real content, visuals, missing hierarchy and recognizability need review | T03/T05; pending | -| CSOC-UC-003 | Writer creates and maintains a space/page with Title, Abstractor, Visual and body | Space/page create/edit symbols and captured card UI; inferred | Metadata/body forms implemented; Visual serving, safe content versioning and restart persistence outstanding | T01/T04/T05; pending | -| CSOC-UC-004 | Writer makes an independent copy, or moves a page to another workspace | Copy icons captured; exact Bubble transfer action unresolved | Copy/move implemented; arbitrary assets/links and actual original semantics need comparison | T04/T05; pending | -| CSOC-UC-005 | Reader sees shared work; outsider cannot access it; writer remains able to edit | Members/Guests/IsPrivate fields; original privacy rules inferred | First slice enforces viewer write denial and explicit membership/creator reads; membership management and exact Bubble privacy still outstanding | T04; pending | -| CSOC-UC-006 | Member can take their app-local information with them or leave with a clear content outcome | Platform-contract portability need; Bubble equivalent **unknown** | Export/delete not implemented; ownership/retention UX requires definition | T04; pending | -| CSOC-UC-007 | Member returns after a restart/incident and their work is still there | Continuity requirement, not a captured Bubble action | Persistent volume and paired DB/content restore evidence outstanding | T01/T02; pending | +| CSOC-UC-003 | Writer creates and maintains a space/page with Title, Abstractor, Visual and body | Space/page create/edit symbols and captured card UI; inferred | Visuals are live; local revisions and stale-edit protection implemented; new release and revision/retention acceptance pending | T01/T04/T05; pending | +| CSOC-UC-004 | Writer makes an independent copy, or moves a page to another workspace | Copy icons captured; exact Bubble transfer action unresolved | Local linked-attachment copy and guarded incoming-address preservation implemented; actual corpus/custom conventions and original semantics need comparison | T04/T05; pending | +| CSOC-UC-005 | Reader sees shared work; outsider cannot access it; writer remains able to edit | Members/Guests/IsPrivate fields; original privacy rules inferred | Owner-managed same-tenant sharing/revocation, viewer denial and ambiguous-storage guards implemented locally; release, tenancy and original privacy review remain | T04; pending | +| CSOC-UC-006 | Member can take their app-local information with them or leave with a clear content outcome | Platform-contract portability need; Bubble equivalent **unknown** | Local personal JSON export and reviewed exit screen implemented; shared content retained, successor owner required; deployment and productive user acceptance pending | T04; pending | +| CSOC-UC-007 | Member returns after a restart/incident and their work is still there | Continuity requirement, not a captured Bubble action | Persistent storage and actual paired restore are evidenced; offsite custody/retention, monitoring and operational rollback remain | T01/T02; pending | | CSOC-UC-008 | Prepare the next ReichelAg founder call from the full previous protocol, write the new notes, copy them for manual email distribution | User-confirmed 2026-10-06; exact Bubble UI not freshly observed | Duplicate/edit implemented; clipboard action added; latest real protocol, mail-client paste and persistence review pending | T03/T05/T08/T09/T10; pending | ### Concrete scenario specification for the first review @@ -64,7 +64,8 @@ edit the copy and confirm the source is independent; move another page and verify source removal, destination content and access. For UC-005: reader can open the shared page, sees read-only access and cannot change it by direct requests; outsider gets no content; destination write -permission is checked before copy/move. Exact sharing UI remains part of T04. +permission is checked before copy/move. Review the locally implemented Share +space UI after its release; original privacy equivalence remains T04. For UC-006: compare the app-local export and proposed leaving behavior before confirming deletion; platform account handling is explicitly separate. For UC-007: compare the pre-incident content and access with an isolated restore. diff --git a/docs/usecases/communication-regressions.json b/docs/usecases/communication-regressions.json new file mode 100644 index 0000000..9f8afd4 --- /dev/null +++ b/docs/usecases/communication-regressions.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "owner": "email-connect", + "test_file": "tests/test_transactional.py", + "required_tests": [ + "test_rejects_auth_recipient_template_and_key", + "test_sends_fixed_template_once", + "test_resend_is_separate_event_and_sends_again", + "test_suppression_blocks_send", + "test_provider_timeout_temporary_and_permanent_are_redacted", + "test_status_authenticates_without_sending_and_redacts_errors", + "test_registration_verification_is_digest_only_and_single_use", + "test_registration_cancellation_is_single_use_and_prevents_verification", + "test_mailbox_evidence_is_not_authorization", + "test_lost_post_send_record_never_resends_after_restart", + "test_definitive_failure_retries_but_changed_payload_is_rejected", + "test_disconnect_after_smtp_acceptance_never_releases_reservation", + "test_verification_retry_after_restart_returns_same_receipt", + "test_verification_binding_cannot_change_on_retry", + "test_verification_uncertain_send_is_not_repeated_after_restart", + "test_verification_acceptance_record_failure_does_not_resend", + "test_verification_definitive_deferral_allows_retry_and_cancels_old_handle", + "test_verification_permanent_failure_stays_terminal", + "test_verification_concurrent_connections_reserve_before_provider_call", + "test_verification_tenant_and_client_operations_are_separate", + "test_legacy_verification_is_preserved_without_resending" + ], + "remaining_acceptance": [ + "CSOC-UC-009 isolated issuer reset, inbox and app browser journey", + "CSOC-UC-010/011 isolated registration and invitation browser journeys", + "Independent caller authentication, tenant/template scope, limits and scoped status", + "Platform sender and independent ledger backup custody admission", + "Named inbound/reply/bounce operations, alignment and retention", + "Per-flow productive user acceptance" + ] +} diff --git a/docs/usecases/first-transfer-review.md b/docs/usecases/first-transfer-review.md index 234ddff..5777bf6 100644 --- a/docs/usecases/first-transfer-review.md +++ b/docs/usecases/first-transfer-review.md @@ -32,7 +32,10 @@ accept migration or a live release. Private page covers are visible per founder review. Missing Spaces-list preview is corrected in revision15; live owner preview/hash preservation and actual production-pair isolated recovery pass. List-preview review is pending. -- Writer, reader and outsider access; sharing and portability UX: pending T04. +- Writer, reader and outsider access; owner sharing/revocation, personal export + and retained-content exit UI are implemented locally and browser-tested. + Release and productive user review remain T04; see + [implementation review](../release/sharing-portability-review-2026-10-08.md). - Content restart durability and paired real-content isolated restore verified. Durable backup custody/retention and operational rollback remain T02. diff --git a/docs/usecases/platform-communication.md b/docs/usecases/platform-communication.md index 43e3155..44a830b 100644 --- a/docs/usecases/platform-communication.md +++ b/docs/usecases/platform-communication.md @@ -5,10 +5,11 @@ SMTP; application invitation/verification delivery uses the existing email-connect service. ReichelAg protocol distribution remains UC-008's manual clipboard/email workflow. -These stable IDs extend the discovery catalogue. They are not yet entries in -the executable product registry: owner-spanning browser coverage is still -required under T06. Personal recovery has user acceptance; the gap in its -automated end-to-end protection remains explicit. +These stable IDs are registered in `registry.json` with synthetic fixture +references, app-side fast checks and `established: false`. Owner-spanning browser +coverage is still required under T06. Personal recovery retains its recorded +user acceptance; it is not marked as an established automated journey until +its full fast/browser contract can be protected. UC-010–012 remain candidates. | ID | Actor, goal and steps | Expected outcome / evidence | Status and owner | | --- | --- | --- | --- | @@ -36,11 +37,11 @@ live sends are part of the gate. | Registration handoff | `tests/test_shell.py::test_registration_link_uses_only_configured_destination` | UC-010 browser request/verify/cancel; preserve tenant/client binding through owner flow | | Invitation auth/template/recipient/key/suppression | email-connect `tests/test_transactional.py`: `test_rejects_auth_recipient_template_and_key`, `test_suppression_blocks_send` | Separate caller identities, caller/template/tenant scope, request limits; no authority from payload `source` | | Invitation retry / uncertain outcome | Owner tests `test_sends_fixed_template_once`, `test_resend_is_separate_event_and_sends_again`, `test_lost_post_send_record_never_resends_after_restart`, `test_definitive_failure_retries_but_changed_payload_is_rejected`, `test_disconnect_after_smtp_acceptance_never_releases_reservation` | Per-caller event namespace, bounded retry schedule and operator handling of unknown outcomes | -| Verification privacy and replay | Owner tests `test_registration_verification_is_digest_only_and_single_use`, `test_registration_cancellation_is_single_use_and_prevents_verification`, `test_mailbox_evidence_is_not_authorization` | Retry-safe challenge creation/submission; expired-handle and binding denials through browser; failure/redaction across every verification path | +| Verification privacy and replay | Owner tests `test_registration_verification_is_digest_only_and_single_use`, `test_registration_cancellation_is_single_use_and_prevents_verification`, `test_mailbox_evidence_is_not_authorization`, plus retry/restart/concurrency functions bound below | Expired-handle and binding denials through an isolated owner-spanning browser journey | | Provider failure / diagnostic redaction | Owner tests `test_provider_timeout_temporary_and_permanent_are_redacted`, `test_status_authenticates_without_sending_and_redacts_errors` | No tokens, recipient/body or provider details in diagnostic projections; caller-scoped status; provider acceptance and inbox receipt shown separately | | Reply/bounce operations | Offline scanner fixtures exist in email-connect | Named monitored inbox, authorized inbound access, correlated bounce → suppression, bounded retention and support escalation | -Fresh owner baseline: 27 transactional tests pass at source +Historical owner baseline (2026-10-07): 27 transactional tests pass at source `73702b7e1a1671948b0545ef32d6e0de9cca9c65`, clean checkout. This is local source evidence, not a deployed version or fresh inbox receipt. Reproduction from this repository's installed Python environment: @@ -52,7 +53,28 @@ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/home/worsch/email-connect/src \ --basetemp=/tmp/csoc-email-owner-tests ``` -Before registering these as protected executable journeys, bind exact fast and -browser node IDs in their owning repositories, arrange the isolated cross-owner -harness, and link owner regression receipts here. `make check` currently -protects UC-008; it does not execute the issuer/email-connect suites. +## Reproducible owner regression gate — 2026-10-08 + +Run `make check-communication EMAIL_CONNECT_ROOT=/path/to/email-connect`. +`communication-regressions.json` binds 21 required test functions covering +authorization/template/recipient denial, suppression, provider failure and +redaction, invitation uncertainty and verification restart/concurrency safety. +The runner copies only Python source and that synthetic test module to a private +temporary tree. It denies socket connects and DNS, strips inherited credentials, +disables plugin autoload, and refuses removed or skipped/xfail contract coverage. +It leaves the owner checkout untouched. Gate tests in `make check` prove those +failure boundaries. No mailbox, SMTP, issuer or live account is contacted. + +The redacted receipt at `var/test-results/communication.json` binds exact +executed source/test/contract hashes and records counts, missing tests and +outstanding acceptance. The current owner check passes **39 tests, zero skips**. +This closes the stale verification-retry baseline above: accepted and uncertain +outcomes now retain their reservations across retries/restarts; definitive +retryable refusals cancel the failed challenge before retry. + +Before marking UC-009–012 as established protected journeys, bind the missing +browser node IDs in their owners, arrange the isolated issuer/mailbox/application +harness, and record owner admission. `make check` protects UC-008 and tests the +new app UI; `check-communication` is a separate explicitly selected owner gate. +Neither check closes independent caller/sender/limits, ledger backup admission, +inbox ownership, DNS alignment or productive per-flow user acceptance. diff --git a/docs/usecases/registry.json b/docs/usecases/registry.json index 98b7b87..ed06615 100644 --- a/docs/usecases/registry.json +++ b/docs/usecases/registry.json @@ -35,6 +35,93 @@ "Paste into the user's usual mail client and review presentation", "Confirm live persistence and measured paired restore before transfer" ] + }, + { + "id": "CSOC-UC-009", + "title": "Personal account recovery", + "established": false, + "confirmation": "user-confirmed", + "transfer_status": "accepted", + "narrative": "docs/usecases/platform-communication.md", + "fixture": "tests/usecases/fixtures/platform-communication-009.json", + "contract": [ + "Request a new password reset, receive it privately and sign in to the workspace", + "Identity credentials and reset handles remain outside the application" + ], + "tests": { + "fast": [ + "tests/test_identity_regressions.py::test_consumed_callback_state_cannot_be_replayed", + "tests/test_identity_regressions.py::test_same_email_different_identity_cannot_take_over_member" + ], + "browser": [] + }, + "owner_regression_contract": "docs/usecases/communication-regressions.json", + "manual_review": [ + "Isolated owner-spanning browser coverage and admission remain required" + ], + "acceptance_ref": "history/20261006T185411Z-personal-workspace-acceptance.md" + }, + { + "id": "CSOC-UC-010", + "title": "Registration verification", + "established": false, + "confirmation": "inferred", + "transfer_status": "pending-user-review", + "narrative": "docs/usecases/platform-communication.md", + "fixture": "tests/usecases/fixtures/platform-communication-010.json", + "contract": [ + "Single-use tenant/client-bound verification is retry-safe and conveys no authorization" + ], + "tests": { + "fast": [ + "tests/test_shell.py::test_registration_link_uses_only_configured_destination" + ], + "browser": [] + }, + "owner_regression_contract": "docs/usecases/communication-regressions.json", + "manual_review": [ + "Isolated owner-spanning browser coverage and admission remain required" + ] + }, + { + "id": "CSOC-UC-011", + "title": "Member invitation", + "established": false, + "confirmation": "inferred", + "transfer_status": "pending-user-review", + "narrative": "docs/usecases/platform-communication.md", + "fixture": "tests/usecases/fixtures/platform-communication-011.json", + "contract": [ + "Fixed invitation template, one provider submission per event, suppression and safe retries" + ], + "tests": { + "fast": [], + "browser": [] + }, + "owner_regression_contract": "docs/usecases/communication-regressions.json", + "manual_review": [ + "Isolated owner-spanning browser coverage and admission remain required" + ] + }, + { + "id": "CSOC-UC-012", + "title": "Application notification and reply", + "established": false, + "confirmation": "inferred", + "transfer_status": "deferred", + "narrative": "docs/usecases/platform-communication.md", + "fixture": "tests/usecases/fixtures/platform-communication-012.json", + "contract": [ + "Each application notification needs an admitted caller, template, recipient purpose and monitored reply route" + ], + "tests": { + "fast": [], + "browser": [] + }, + "owner_regression_contract": "docs/usecases/communication-regressions.json", + "manual_review": [ + "Isolated owner-spanning browser coverage and admission remain required" + ] } ] } diff --git a/scripts/check-communication.py b/scripts/check-communication.py new file mode 100644 index 0000000..bb96687 --- /dev/null +++ b/scripts/check-communication.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Run exact owner retry/privacy regressions on a private, network-denied snapshot. + +Only source and one synthetic test module are copied. The owner's configuration, +environment, caches and runtime databases are neither read nor mutated. +""" + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "docs/usecases/communication-regressions.json" +GUARD = ''' +import socket +import pytest + +def forbidden(*args, **kwargs): + raise RuntimeError("Communication regressions forbid all network access") + +def pytest_sessionstart(session): + socket.socket.connect = forbidden + socket.socket.connect_ex = forbidden + socket.getaddrinfo = forbidden + socket.create_connection = forbidden + +def pytest_collection_modifyitems(items): + for item in items: + if any(item.get_closest_marker(m) for m in ("skip", "skipif", "xfail")): + raise pytest.UsageError("Communication contracts cannot skip or xfail") +''' + + +def check(owner, receipt): + contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + if contract["schema_version"] != 1: + raise ValueError("Unsupported communication contract") + owner = owner.resolve(strict=True) + source = owner / "src/email_connect" + test = owner / contract["test_file"] + if not source.is_dir() or not test.is_file() or source.is_symlink() or test.is_symlink(): + raise ValueError("Owner source and synthetic test module must be regular local files") + files = sorted(source.rglob("*.py")) + [test] + if any(p.is_symlink() for p in files): + raise ValueError("Owner Python sources must not be symlinks") + # Snapshot exact bytes first. Hash receipts bind the code actually executed, + # including an uncommitted source change, rather than assuming a HEAD pin. + hashes = {} + with tempfile.TemporaryDirectory(prefix="csoc-communication-") as private: + workspace = Path(private) + for path in files: + relative = path.relative_to(owner) + data = path.read_bytes() + hashes[str(relative)] = hashlib.sha256(data).hexdigest() + target = workspace / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + (workspace / "conftest.py").write_text(GUARD, encoding="utf-8") + (workspace / "pytest.ini").write_text("[pytest]\n", encoding="utf-8") + report = workspace / "result.xml" + result = subprocess.run( + [sys.executable, "-m", "pytest", "-q", "-c", str(workspace / "pytest.ini"), + str(workspace / contract["test_file"]), "-p", "no:cacheprovider", + "--junitxml", str(report), "--basetemp", str(workspace / "test-data")], + cwd=workspace, capture_output=True, timeout=120, + env={"PATH": os.defpath, "PYTHONPATH": str(workspace / "src"), + "PYTHONDONTWRITEBYTECODE": "1", "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"}, + ) + cases = ET.parse(report).findall(".//testcase") if report.is_file() else [] + covered = {case.attrib["name"].split("[", 1)[0] for case in cases} + missing = sorted(set(contract["required_tests"]) - covered) + disabled = sum(len(case.findall("skipped")) for case in cases) + failed = sum(len(case.findall("failure")) + len(case.findall("error")) for case in cases) + passed = result.returncode == 0 and bool(cases) and not (missing or disabled or failed) + evidence = { + "schema_version": 1, "owner": contract["owner"], "passed": passed, + "network": "all socket connects and DNS resolution denied", + "tests": len(cases), "skipped": disabled, "failed": failed, + "missing_required_tests": missing, "source_sha256": hashes, + "contract_sha256": hashlib.sha256(CONTRACT.read_bytes()).hexdigest(), + "acceptance_complete": False, + "remaining_acceptance": contract["remaining_acceptance"], + } + # Do not publish raw pytest output: reports contain no message bodies, + # handles, bearer values or provider diagnostics, including on failures. + receipt.parent.mkdir(parents=True, exist_ok=True) + receipt.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + return evidence + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--owner", type=Path, required=True) + parser.add_argument("--receipt", type=Path, + default=ROOT / "var/test-results/communication.json") + args = parser.parse_args() + try: + evidence = check(args.owner, args.receipt) + except (OSError, ValueError, KeyError, subprocess.TimeoutExpired, ET.ParseError): + print("Communication check could not produce a valid isolated receipt", file=sys.stderr) + return 1 + print(json.dumps({key: evidence[key] for key in + ("passed", "tests", "skipped", "failed", "missing_required_tests")})) + return 0 if evidence["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-sharing-browser.mjs b/scripts/test-sharing-browser.mjs new file mode 100644 index 0000000..c59b290 --- /dev/null +++ b/scripts/test-sharing-browser.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { chromium } from 'playwright'; + +const base = new URL(process.argv[2]); +if (base.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(base.hostname)) { + throw new Error('Sharing checks require an isolated loopback server'); +} +const browser = await chromium.launch({headless: true}); +try { + const context = await browser.newContext(); + await context.route('**/*', route => new URL(route.request().url()).origin === base.origin + ? route.continue() : route.abort()); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + async function login(subject) { + await page.goto(`${base.origin}/auth/dev-login/`); + await page.getByLabel('Subject').fill(subject); + await page.getByLabel('Tenant').fill('tenant:sharing-test'); + await page.getByRole('button', {name: 'Establish session'}).click(); + await page.waitForURL('**/app/'); + } + const detail = `${base.origin}/app/spaces/shared-browser/`; + await login('sharing-reader'); + await page.goto(`${base.origin}/account/data/`); + const readerId = await page.locator('main code').first().innerText(); + assert.match(readerId, /^[0-9a-f-]{36}$/); + await login('sharing-owner'); + await page.goto(detail); + await page.getByRole('link', {name: 'Share space', exact: true}).click(); + await page.getByLabel('Member ID').fill(readerId); + await page.getByLabel('Access', {exact: true}).selectOption('viewer'); + await page.getByRole('button', {name: 'Update access'}).click(); + await page.waitForURL('**/sharing/'); + assert.match(await page.locator('main').innerText(), /Space access updated/); + await login('sharing-reader'); + await page.goto(`${detail}?page=notes`); + assert.match(await page.locator('#page-content').innerText(), /Original notes/); + assert.equal(await page.getByRole('link', {name: 'Share space', exact: true}).count(), 0); + assert.equal(await page.getByRole('link', {name: 'Edit page', exact: true}).count(), 0); + await login('sharing-owner'); + await page.goto(`${detail}pages/notes/edit/`); + const otherTab = await context.newPage(); + await otherTab.goto(`${detail}pages/notes/edit/`); + await page.getByLabel('Body (markdown)').fill('First saved notes'); + await page.getByRole('button', {name: 'Save page'}).click(); + await page.waitForURL('**?page=notes'); + await otherTab.getByLabel('Body (markdown)').fill('Unsaved second-tab notes'); + await otherTab.getByRole('button', {name: 'Save page'}).click(); + assert.match(await otherTab.locator('main').innerText(), /Reopen it and merge/); + assert.equal(await otherTab.getByLabel('Body (markdown)').inputValue(), 'Unsaved second-tab notes'); + await otherTab.close(); + await page.goto(`${detail}sharing/`); + await page.getByLabel('Member ID').fill(readerId); + await page.getByLabel('Access', {exact: true}).selectOption('owner'); + await page.getByRole('button', {name: 'Update access'}).click(); + await page.waitForURL('**/sharing/'); + await page.goto(`${base.origin}/account/data/`); + await page.getByLabel('Type DELETE to remove your local account').fill('DELETE'); + await page.getByRole('button', {name: 'Remove local account'}).click(); + await page.waitForURL(base.origin + '/'); + assert.equal(await page.locator('header').getByRole('link', {name: 'Sign in', exact: true}).count(), 1); + await login('sharing-reader'); + await page.goto(`${detail}?page=notes`); + assert.match(await page.locator('#page-content').innerText(), /First saved notes/); + await page.goto(`${base.origin}/account/data/`); + assert.match(await page.locator('main').innerText(), /Before removal/); + assert.equal(await page.getByRole('button', {name: 'Remove local account'}).count(), 0); + console.log('Sharing, concurrent edits and retained-content exit passed'); +} finally { + await browser.close(); +} diff --git a/tests/test_communication_gate.py b/tests/test_communication_gate.py new file mode 100644 index 0000000..ad47813 --- /dev/null +++ b/tests/test_communication_gate.py @@ -0,0 +1,57 @@ +"""Prove the cross-owner gate cannot send, hide skips or leak inherited credentials.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def gate(tmp_path, monkeypatch): + script = Path(__file__).resolve().parents[1] / "scripts/check-communication.py" + spec = importlib.util.spec_from_file_location("communication_gate", script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + contract = tmp_path / "contract.json" + contract.write_text(json.dumps({ + "schema_version": 1, "owner": "synthetic-owner", + "test_file": "tests/test_contract.py", "required_tests": ["test_contract"], + "remaining_acceptance": ["User review remains separate"], + })) + monkeypatch.setattr(module, "CONTRACT", contract) + owner = tmp_path / "owner" + (owner / "src/email_connect").mkdir(parents=True) + (owner / "src/email_connect/__init__.py").write_text("") + (owner / "tests").mkdir() + return module, owner, tmp_path / "receipt.json" + + +def test_communication_gate_scrubs_credentials_and_does_not_modify_owner(gate, monkeypatch): + module, owner, receipt = gate + monkeypatch.setenv("SMTP_PASSWORD", "synthetic-inherited-secret") + test = owner / "tests/test_contract.py" + test.write_text( + "import os\ndef test_contract():\n assert 'SMTP_PASSWORD' not in os.environ\n" + ) + before = sorted(p.relative_to(owner) for p in owner.rglob("*")) + evidence = module.check(owner, receipt) + assert evidence["passed"] and evidence["tests"] == 1 + assert evidence["acceptance_complete"] is False + assert "synthetic-inherited-secret" not in receipt.read_text() + assert sorted(p.relative_to(owner) for p in owner.rglob("*")) == before + + +@pytest.mark.parametrize("body", [ + ("import socket\ndef test_contract():\n" + " socket.create_connection(('smtp.example.test', 25))\n"), + "import pytest\n@pytest.mark.skip\ndef test_contract():\n pass\n", + "def test_renamed():\n pass\n", +]) +def test_communication_gate_refuses_network_disabled_or_missing_contract(gate, body): + module, owner, receipt = gate + (owner / "tests/test_contract.py").write_text(body) + evidence = module.check(owner, receipt) + assert evidence["passed"] is False + assert "smtp.example.test" not in receipt.read_text() + assert "RuntimeError" not in receipt.read_text() diff --git a/tests/test_content_revisions.py b/tests/test_content_revisions.py new file mode 100644 index 0000000..828ee4c --- /dev/null +++ b/tests/test_content_revisions.py @@ -0,0 +1,285 @@ +"""Conflict/failure and attachment fidelity checks with synthetic local content.""" + +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest +from django.urls import reverse + +from coulomb_social.apps.spaces import pageops +from coulomb_social.apps.spaces.services import create_space, save_space_page +from tests.test_space_permissions import make_member + + +@pytest.fixture +def trees(tmp_path, settings): + settings.CONTENT_ROOT = str(tmp_path / "content") + for slug in ("source", "destination"): + pageops.init_space_content(slug, title=slug) + return tmp_path / "content" + + +def test_simultaneous_edits_have_one_winner_and_durable_old_and_new_versions(trees): + before = pageops.write_page("source", "notes", title="Notes", body="Original") + original = Path(before.path).read_bytes() + + def save(body): + try: + pageops.write_page("source", "notes", title="Notes", body=body, + expected_revision=before.revision) + return "saved" + except pageops.PageOpsError: + return "conflict" + + with ThreadPoolExecutor(max_workers=2) as pool: + assert sorted(pool.map(save, ("First edit", "Second edit"))) == ["conflict", "saved"] + versions = [p.read_bytes() for p in (trees / ".revisions/source/notes").glob("*.md")] + assert original in versions + assert Path(before.path).read_bytes() in versions + assert len(versions) == 2 + pageops.delete_page("source", "notes") + assert not Path(before.path).exists() + assert original in versions + + +def test_failed_atomic_publish_leaves_original_readable_and_history_retained(trees, monkeypatch): + doc = pageops.write_page("source", "notes", title="Notes", body="Original") + original = Path(doc.path).read_bytes() + replace = pageops.os.replace + + def fail_page(source, destination): + if Path(destination) == Path(doc.path): + raise OSError("synthetic disk failure") + return replace(source, destination) + + monkeypatch.setattr(pageops.os, "replace", fail_page) + with pytest.raises(OSError): + pageops.write_page("source", "notes", title="Notes", body="New") + assert Path(doc.path).read_bytes() == original + assert sorted(p.name for p in Path(doc.path).parent.iterdir()) == ["index.md", "notes.md"] + + +def test_empty_imported_page_has_a_real_revision_and_can_be_edited(trees): + pageops.page_file("source", "empty").write_bytes(b"") + source = pageops.read_page("source", "empty") + assert source.exists and source.revision + pageops.write_page("source", "empty", body="New notes", expected_revision=source.revision) + assert pageops.read_page("source", "empty").body == "New notes\n" + assert any(p.read_bytes() == b"" for p in (trees / ".revisions/source/empty").glob("*.md")) + + +@pytest.mark.parametrize("operation", ["copy", "move"]) +def test_body_assets_reference_links_visual_and_nested_equal_names_survive(trees, operation): + assets = pageops.assets_dir("source") + (assets / "nested").mkdir() + (assets / "diagram.png").write_bytes(b"synthetic image one") + (assets / "nested/diagram.png").write_bytes(b"synthetic image two") + body = ('![Diagram](../assets/diagram.png)\n' + '![Nested][picture]\n\n[picture]: assets/nested/diagram.png\n' + '[File](assets/diagram.png#detail)\n') + source = pageops.write_page("source", "notes", title="Notes", body=body, + visual="assets/diagram.png") + original = Path(source.path).read_bytes() + if operation == "copy": + doc = pageops.copy_page("source", "notes", "destination", "new-notes") + assert Path(source.path).read_bytes() == original + else: + doc = pageops.transfer_page("source", "notes", "destination", "new-notes") + assert not Path(source.path).exists() + assert doc.visual == "assets/new-notes-diagram.png" + assert "![Diagram](assets/new-notes-diagram.png)" in doc.body + assert "[File](assets/new-notes-diagram.png#detail)" in doc.body + assert "assets/nested/" not in doc.body + assert sorted(p.read_bytes() for p in pageops.assets_dir("destination").iterdir()) == [ + b"synthetic image one", b"synthetic image two", + ] + + +def test_attachment_failure_rolls_back_partial_copy_and_does_not_move_source(trees): + (pageops.assets_dir("source") / "present.png").write_bytes(b"synthetic image") + source = pageops.write_page("source", "notes", title="Notes", + body="![One](assets/present.png)\n![Two](assets/missing.png)") + before = Path(source.path).read_bytes() + with pytest.raises(pageops.PageOpsError, match="attachment safely"): + pageops.transfer_page("source", "notes", "destination", "notes") + assert Path(source.path).read_bytes() == before + assert list(pageops.assets_dir("destination").iterdir()) == [] + assert not pageops.read_page("destination", "notes").exists + + +@pytest.mark.django_db +def test_stale_rename_and_missing_revision_preserve_user_input_and_current_page(trees, client): + owner = make_member("owner") + space = create_space(owner, title="Conflict", slug="conflict") + before = pageops.write_page(space.slug, "notes", title="Notes", body="Original") + pageops.write_page(space.slug, "notes", title="Notes", body="Other writer") + client.force_login(owner.user) + url = reverse("spaces:page_edit", args=[space.slug, "notes"]) + for revision in (None, before.revision): + fields = {"title": "Mine", "slug": "renamed", "body": "My unsaved notes"} + if revision is not None: + fields["revision"] = revision + response = client.post(url, fields) + assert response.status_code == 200 + assert b"Reopen it and merge" in response.content + assert b"My unsaved notes" in response.content + assert pageops.read_page(space.slug, "notes").body == "Other writer\n" + assert not pageops.read_page(space.slug, "renamed").exists + current = pageops.read_page(space.slug, "notes") + save_space_page(owner, space, "notes", "renamed", expected_revision=current.revision, + title="Merged", body="Other writer plus my notes") + assert not pageops.read_page(space.slug, "notes").exists + + +@pytest.mark.django_db +def test_space_edit_conflict_does_not_overwrite_new_index_body_or_metadata(trees, client): + owner = make_member("owner") + space = create_space(owner, title="Conflict", slug="conflict") + before = pageops.read_page(space.slug, "index") + pageops.write_page(space.slug, "index", title="Conflict", body="Another writer's index", + extra_meta={"source_id": "synthetic-source"}) + client.force_login(owner.user) + fields = {"title": "My title", "abstractor": "My abstract", "revision": before.revision} + url = reverse("spaces:edit", args=[space.slug]) + response = client.post(url, fields) + assert b"space changed" in response.content and b"My title" in response.content + space.refresh_from_db() + assert space.title == "Conflict" + current = pageops.read_page(space.slug, "index") + assert current.body == "Another writer's index\n" + fields["revision"] = current.revision + assert client.post(url, fields).status_code == 302 + assert pageops.read_page(space.slug, "index").meta["source_id"] == "synthetic-source" + + +def test_page_symlink_cannot_read_or_overwrite_another_space(trees): + target = pageops.page_file("destination", "index") + pageops.page_file("source", "linked").symlink_to(target) + before = target.read_bytes() + with pytest.raises(pageops.PageOpsError, match="symlink"): + pageops.read_page("source", "linked") + with pytest.raises(pageops.PageOpsError, match="symlink"): + pageops.write_page("source", "linked", body="Tampered") + assert target.read_bytes() == before + + +@pytest.mark.django_db +def test_incoming_page_addresses_survive_rename_move_and_enforce_every_grant(trees, client): + from coulomb_social.apps.spaces.services import set_space_access, transfer_space_page + + owner, reader = make_member("owner"), make_member("reader") + source = create_space(owner, title="Shared", slug="shared") + target = create_space(owner, title="Private", slug="private") + set_space_access(owner, source, reader.pk, "viewer") + doc = pageops.write_page(source.slug, "notes", title="Notes", body="Retained content") + save_space_page(owner, source, "notes", "renamed", expected_revision=doc.revision, + title="Notes", body=doc.body) + client.force_login(reader.user) + old_url = reverse("spaces:detail", args=[source.slug]) + "?page=notes" + response = client.get(old_url, follow=True) + assert response.status_code == 200 + assert b"Retained content" in response.content + transfer_space_page(owner, source, "renamed", target, dest_slug="moved") + assert client.get(old_url).status_code == 404 + assert b"Private" not in client.get(old_url).content + set_space_access(owner, target, reader.pk, "viewer") + response = client.get(old_url, follow=True) + assert response.status_code == 200 + assert b"Retained content" in response.content + assert response.redirect_chain == [ + (reverse("spaces:detail", args=[target.slug]) + "?page=moved", 302), + ] + with pytest.raises(pageops.PageOpsError, match="reserved"): + pageops.write_page(source.slug, "notes", body="Hijack previous address") + set_space_access(owner, source, reader.pk, "remove") + assert client.get(old_url).status_code == 404 + + +def test_failed_destination_publish_rolls_back_assets_and_keeps_source(trees, monkeypatch): + assets = pageops.assets_dir("source") + (assets / "diagram.png").write_bytes(b"synthetic image") + source = pageops.write_page("source", "notes", body="![Diagram](assets/diagram.png)") + before = Path(source.path).read_bytes() + original = pageops.os.replace + + def fail_target(old, new): + if Path(new) == pageops.page_file("destination", "notes"): + raise OSError("synthetic destination failure") + return original(old, new) + + monkeypatch.setattr(pageops.os, "replace", fail_target) + with pytest.raises(OSError): + pageops.transfer_page("source", "notes", "destination", "notes") + assert Path(source.path).read_bytes() == before + assert not pageops.read_page("destination", "notes").exists + assert list(pageops.assets_dir("destination").iterdir()) == [] + assert not pageops.page_aliases("source") + + +@pytest.mark.django_db +def test_destination_disk_failure_is_visible_without_losing_source(trees, client, monkeypatch): + owner = make_member("owner") + space = create_space(owner, title="Work", slug="work") + source = pageops.write_page(space.slug, "notes", title="Notes", body="Original notes") + original = pageops.os.replace + + def fail_target(old, new): + if Path(new) == pageops.page_file(space.slug, "copy"): + raise OSError("synthetic private storage path must not be disclosed") + return original(old, new) + + monkeypatch.setattr(pageops.os, "replace", fail_target) + client.force_login(owner.user) + response = client.post(reverse("spaces:page_copy", args=[space.slug, source.slug]), { + "dest_slug": "copy", "title": "Copy", + }) + assert response.status_code == 200 + assert b"could not be confirmed" in response.content + assert b"private storage path" not in response.content + assert pageops.read_page(space.slug, "notes").body == "Original notes\n" + assert not pageops.read_page(space.slug, "copy").exists + + +def test_separate_processes_share_the_writer_lock_and_reject_stale_revision(trees): + doc = pageops.write_page("source", "notes", title="Notes", body="Original") + code = ''' +import sys +from django.conf import settings +settings.configure(CONTENT_ROOT=sys.argv[1]) +from coulomb_social.apps.spaces import pageops +if sys.argv[3] == "hold": + with pageops.content_write_lock(): + print("locked", flush=True) + sys.stdin.readline() + pageops.write_page("source", "notes", body="First process", expected_revision=sys.argv[2]) +else: + print("attempting", flush=True) + try: + pageops.write_page("source", "notes", body="Second process", expected_revision=sys.argv[2]) + except pageops.PageOpsError: + print("conflict", flush=True) +''' + children = [] + try: + for mode in ("hold", "write"): + child = subprocess.Popen( + [sys.executable, "-c", code, str(trees), doc.revision, mode], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + children.append(child) + assert child.stdout.readline().strip() == ("locked" if mode == "hold" else "attempting") + with pytest.raises(subprocess.TimeoutExpired): + children[1].communicate(timeout=0.25) + _, errors = children[0].communicate(input="release\n", timeout=10) + assert children[0].returncode == 0, errors + output, errors = children[1].communicate(timeout=10) + assert children[1].returncode == 0, errors + assert output.strip() == "conflict" + assert pageops.read_page("source", "notes").body == "First process\n" + finally: + for child in children: + if child.poll() is None: + child.terminate() + child.communicate(timeout=10) diff --git a/tests/test_member_portability.py b/tests/test_member_portability.py new file mode 100644 index 0000000..0b04bb8 --- /dev/null +++ b/tests/test_member_portability.py @@ -0,0 +1,170 @@ +"""App-local sharing/exit with synthetic identities and retained shared work.""" + +import pytest +from django.core.exceptions import ValidationError +from django.test import Client +from django.urls import reverse + +from coulomb_social.apps.members.models import Member, User +from coulomb_social.apps.members.portability import delete_local_member +from coulomb_social.apps.spaces import pageops +from coulomb_social.apps.spaces.models import Space, SpaceMembership +from coulomb_social.apps.spaces.services import create_space, set_space_access +from tests.test_space_permissions import make_member + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def people(tmp_path, settings, client): + settings.CONTENT_ROOT = str(tmp_path / "content") + owner, reader, outsider = [make_member(s) for s in ("owner", "reader", "outsider")] + space = create_space(owner, title="Retained work", slug="retained-work") + client.force_login(owner.user) + return owner, reader, outsider, space + + +def test_owner_shares_reader_then_revokes_without_email(people, client): + owner, reader, _, space = people + url = reverse("spaces:sharing", kwargs={"slug": space.slug}) + assert b"Share space" in client.get(reverse("spaces:detail", args=[space.slug])).content + assert client.post(url, {"member_id": str(reader.pk), "role": "viewer"}).status_code == 302 + client.force_login(reader.user) + assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 200 + assert client.get(url).status_code == 403 + assert client.post(url, {"member_id": str(owner.pk), "role": "remove"}).status_code == 403 + client.force_login(owner.user) + assert client.post(url, {"member_id": str(reader.pk), "role": "remove"}).status_code == 302 + client.force_login(reader.user) + assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 404 + + +@pytest.mark.parametrize("target", ["foreign", "missing", "malformed"]) +def test_sharing_rejects_foreign_missing_and_malformed_ids(people, client, target): + _, _, _, space = people + ids = {"foreign": str(make_member("foreign", tenant="tenant:b").pk), + "missing": "00000000-0000-0000-0000-000000000000", "malformed": "bad-id"} + url = reverse("spaces:sharing", args=[space.slug]) + response = client.post(url, {"member_id": ids[target], "role": "owner"}) + assert response.status_code == 200 + assert b"existing member in this tenant" in response.content + assert space.memberships.count() == 1 + + +def test_creator_and_last_owner_cannot_be_revoked(people): + owner, reader, _, space = people + with pytest.raises(pageops.PageOpsError, match="creator"): + set_space_access(owner, space, owner.pk, "remove") + set_space_access(owner, space, reader.pk, "owner") + space.created_by = None + space.save() + set_space_access(reader, space, owner.pk, "remove") + with pytest.raises(pageops.PageOpsError, match="at least one owner"): + set_space_access(reader, space, reader.pk, "viewer") + + +def test_export_is_personal_tenant_scoped_and_never_exports_secrets(people, client): + owner, reader, _, space = people + reader.email = "other-person@example.test" + reader.save() + set_space_access(owner, space, reader.pk, "viewer") + foreign = Space.objects.create(tenant_id="tenant:b", slug="foreign", title="Foreign") + SpaceMembership.objects.create(space=foreign, member=owner, role="viewer") + session = client.session + session["access_token"] = "synthetic-secret" + session.save() + response = client.get(reverse("core:account_export")) + assert response.status_code == 200 + assert response["Cache-Control"] == "private, no-store" + assert response["Content-Disposition"].startswith("attachment;") + data = response.json() + assert data["member"]["id"] == str(owner.pk) + assert [g["space_slug"] for g in data["memberships"]] == [space.slug] + for private in (b"synthetic-secret", b"other-person@example.test", b"password", b"Foreign"): + assert private not in response.content + client.force_login(reader.user) + assert client.get(reverse("core:account_export")).json()["member"]["id"] == str(reader.pk) + + +@pytest.mark.parametrize("archived", [False, True]) +def test_exit_blocks_last_owner_and_retains_content(people, client, archived): + owner, _, _, space = people + space.is_active = not archived + space.save() + before = pageops.page_file(space.slug, "index").read_bytes() + url = reverse("core:account_data") + assert b"Before removal" in client.get(url).content + assert client.post(url, {"confirmation": "DELETE"}).status_code == 200 + assert Member.objects.filter(pk=owner.pk).exists() + assert pageops.page_file(space.slug, "index").read_bytes() == before + + +def test_exit_with_successor_removes_local_grants_and_all_old_sessions(people, client): + owner, reader, _, space = people + set_space_access(owner, space, reader.pk, "owner") + other_session = Client() + other_session.force_login(owner.user) + before = pageops.page_file(space.slug, "index").read_bytes() + url = reverse("core:account_data") + assert client.post(url, {"confirmation": "wrong"}).status_code == 200 + response = client.post(url, {"confirmation": "DELETE"}) + assert response.status_code == 302 + assert not Member.objects.filter(pk=owner.pk).exists() + assert not User.objects.filter(pk=owner.user_id).exists() + space.refresh_from_db() + assert space.created_by_id is None + assert pageops.page_file(space.slug, "index").read_bytes() == before + assert other_session.get(reverse("core:app_home")).status_code == 302 + client.force_login(reader.user) + assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 200 + + +def test_inactive_successor_cannot_release_owner_responsibility(people): + owner, reader, _, space = people + set_space_access(owner, space, reader.pk, "owner") + reader.user.is_active = False + reader.user.save() + with pytest.raises(pageops.PageOpsError, match="another active member"): + delete_local_member(owner) + + +def test_account_and_sharing_posts_require_csrf(people): + owner, reader, _, space = people + guarded = Client(enforce_csrf_checks=True) + guarded.force_login(owner.user) + assert guarded.post(reverse("core:account_data"), {"confirmation": "DELETE"}).status_code == 403 + assert guarded.post(reverse("spaces:sharing", args=[space.slug]), { + "member_id": str(reader.pk), "role": "owner", + }).status_code == 403 + + +def test_admin_validation_rejects_cross_tenant_grants_and_colliding_storage(people): + _, _, _, space = people + foreign = make_member("foreign", tenant="tenant:b") + with pytest.raises(ValidationError, match="another tenant"): + SpaceMembership(space=space, member=foreign).full_clean() + with pytest.raises(ValidationError, match="already uses that slug"): + Space(tenant_id=foreign.tenant_id, slug=space.slug, title="Collision").full_clean() + + +def test_corrupt_foreign_creator_is_blocked_without_disclosing_other_tenant(people, client): + owner, _, _, _ = people + Space.objects.create(tenant_id="tenant:b", slug="foreign", title="Foreign private title", + created_by=owner) + response = client.get(reverse("core:account_data")) + assert b"operator review" in response.content + assert b"Foreign private title" not in response.content + response = client.post(reverse("core:account_data"), {"confirmation": "DELETE"}) + assert b"Foreign private title" not in response.content + assert Member.objects.filter(pk=owner.pk).exists() + + +def test_legacy_ambiguous_storage_denies_pages_lists_and_writes(people, client): + owner, _, _, space = people + Space.objects.create(tenant_id="tenant:b", slug=space.slug, title="Collision") + assert client.get(reverse("spaces:detail", args=[space.slug])).status_code == 404 + assert client.get(reverse("spaces:page_edit", args=[space.slug, "index"])).status_code == 404 + assert space.title.encode() not in client.get(reverse("core:app_home")).content + with pytest.raises(pageops.PageOpsError, match="read-only"): + from coulomb_social.apps.spaces.services import put_space_page + put_space_page(owner, space, "notes", title="Attempt") diff --git a/tests/test_reichelag_workflow.py b/tests/test_reichelag_workflow.py index 75cad7a..ac0b40a 100644 --- a/tests/test_reichelag_workflow.py +++ b/tests/test_reichelag_workflow.py @@ -37,7 +37,8 @@ def test_full_protocol_duplicate_edit_and_clipboard_control(tmp_path, settings, edited = scenario["next_body"] response = client.post(reverse("spaces:page_edit", kwargs={ "slug": space.slug, "page_slug": "next-call", - }), {"title": "Next call", "slug": "next-call", "body": edited}) + }), {"title": "Next call", "slug": "next-call", "body": edited, + "revision": pageops.read_page(space.slug, "next-call").revision}) assert response.status_code == 302 assert pageops.read_page(space.slug, "previous-call").body == body response = client.get(reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=next-call") @@ -99,6 +100,7 @@ def test_protocol_naming_rename_and_collision_safety(tmp_path, settings, client) "slug": space.slug, "page_slug": duplicate.slug, }) fields = {"title": "Renamed call", "slug": source.slug, + "revision": duplicate.revision, "abstractor": duplicate.abstractor, "visual": duplicate.visual, "body": duplicate.body} response = client.post(edit_url, fields) @@ -119,7 +121,7 @@ def test_protocol_naming_rename_and_collision_safety(tmp_path, settings, client) assert renamed.meta["source_modified_at"] == "12345" assert Path(source.path).read_bytes() == source_bytes # Changing title alone preserves the filename and imported source metadata. - fields.update(title="Corrected title", slug="renamed-call") + fields.update(title="Corrected title", slug="renamed-call", revision=renamed.revision) response = client.post(reverse("spaces:page_edit", kwargs={ "slug": space.slug, "page_slug": "renamed-call", }), fields) @@ -137,13 +139,15 @@ def test_protocol_naming_rename_and_collision_safety(tmp_path, settings, client) assert Path(renamed.path).read_bytes() == protected # Renaming to index is refused; changing an index title cannot move its file. fields["slug"] = "index" + fields["revision"] = renamed.revision response = client.post(reverse("spaces:page_edit", kwargs={ "slug": space.slug, "page_slug": renamed.slug, }), fields) assert response.status_code == 200 and b"already exists" in response.content response = client.post(reverse("spaces:page_edit", kwargs={ "slug": space.slug, "page_slug": "index", - }), {"title": "Index title", "slug": "move-index", "body": "Index notes\n"}) + }), {"title": "Index title", "slug": "move-index", "body": "Index notes\n", + "revision": pageops.read_page(space.slug, "index").revision}) assert response.status_code == 302 assert pageops.read_page(space.slug, "index").title == "Index title" assert not pageops.read_page(space.slug, "move-index").exists diff --git a/tests/test_space_crud.py b/tests/test_space_crud.py index e1a57c9..45c2b32 100644 --- a/tests/test_space_crud.py +++ b/tests/test_space_crud.py @@ -71,7 +71,8 @@ def test_edit_space_updates_index_frontmatter(client, settings, content_root): ) r = client.post( reverse("spaces:edit", kwargs={"slug": "lab"}), - {"title": "Lab 2", "abstractor": "New abstract", "visual": "assets/v.jpg"}, + {"title": "Lab 2", "abstractor": "New abstract", "visual": "assets/v.jpg", + "revision": pageops.read_page("lab", "index").revision}, ) assert r.status_code == 302 space = Space.objects.get(slug="lab") @@ -146,6 +147,7 @@ def test_page_crud_copy_transfer(client, settings, content_root): reverse("spaces:page_edit", kwargs={"slug": "alpha", "page_slug": "notes"}), { "title": "Notes v2", + "revision": pageops.read_page("alpha", "notes").revision, "slug": "notes", "abstractor": "Updated", "visual": "assets/n.jpg", diff --git a/tests/usecases/fixtures/platform-communication-009.json b/tests/usecases/fixtures/platform-communication-009.json new file mode 100644 index 0000000..ffb210a --- /dev/null +++ b/tests/usecases/fixtures/platform-communication-009.json @@ -0,0 +1,8 @@ +{ + "synthetic": true, + "usecase_id": "CSOC-UC-009", + "tenant": "tenant:communication-test", + "recipient": "member@example.test", + "transport": "injected provider; no live sends", + "identity_authority": "isolated owner fixture" +} diff --git a/tests/usecases/fixtures/platform-communication-010.json b/tests/usecases/fixtures/platform-communication-010.json new file mode 100644 index 0000000..c665b24 --- /dev/null +++ b/tests/usecases/fixtures/platform-communication-010.json @@ -0,0 +1,8 @@ +{ + "synthetic": true, + "usecase_id": "CSOC-UC-010", + "tenant": "tenant:communication-test", + "recipient": "member@example.test", + "transport": "injected provider; no live sends", + "identity_authority": "isolated owner fixture" +} diff --git a/tests/usecases/fixtures/platform-communication-011.json b/tests/usecases/fixtures/platform-communication-011.json new file mode 100644 index 0000000..af52f60 --- /dev/null +++ b/tests/usecases/fixtures/platform-communication-011.json @@ -0,0 +1,8 @@ +{ + "synthetic": true, + "usecase_id": "CSOC-UC-011", + "tenant": "tenant:communication-test", + "recipient": "member@example.test", + "transport": "injected provider; no live sends", + "identity_authority": "isolated owner fixture" +} diff --git a/tests/usecases/fixtures/platform-communication-012.json b/tests/usecases/fixtures/platform-communication-012.json new file mode 100644 index 0000000..9a69011 --- /dev/null +++ b/tests/usecases/fixtures/platform-communication-012.json @@ -0,0 +1,8 @@ +{ + "synthetic": true, + "usecase_id": "CSOC-UC-012", + "tenant": "tenant:communication-test", + "recipient": "member@example.test", + "transport": "injected provider; no live sends", + "identity_authority": "isolated owner fixture" +} diff --git a/tests/usecases/test_sharing_browser.py b/tests/usecases/test_sharing_browser.py new file mode 100644 index 0000000..1cee22d --- /dev/null +++ b/tests/usecases/test_sharing_browser.py @@ -0,0 +1,35 @@ +"""Candidate sharing/portability journey; synthetic app-only browser checks.""" + +import subprocess +from pathlib import Path + +import pytest + +from coulomb_social.apps.spaces.services import create_space, put_space_page +from tests.test_space_permissions import make_member + + +@pytest.mark.browser +@pytest.mark.django_db(transaction=True) +def test_sharing_conflict_and_account_exit_through_ui(tmp_path, settings, request): + settings.DEBUG = True + settings.OIDC_ENABLED = False + settings.USER_ENGINE_BASE_URL = "" + settings.FLEX_AUTH_BASE_URL = "" + settings.FORGEJO_BASE_URL = "" + settings.CONTENT_ROOT = str(tmp_path / "content") + owner = make_member("sharing-owner", tenant="tenant:sharing-test") + reader = make_member("sharing-reader", tenant="tenant:sharing-test") + # Dev login uses this same exact issuer/subject association. + for member in (owner, reader): + member.issuer = "https://local.dev/issuer" + member.save() + space = create_space(owner, title="Synthetic shared work", slug="shared-browser") + put_space_page(owner, space, "notes", title="Notes", body="Original notes") + server = request.getfixturevalue("live_server") + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + ["node", str(root / "scripts/test-sharing-browser.mjs"), str(server)], + cwd=root, capture_output=True, text=True, timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/workplans/CSOC-WP-0008-production-transfer-acceptance.md b/workplans/CSOC-WP-0008-production-transfer-acceptance.md index 74edba0..ad3eda8 100644 --- a/workplans/CSOC-WP-0008-production-transfer-acceptance.md +++ b/workplans/CSOC-WP-0008-production-transfer-acceptance.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: coulomb-social created: "2026-10-06" -updated: "2026-10-07" +updated: "2026-10-08" origin: residual origin_ref: CSOC-WP-0007 depends_on: @@ -639,3 +639,40 @@ upgrade20.58seconds, app1/1. Read-only live list preview/image bytes, outsider exclusion, owner grant and all75stored hashes verified. Health/static200, clipboard unchanged. Snapshot-helper pin/7tests/server dry-run pass. T09 awaits founder list-preview/full workflow review; other T02/T04/T05 requirements stay open. + + +## Sharing, portability and content revision increment — 2026-10-08 + +T04 local implementation now provides owner-only Share space grants/revocation +by exact same-tenant member ID, with viewer/member/owner roles, creator/last-owner +protection and immediate subsequent-read denial. Your app data exports only the +requesting member's local records; explicit DELETE removes local Member/User and +grants, invalidates old sessions and retains shared content with another active +same-tenant owner. Archived spaces still require a successor. Platform identity +is separate. Corrupt foreign ownership blocks removal without disclosing names. +Lists/pages/service writes now deny duplicate slug-keyed storage, and serialized +app/import/admin writes plus admin validation reject ordinary collisions and +cross-tenant grants. Production tenancy/privacy and privileged maintenance +reconciliation remain T04; no database-wide global constraint is claimed. + +T05 local implementation adds immutable content-addressed previous/new versions, +atomic fsync-backed publication, stale page/space form rejection with retained +input, and cooperating thread/process writer locks. Blank imported pages remain +editable; original source metadata survives renaming/index edits. Visual and +Markdown body-linked attachments copy with rewritten destinations and failure +cleanup; old incoming page addresses survive rename/move with source/destination +permission checks and reserved-address reuse denial. Replacing an imported tree +with retained addresses now requires reconciliation. Existing archives are never +overwritten silently. Raw HTML/custom Markdown, actual corpus fidelity, +revision retention/capacity acceptance and crash/operational recovery remain open. + +Full make check: **182 passed**, Ruff clean, mandatory UC-008 and new sharing/ +concurrent-edit/account-exit Chromium journeys pass. New failure, parallel +thread/process, tenant, ownership, CSRF, blank-page, attachment and incoming-link +checks pass. Django system check passes; no schema changes detected. Review: +docs/release/sharing-portability-review-2026-10-08.md. Source implementation +choice: decision9ffb5125-5551-4101-9025-5b6f72f40940. This is not acceptance of a +durable retention policy or authority for deployment/cutover. No private corpus, +member profile, live image, offsite archive or timer changed. T01/T02/T03/T04/T05/ +T06/T08/T09 remain live for their existing admission, deployment, reconciliation +and productive user acceptance gates; this workplan remains active. diff --git a/workplans/CSOC-WP-0010-platform-email-facilities.md b/workplans/CSOC-WP-0010-platform-email-facilities.md index 213c5d1..16c18d4 100644 --- a/workplans/CSOC-WP-0010-platform-email-facilities.md +++ b/workplans/CSOC-WP-0010-platform-email-facilities.md @@ -186,3 +186,32 @@ end-to-end journeys: T06 remains open until isolated issuer/mailbox/app browser coverage and owner regression bindings are delivered. UC-008 stays protected by make check; protocol distribution remains manual. WP-0008-T07 lint debt closed; full application gate98passed. No Bubble/apex cutover or new notification send. + + +## Executable communication regression increment — 2026-10-08 + +T06 registers UC-009–012 with synthetic fixture references and explicit +established:false/browser gaps. UC-009 retains its existing personal recovery +acceptance; the remaining flows are candidates. The UC-008 protected registry +and manual email distribution remain intact. communication-regressions.json +binds21 exact owner functions; make check-communication snapshots Python source +and the synthetic transactional test into a private temporary tree, denies all +socket connects/DNS, strips inherited credentials, disables plugin autoload, +refuses skipped/xfail or missing contract tests and produces redacted source/ +test/contract-hash receipts. The owner checkout is not changed. + +Actual current owner gate: **39 passed, zero skips**, including verification +restart/uncertain/concurrent retry safety. Local runner failure tests prove +network denial, environment isolation and missing/disabled-coverage refusal. +Application gate: **182 passed**, Ruff and both real Chromium journeys pass. +Review: docs/release/sharing-portability-review-2026-10-08.md; public owner receipt: +docs/release/communication-regressions-2026-10-08.json. This closes stale baseline +reporting, not owner admission or full end-to-end automated protection. + +T04/T05/T06 remain progress: independent caller/template/tenant admission, limits, +caller-scoped status, platform sender and independent ledger backup custody, +inbound/reply/bounce ownership, alignment/retention and isolated issuer/mailbox/app +browser journeys still need owner returns. EMAIL-WP-0007-T03/T04 and +RPF-WP-0053-T05 remain the live ledger custody/activation handoffs. No external +owner source/runtime, mailbox access, caller admission or email send changed; +no productive new communication flow was accepted or this workplan finished.