Implement space sharing, member portability and recoverable content edits
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m43s
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m43s
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a119cf-56d0-7fa3-840b-eb8732e1db0b
This commit is contained in:
parent
a2220afa1a
commit
dd83c90d73
38 changed files with 1833 additions and 136 deletions
5
Makefile
5
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)"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
coulomb_social/apps/members/portability.py
Normal file
82
coulomb_social/apps/members/portability.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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<prefix>\]\(\s*<?|\]:\s*<?)(?P<url>(?:\.\./|\./)?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-<slug> 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ urlpatterns = [
|
|||
path("new/", views.space_create, name="create"),
|
||||
path("<slug:slug>/assets/<path:asset_path>", views.space_asset, name="asset"),
|
||||
path("<slug:slug>/edit/", views.space_edit, name="edit"),
|
||||
path("<slug:slug>/sharing/", views.space_sharing, name="sharing"),
|
||||
path("<slug:slug>/archive/", views.space_archive, name="archive"),
|
||||
path("<slug:slug>/refresh/", views.space_refresh, name="refresh"),
|
||||
path("<slug:slug>/pages/new/", views.page_new, name="page_new"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@
|
|||
<div class="menu-panel" role="menu">
|
||||
<div class="menu-meta">Signed in</div>
|
||||
<a href="{% url 'core:account_session' %}" role="menuitem">Session details</a>
|
||||
<a href="{% url 'core:account_data' %}" role="menuitem">Your app data</a>
|
||||
<a href="{% url 'identity:logout' %}" role="menuitem">Sign out</a>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
26
coulomb_social/templates/core/account_data.html
Normal file
26
coulomb_social/templates/core/account_data.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Your app data — {{ site_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Your app data</h1>
|
||||
<p>Your member ID: <code>{{ member.id }}</code>. Share this ID with a space owner to request access.</p>
|
||||
<p><a class="btn" href="{% url 'core:account_export' %}">Export my account records</a></p>
|
||||
<p>The export includes your local profile, identity references and memberships. Pages and assets remain in their spaces.</p>
|
||||
<h2>Leaving the app</h2>
|
||||
<p>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.</p>
|
||||
<h2>Spaces you are responsible for</h2>
|
||||
<ul>{% for space in retained_spaces %}<li>{{ space.title }}{% if not space.is_active %} (archived){% endif %}</li>{% empty %}<li>None</li>{% endfor %}</ul>
|
||||
{% if ownership_review %}
|
||||
<p>An inconsistent ownership record needs operator review before removal.</p>
|
||||
{% elif blockers %}
|
||||
<p>Before removal, give another active member owner access to each of these spaces:</p>
|
||||
<ul>{% for space in blockers %}<li>{{ space.title }}</li>{% endfor %}</ul>
|
||||
{% else %}
|
||||
<form method="post">{% csrf_token %}
|
||||
<label for="confirmation">Type DELETE to remove your local account</label>
|
||||
<input id="confirmation" name="confirmation" type="text" required autocomplete="off">
|
||||
<button class="btn secondary" type="submit">Remove local account</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -97,6 +97,7 @@
|
|||
<div class="content-actions" style="margin:0;">
|
||||
<a class="btn secondary small" href="{% url 'spaces:edit' space.slug %}">Edit space</a>
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_new' space.slug %}">New page</a>
|
||||
{% if can_manage %}<a class="btn secondary small" href="{% url 'spaces:sharing' space.slug %}">Share space</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
<form method="post" class="card" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
{% if space %}<input type="hidden" name="revision" value="{{ revision }}">{% endif %}
|
||||
<label for="id_title">Title</label>
|
||||
<input type="text" id="id_title" name="title" required maxlength="255"
|
||||
value="{% if space %}{{ space.title }}{% endif %}" autocomplete="off">
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
<form method="post" class="card" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="revision" value="{{ doc.revision }}">
|
||||
<label for="id_title">Title</label>
|
||||
<input type="text" id="id_title" name="title" maxlength="255"
|
||||
value="{{ doc.title }}" autocomplete="off"
|
||||
|
|
@ -44,7 +45,7 @@
|
|||
<input type="text" id="id_slug" name="slug" value="index" readonly maxlength="80">
|
||||
{% else %}
|
||||
<input type="text" id="id_slug" name="slug" maxlength="80"
|
||||
value="{{ doc.slug }}"
|
||||
value="{{ submitted_slug|default:doc.slug }}"
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
|
||||
placeholder="page-slug" autocomplete="off"
|
||||
{% if is_new %}required{% endif %}>
|
||||
|
|
|
|||
21
coulomb_social/templates/spaces/sharing.html
Normal file
21
coulomb_social/templates/spaces/sharing.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Share {{ space.title }} — {{ site_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<p><a href="{% url 'spaces:detail' space.slug %}">← {{ space.title }}</a></p>
|
||||
<h1>Share space</h1>
|
||||
<p>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.</p>
|
||||
<form method="post" class="card">{% csrf_token %}
|
||||
<label for="member_id">Member ID</label>
|
||||
<input id="member_id" name="member_id" type="text" required autocomplete="off">
|
||||
<label for="role">Access</label>
|
||||
<select id="role" name="role">
|
||||
{% for value, label in roles %}<option value="{{ value }}" {% if value == 'viewer' %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
<option value="remove">Remove access</option>
|
||||
</select>
|
||||
<p><button class="btn" type="submit">Update access</button></p>
|
||||
</form>
|
||||
<h2>Current access</h2>
|
||||
<ul>{% for grant in grants %}<li>{{ grant.member.display_name|default:grant.member.subject }} — {{ grant.get_role_display }} — <code>{{ grant.member_id }}</code></li>{% empty %}<li>The creator has owner access.</li>{% endfor %}</ul>
|
||||
{% endblock %}
|
||||
36
docs/release/communication-regressions-2026-10-08.json
Normal file
36
docs/release/communication-regressions-2026-10-08.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
118
docs/release/sharing-portability-review-2026-10-08.md
Normal file
118
docs/release/sharing-portability-review-2026-10-08.md
Normal file
|
|
@ -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/<space>/<page>/<sha256>.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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
36
docs/usecases/communication-regressions.json
Normal file
36
docs/usecases/communication-regressions.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
114
scripts/check-communication.py
Normal file
114
scripts/check-communication.py
Normal file
|
|
@ -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())
|
||||
71
scripts/test-sharing-browser.mjs
Normal file
71
scripts/test-sharing-browser.mjs
Normal file
|
|
@ -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();
|
||||
}
|
||||
57
tests/test_communication_gate.py
Normal file
57
tests/test_communication_gate.py
Normal file
|
|
@ -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()
|
||||
285
tests/test_content_revisions.py
Normal file
285
tests/test_content_revisions.py
Normal file
|
|
@ -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 = ('\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 "" 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="\n")
|
||||
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="")
|
||||
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)
|
||||
170
tests/test_member_portability.py
Normal file
170
tests/test_member_portability.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
8
tests/usecases/fixtures/platform-communication-009.json
Normal file
8
tests/usecases/fixtures/platform-communication-009.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
8
tests/usecases/fixtures/platform-communication-010.json
Normal file
8
tests/usecases/fixtures/platform-communication-010.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
8
tests/usecases/fixtures/platform-communication-011.json
Normal file
8
tests/usecases/fixtures/platform-communication-011.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
8
tests/usecases/fixtures/platform-communication-012.json
Normal file
8
tests/usecases/fixtures/platform-communication-012.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
35
tests/usecases/test_sharing_browser.py
Normal file
35
tests/usecases/test_sharing_browser.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue