Files
rfc-app/backend/tests/test_project_scoped_propose.py
T
Ben Stull c2f566512a §22 S3: scope-role enforcement + collection-grain visibility (@S3) — v0.42.0
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>
2026-06-05 18:07:51 -07:00

80 lines
3.5 KiB
Python

"""§22.4 (Plan B write): proposing a new entry into a *specific* project lands
it in that project's content repo and surfaces under that project's proposals,
isolated from the default project."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _register_ecomm(fake):
from app import db
db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
"VALUES ('ecomm', 'Ecomm', 'ecomm-content', 'public')"
)
db.conn().execute(
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES ('ecomm', 'ecomm', 'document', '', 'super-draft', 'public', 'Ecomm')"
)
fake._seed_repo("wiggleverse", "ecomm-content")
def test_propose_into_second_project_lands_scoped(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_register_ecomm(fake)
provision_user_row(user_id=3, login="alice", role="contributor")
# §22 S3: the grandfathered implicit-public baseline covers only the N=1
# `default` collection; a second project requires an explicit scope grant
# to write. Grant alice contributor at the ecomm project.
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('project', 'ecomm', 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/ecomm/rfcs/propose", json={
"title": "Cart", "slug": "cart", "pitch": "why a cart", "tags": [],
})
assert r.status_code == 200, r.text
# The idea PR shows under ecomm's proposals, not the default's.
e = {i["slug"] for i in client.get("/api/projects/ecomm/proposals").json()["items"]}
d = {i["slug"] for i in client.get("/api/projects/default/proposals").json()["items"]}
assert "cart" in e
assert "cart" not in d
# It landed in ecomm's content repo, not the default 'meta' repo.
assert ("wiggleverse", "ecomm-content") in {
(o, rp) for (o, rp) in fake.branches if rp == "ecomm-content"
}
assert any(
br.startswith("propose/cart")
for br in fake.branches.get(("wiggleverse", "ecomm-content"), {})
)
def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
app, _ = app_with_fake_gitea
from app import db
with TestClient(app) as client:
db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
"VALUES ('secret', 'Secret', 'secret-content', 'gated')"
)
db.conn().execute(
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES ('secret', 'secret', 'document', '', 'super-draft', 'gated', 'Secret')"
)
provision_user_row(user_id=4, login="bob", role="contributor")
sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/projects/secret/rfcs/propose", json={
"title": "X", "slug": "x", "pitch": "p", "tags": [],
})
assert r.status_code == 404