33212c71e4
Adds the global-Owner create-project action and the role-aware deployment directory, completing slice S5 of the three-tier refactor (release v0.44.0, minor/non-breaking — no migration). Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5), Part C.3 (C3.1–C3.2). Backend: - auth.can_create_project — global-Owner gate (deployment owner/admin or an explicit scope_type='global' Owner grant). - bot.create_project — provision the Gitea content repo (seed README so main exists), read+append+commit projects.yaml in the registry repo, audit-log (create_project). The bot stays the only git writer (§1). - POST /api/projects (api_deployment) — global-Owner gated; validates the id (slug, not 'default'), name, type, visibility, content_repo; commits via the bot, then re-mirrors the registry so projects + default collection rows flow from git (§22.2). GET /api/deployment gains viewer.can_create_project and default_project_readable. make_router now takes gitea + bot. Frontend: - api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable + refresh; DeploymentLanding redirects into the default only when readable (gated/absent default falls through to the directory, no 404 bounce). - Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA; C3.2 "Nothing has been shared with you yet") + Owner-only "New project" control + CreateProjectModal. Tests: backend test_create_project_vertical.py (vertical + gates + the deployment empty-state signals); frontend Directory.test.jsx empty-state cases. Also: ignore the session-local .superpowers/ tooling dir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
9.3 KiB
Python
203 lines
9.3 KiB
Python
"""§22 S5 — create-project vertical: a global Owner POSTs `/api/projects`, the
|
|
bot provisions a Gitea content repo + commits the project to `projects.yaml`, and
|
|
the registry mirror upserts the `projects` + default `collections` rows (registry
|
|
stays the source of truth). Plus the deployment-directory empty-state signals
|
|
(`viewer.can_create_project`, `default_project_readable`) that drive C3.1/C3.2.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import db
|
|
from test_propose_vertical import ( # noqa: F401
|
|
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
|
)
|
|
|
|
|
|
def test_create_project_provisions_repo_commits_registry_and_mirrors(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
|
role="owner", email="ben@test")
|
|
r = client.post("/api/projects",
|
|
json={"project_id": "acme", "name": "Acme", "type": "bdd",
|
|
"visibility": "public"})
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["id"] == "acme"
|
|
assert body["type"] == "bdd"
|
|
assert body["visibility"] == "public"
|
|
|
|
# The bot provisioned the content repo (default name <id>-content) and
|
|
# seeded a README so `main` exists.
|
|
assert ("wiggleverse", "acme-content") in fake.repos
|
|
readme = fake.files.get(("wiggleverse", "acme-content", "main", "README.md"))
|
|
assert readme is not None and "acme" in readme["content"]
|
|
|
|
# The bot committed the new project into projects.yaml.
|
|
reg = fake.files.get(("wiggleverse", "registry", "main", "projects.yaml"))
|
|
assert reg is not None
|
|
assert "id: acme" in reg["content"]
|
|
assert "content_repo: acme-content" in reg["content"]
|
|
|
|
# The registry refresh mirrored a projects row + its default collection.
|
|
prow = db.conn().execute(
|
|
"SELECT name, content_repo, visibility FROM projects WHERE id='acme'"
|
|
).fetchone()
|
|
assert (prow["name"], prow["content_repo"], prow["visibility"]) == (
|
|
"Acme", "acme-content", "public")
|
|
crow = db.conn().execute(
|
|
"SELECT type, project_id, subfolder FROM collections WHERE id='acme'"
|
|
).fetchone()
|
|
assert (crow["type"], crow["project_id"], crow["subfolder"]) == ("bdd", "acme", "")
|
|
|
|
# It is now visible in the deployment directory and readable.
|
|
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
|
|
assert "acme" in ids
|
|
assert client.get("/api/projects/acme").status_code == 200
|
|
|
|
# An audit row records the structural action with the global Owner actor.
|
|
act = db.conn().execute(
|
|
"SELECT actor_user_id FROM actions WHERE action_kind='create_project'"
|
|
).fetchone()
|
|
assert act is not None and act["actor_user_id"] == 1
|
|
|
|
|
|
def test_create_project_custom_content_repo_name(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
|
role="owner", email="ben@test")
|
|
r = client.post("/api/projects",
|
|
json={"project_id": "beta", "name": "Beta", "type": "document",
|
|
"content_repo": "beta-corpus"})
|
|
assert r.status_code == 200, r.text
|
|
assert ("wiggleverse", "beta-corpus") in fake.repos
|
|
prow = db.conn().execute(
|
|
"SELECT content_repo FROM projects WHERE id='beta'"
|
|
).fetchone()
|
|
assert prow["content_repo"] == "beta-corpus"
|
|
|
|
|
|
def test_create_project_requires_global_owner(app_with_fake_gitea):
|
|
# A plain deployment contributor is not a global Owner (C: + New project is a
|
|
# global-Owner action), even though they may create collections.
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
|
role="contributor", email="alice@test")
|
|
r = client.post("/api/projects",
|
|
json={"project_id": "x", "name": "X", "type": "bdd"})
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_create_project_global_owner_grant_permitted(app_with_fake_gitea):
|
|
# An explicit global-scope Owner grant (not a deployment owner/admin) may
|
|
# create projects.
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=3, login="gina", role="contributor")
|
|
db.conn().execute(
|
|
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
|
"VALUES ('global', '*', 3, 'owner')"
|
|
)
|
|
sign_in_as(client, user_id=3, gitea_login="gina", display_name="Gina",
|
|
role="contributor", email="gina@test")
|
|
r = client.post("/api/projects",
|
|
json={"project_id": "gproj", "name": "G", "type": "document"})
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
def test_create_project_anonymous_rejected(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
r = client.post("/api/projects",
|
|
json={"project_id": "x", "name": "X", "type": "bdd"})
|
|
assert r.status_code in (401, 403)
|
|
|
|
|
|
def test_create_project_rejects_duplicate(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
|
role="owner", email="ben@test")
|
|
ok = client.post("/api/projects",
|
|
json={"project_id": "acme", "name": "Acme", "type": "bdd"})
|
|
assert ok.status_code == 200, ok.text
|
|
dup = client.post("/api/projects",
|
|
json={"project_id": "acme", "name": "Acme 2", "type": "bdd"})
|
|
assert dup.status_code == 409
|
|
|
|
|
|
def test_create_project_rejects_reserved_default_id(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
|
role="owner", email="ben@test")
|
|
r = client.post("/api/projects",
|
|
json={"project_id": "default", "name": "X", "type": "bdd"})
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_create_project_rejects_bad_type(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
|
role="owner", email="ben@test")
|
|
r = client.post("/api/projects",
|
|
json={"project_id": "x", "name": "X", "type": "nonsense"})
|
|
assert r.status_code == 422
|
|
|
|
|
|
# --- C3.1 / C3.2: deployment-directory empty-state signals ------------------
|
|
|
|
|
|
def test_deployment_owner_sees_create_project_capability(app_with_fake_gitea):
|
|
# C3.1: a global Owner is offered the create-project action.
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
|
body = client.get("/api/deployment").json()
|
|
assert body["viewer"]["can_create_project"] is True
|
|
|
|
|
|
def test_deployment_non_owner_no_create_capability(app_with_fake_gitea):
|
|
# C3.2: a granted account with no roles is not offered create-project.
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="vee", role="contributor")
|
|
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
|
|
body = client.get("/api/deployment").json()
|
|
assert body["viewer"]["can_create_project"] is False
|
|
|
|
|
|
def test_deployment_default_readable_when_default_is_public(app_with_fake_gitea):
|
|
# The seeded default project is public → readable → the N=1 redirect target
|
|
# is valid (land-in-corpus preserved).
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
body = client.get("/api/deployment").json()
|
|
assert body["default_project_readable"] is True
|
|
|
|
|
|
def test_deployment_default_not_readable_when_only_gated(app_with_fake_gitea):
|
|
# C3.2: the only project is gated; a granted non-member sees no visible
|
|
# projects AND default_project_readable False → the frontend renders the
|
|
# empty directory (no 404 bounce).
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
db.conn().execute("UPDATE projects SET visibility='gated' WHERE id='default'")
|
|
db.conn().execute("UPDATE collections SET visibility='gated' WHERE id='default'")
|
|
provision_user_row(user_id=2, login="vee", role="contributor")
|
|
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
|
|
body = client.get("/api/deployment").json()
|
|
assert body["projects"] == []
|
|
assert body["default_project_readable"] is False
|