c2f566512a
Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.
- migration 030: memberships.scope_type += 'global' (the global RFC
Contributor tier; sentinel scope_id '*').
- auth.effective_scope_role folds global → project → collection,
most-permissive, no negative override; can_read_collection /
can_contribute_in_collection / is_collection_superuser /
can_create_collection gate reads, writes, admin, and create.
- collection-grain visibility: a gated collection is hidden from the
public (404, omitted from the directory) yet visible+listed for a
scope-role holder; a collection may be set only as strict or stricter
than its project (public < unlisted < gated), validated at create and
clamped at the mirror.
- entry-scoped authority (mark-reviewed, graduate, branch read/contribute,
PR/discussion/contribution moderation) re-pointed from the project grain
to the entry's collection.
- create-collection authority widened to a project/global-scope grant
holder (§B.1), not only a deployment owner/admin.
Keystone reconciliation (session 0076): a plain granted account is a
granted *account*, not a write-everywhere global role; the implicit-public
write baseline is grandfathered onto the migration-seeded `default`
collection only, so the N=1 deployment loses no capability. Reinterprets
§B.1/§B.3 literally — flagged for the SPEC merge (S6).
Completes @S3 (C1.1–C1.8). Tests: test_s3_scope_roles_vertical.py (8 C.1
scenarios + visibility/strictness), test_migration_030_global_scope.py.
Full backend suite 493 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
7.0 KiB
Python
165 lines
7.0 KiB
Python
"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose.
|
|
|
|
The mirror test drives cache.refresh_meta_repo against an in-memory content repo
|
|
holding entries under both the default `rfcs/` and a named collection's
|
|
`features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from app import cache, db
|
|
from app.config import Config
|
|
|
|
|
|
def _db() -> Config:
|
|
cfg = Config(
|
|
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
|
registry_repo="registry", oauth_client_id="x",
|
|
oauth_client_secret="x", app_url="x", secret_key="x",
|
|
database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db",
|
|
owner_gitea_login="x", webhook_secret="x",
|
|
)
|
|
db.run_migrations(cfg)
|
|
if db._CONN is not None:
|
|
db._CONN.close()
|
|
db._CONN = None
|
|
db.init(cfg)
|
|
return cfg
|
|
|
|
|
|
class _CorpusGitea:
|
|
"""A content repo modelled as a flat {path: text} map, listing files under a
|
|
directory prefix and reading them back."""
|
|
|
|
def __init__(self, tree: dict[str, str]):
|
|
self._tree = tree
|
|
|
|
async def list_dir(self, org, repo, path, ref="main"):
|
|
out = []
|
|
prefix = (path.rstrip("/") + "/") if path else ""
|
|
for p in self._tree:
|
|
if p.startswith(prefix) and "/" not in p[len(prefix):]:
|
|
out.append({"type": "file", "name": p.split("/")[-1], "path": p})
|
|
return out
|
|
|
|
async def read_file(self, org, repo, path, ref="main"):
|
|
t = self._tree.get(path)
|
|
return (t, "sha-" + path) if t is not None else None
|
|
|
|
|
|
def _entry_md(slug, title):
|
|
return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n"
|
|
|
|
|
|
def _seed_project_with_two_collections():
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
|
"VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))")
|
|
for cid, sub in [("default", ""), ("features", "features")]:
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
|
|
"visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, "
|
|
"'super-draft','public', datetime('now'), datetime('now'))", (cid, sub))
|
|
|
|
|
|
def test_mirror_keys_entries_by_collection():
|
|
cfg = _db()
|
|
_seed_project_with_two_collections()
|
|
gitea = _CorpusGitea({
|
|
"rfcs/a.md": _entry_md("a", "Default A"),
|
|
"features/rfcs/b.md": _entry_md("b", "Feature B"),
|
|
})
|
|
asyncio.run(cache.refresh_meta_repo(cfg, gitea))
|
|
got = {(r["collection_id"], r["slug"]) for r in
|
|
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
|
|
assert got == {("default", "a"), ("features", "b")}
|
|
|
|
|
|
# --- collection-scoped serve + propose (full app) -----------------------------
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
from test_propose_vertical import ( # noqa: E402,F401
|
|
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
|
)
|
|
|
|
|
|
def _add_features_collection(content_repo="meta"):
|
|
"""Add a named 'features' collection (subfolder 'features') under the seeded
|
|
default project, plus a single entry under features/rfcs/ in the db cache."""
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
|
|
"initial_state, visibility, name, created_at, updated_at) VALUES "
|
|
"('features','default','document','features','super-draft','public','Features', "
|
|
"datetime('now'), datetime('now'))")
|
|
|
|
|
|
def test_scoped_list_returns_only_that_collection(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_features_collection()
|
|
# Seed one entry under each collection's rfcs dir + mirror them in.
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = {
|
|
"content": _entry_md("a", "Default A"), "sha": "sa"}
|
|
fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = {
|
|
"content": _entry_md("b", "Feature B"), "sha": "sb"}
|
|
from app import cache as cache_mod, gitea as gitea_mod
|
|
from app.config import load_config
|
|
cfg = load_config()
|
|
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
|
|
|
r = client.get("/api/projects/default/collections/features/rfcs")
|
|
assert r.status_code == 200, r.text
|
|
assert [i["slug"] for i in r.json()["items"]] == ["b"]
|
|
# The default collection still serves only its own entry.
|
|
r2 = client.get("/api/projects/default/collections/default/rfcs")
|
|
assert [i["slug"] for i in r2.json()["items"]] == ["a"]
|
|
|
|
|
|
def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_features_collection()
|
|
provision_user_row(user_id=3, login="alice", role="contributor")
|
|
# §22 S3: an explicitly-created collection requires an explicit scope
|
|
# grant to write (the grandfathered baseline covers only `default`).
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
|
"VALUES ('collection', 'features', 3, 'contributor')")
|
|
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
|
role="contributor", email="alice@test")
|
|
r = client.post(
|
|
"/api/projects/default/collections/features/rfcs/propose",
|
|
json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []})
|
|
assert r.status_code == 200, r.text
|
|
# The bot wrote the entry under features/rfcs/, not rfcs/.
|
|
keys = {(k[1], k[3]) for k in fake.files
|
|
if k[1] == "meta" and k[3].endswith("newb.md")}
|
|
assert ("meta", "features/rfcs/newb.md") in keys
|
|
|
|
|
|
def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
r = client.get("/api/projects/default/collections/nope/rfcs")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_s2_anonymous_empty_public_collection(app_with_fake_gitea):
|
|
"""C3.6 (@S2): a public collection with no entries; an anonymous visitor
|
|
lands on its catalog → an empty catalog (200, no items), and the propose
|
|
action is not available to them (the propose route rejects anonymous)."""
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_features_collection() # public, no entries
|
|
# Anonymous (no session cookie) reads the empty catalog — 200, [].
|
|
r = client.get("/api/projects/default/collections/features/rfcs")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["items"] == []
|
|
# No propose action for an anonymous visitor.
|
|
r2 = client.post(
|
|
"/api/projects/default/collections/features/rfcs/propose",
|
|
json={"title": "X", "slug": "x", "pitch": "p", "tags": []})
|
|
assert r2.status_code == 401
|