§22 S5: in-app create-project + global-directory empty states (@S5 C3.1–C3.2)
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>
This commit is contained in:
+1
-1
@@ -153,7 +153,7 @@ def make_router(
|
||||
router.include_router(api_contributions.make_router())
|
||||
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
||||
# VITE_APP_NAME) + the old-URL 308 redirects.
|
||||
router.include_router(api_deployment.make_router(config))
|
||||
router.include_router(api_deployment.make_router(config, gitea, bot))
|
||||
router.include_router(api_collections.make_router(config, gitea, bot))
|
||||
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
|
||||
# {owner, contributor} at project/collection scope to existing accounts.
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
|
||||
old-URL 308 redirects.
|
||||
old-URL 308 redirects + §22 S5 in-app create-project.
|
||||
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
|
||||
plus the corpus-served `default_project_id` the M3-frontend guard keys on.
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
plus the corpus-served `default_project_id` the M3-frontend guard keys on, the
|
||||
`viewer` capability block (S5: `can_create_project`), and
|
||||
`default_project_readable` (whether the N=1 redirect target is reachable by this
|
||||
viewer — drives the deployment-directory empty state vs the land-in-corpus
|
||||
redirect).
|
||||
POST /api/projects — §22 S5 create-project (global-Owner only). The bot
|
||||
provisions a Gitea content repo and commits a project entry to `projects.yaml`;
|
||||
the registry mirror then upserts the `projects` + default `collections` rows
|
||||
(§22.2 keeps the registry the source of truth).
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
gated behind the §22.5 read gate (404 for a non-member of a gated project).
|
||||
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
|
||||
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
|
||||
`/p/<default>/…` routes (the SPA no longer owns these paths; nginx proxies them
|
||||
to the backend instead of serving index.html).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, collections as collections_mod, db, projects as projects_mod
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
projects as projects_mod,
|
||||
registry as registry_mod,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
# A project id is a slug (the §22.2 registry key + the `/p/<id>/` path segment).
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
# A Gitea repo name: alphanumeric start, then alphanumerics / `-` / `_` / `.`.
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
def make_router(config: Config) -> APIRouter:
|
||||
class CreateProjectBody(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
type: str
|
||||
visibility: str | None = None
|
||||
content_repo: str | None = None
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/deployment")
|
||||
@@ -51,6 +83,16 @@ def make_router(config: Config) -> APIRouter:
|
||||
for r in rows
|
||||
if r["id"] in visible
|
||||
]
|
||||
# §22 S5: the N=1 land-in-corpus redirect targets the default project, but
|
||||
# only when this viewer can actually read it. A `gated` default (C3.2) or
|
||||
# an absent default (C3.1, a deployment with no projects) is *not* a valid
|
||||
# redirect target — the frontend then falls through to the deployment
|
||||
# directory's role-aware empty state instead of bouncing into a 404.
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
default_exists = db.conn().execute(
|
||||
"SELECT 1 FROM projects WHERE id = ?", (default_id,)
|
||||
).fetchone() is not None
|
||||
default_readable = default_exists and auth.can_read_project(viewer, default_id)
|
||||
return {
|
||||
"name": (dep["name"] if dep else "") or "",
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
@@ -58,8 +100,100 @@ def make_router(config: Config) -> APIRouter:
|
||||
# serves the corpus for (the default, until Plan B serves per
|
||||
# project). The frontend renders corpus routes only for this id and
|
||||
# shows a "content not yet served" placeholder for any other.
|
||||
"default_project_id": projects_mod.resolved_default_id(config),
|
||||
"default_project_id": default_id,
|
||||
"default_project_readable": default_readable,
|
||||
"projects": projects,
|
||||
# §22 S5 (C3.1/C3.2): role-aware deployment-directory affordances.
|
||||
"viewer": {"can_create_project": auth.can_create_project(viewer)},
|
||||
}
|
||||
|
||||
@router.post("/api/projects")
|
||||
async def create_project(body: CreateProjectBody, request: Request) -> dict[str, Any]:
|
||||
# §22 S5 / §A.2 / §B.1: "+ New project" is a global-Owner action.
|
||||
user = auth.require_contributor(request)
|
||||
if not auth.can_create_project(user):
|
||||
raise HTTPException(403, "Only a global Owner may create projects")
|
||||
pid = body.project_id.strip().lower()
|
||||
if not _SLUG_RE.match(pid) or pid == "default":
|
||||
raise HTTPException(422, "project id must be a slug and not 'default'")
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(422, "project name is required")
|
||||
if body.type not in registry_mod.VALID_TYPES:
|
||||
raise HTTPException(422, f"invalid type {body.type!r}")
|
||||
# A project is created visible by default — the point of standing one up
|
||||
# is for it to be seen; an Owner narrows it afterwards (or picks gated).
|
||||
visibility = (body.visibility or "public").strip()
|
||||
if visibility not in registry_mod.VALID_VISIBILITY:
|
||||
raise HTTPException(422, f"invalid visibility {visibility!r}")
|
||||
if db.conn().execute("SELECT 1 FROM projects WHERE id = ?", (pid,)).fetchone():
|
||||
raise HTTPException(409, f"project `{pid}` already exists")
|
||||
content_repo = (body.content_repo or f"{pid}-content").strip()
|
||||
if not _REPO_RE.match(content_repo):
|
||||
raise HTTPException(422, f"invalid content repo name {content_repo!r}")
|
||||
if await gitea.get_repo(config.gitea_org, content_repo) is not None:
|
||||
raise HTTPException(409, f"repo `{content_repo}` already exists")
|
||||
|
||||
# Read the current registry, append the project, recompose. Reads live in
|
||||
# gitea.py and may be called anywhere; the bot owns the write back.
|
||||
read = await gitea.read_file(
|
||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||
)
|
||||
if read is None:
|
||||
raise HTTPException(409, "registry projects.yaml not found")
|
||||
text, sha = read
|
||||
try:
|
||||
doc = yaml.safe_load(text) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(500, f"registry projects.yaml is not valid YAML: {e}")
|
||||
if not isinstance(doc, dict):
|
||||
raise HTTPException(500, "registry projects.yaml is malformed")
|
||||
projects = doc.get("projects")
|
||||
if not isinstance(projects, list):
|
||||
projects = []
|
||||
if any(isinstance(p, dict) and str(p.get("id") or "") == pid for p in projects):
|
||||
raise HTTPException(409, f"project `{pid}` already in the registry")
|
||||
projects.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": name,
|
||||
"type": body.type,
|
||||
"content_repo": content_repo,
|
||||
"visibility": visibility,
|
||||
}
|
||||
)
|
||||
doc["projects"] = projects
|
||||
new_text = yaml.safe_dump(doc, sort_keys=False)
|
||||
readme_text = f"# {name}\n\nContent repository for project `{pid}`.\n"
|
||||
|
||||
try:
|
||||
await bot.create_project(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
registry_repo=config.registry_repo,
|
||||
content_repo=content_repo,
|
||||
project_id=pid,
|
||||
projects_yaml_new=new_text,
|
||||
projects_yaml_sha=sha,
|
||||
readme_text=readme_text,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
# §22.2: re-read the registry so the new entry becomes projects +
|
||||
# default-collection rows.
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, visibility FROM projects WHERE id = ?", (pid,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(500, "project committed but not mirrored")
|
||||
cid = collections_mod.default_collection_id(pid)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"visibility": row["visibility"],
|
||||
"type": collections_mod.collection_type(cid),
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}")
|
||||
|
||||
@@ -682,6 +682,25 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_create_project(user: SessionUser | None) -> bool:
|
||||
"""§22 S5 (§A.2 / §B.1): may the user create a new project? "+ New project"
|
||||
is a **global-Owner** action — a deployment owner/admin (a global Owner per
|
||||
§B.1) or a holder of an explicit `scope_type='global'` Owner grant. Creating
|
||||
a project is deployment-level, so it is not reachable by a project- or
|
||||
collection-scope grant nor by a global RFC Contributor (that role creates
|
||||
collections, not projects). Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships "
|
||||
"WHERE user_id = ? AND scope_type = 'global' AND role = 'owner' LIMIT 1",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any
|
||||
collection within it)? Managing membership is an *Owner* capability whose
|
||||
|
||||
@@ -198,6 +198,82 @@ class Bot:
|
||||
)
|
||||
return created
|
||||
|
||||
async def create_project(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
registry_repo: str,
|
||||
content_repo: str,
|
||||
project_id: str,
|
||||
projects_yaml_new: str,
|
||||
projects_yaml_sha: str,
|
||||
readme_text: str,
|
||||
) -> dict:
|
||||
"""§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping
|
||||
a bot write at two git sources:
|
||||
|
||||
1. **provision the content repo** — create `org/content_repo` if it
|
||||
doesn't exist, then seed a `README.md` on `main` so the branch
|
||||
exists (the contents API initialises the repo with that commit; the
|
||||
corpus mirror and the propose path both need a `main` to write to).
|
||||
2. **register the project** — commit the caller-composed
|
||||
`projects.yaml` (the existing doc with the new project appended) to
|
||||
the registry repo's `main`.
|
||||
|
||||
Like `create_collection`, this is a structural admin action committed
|
||||
straight to main (no PR), like the registry config it feeds; the caller
|
||||
then re-runs the registry mirror so the new `projects` + default
|
||||
`collections` rows flow from the registry (§22.2 keeps the registry the
|
||||
source of truth). Logs a `create_project` audit row for the §6.5 trail.
|
||||
Returns the registry update_file result (carries the new commit sha)."""
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
existing = await self._gitea.get_repo(org, content_repo)
|
||||
if existing is None:
|
||||
await self._gitea.create_org_repo(
|
||||
org, content_repo, description=f"Content repo for project {project_id}"
|
||||
)
|
||||
# Seed a README if absent, which also establishes `main` on a freshly
|
||||
# created (auto_init=False) repo — the contents API initialises the repo
|
||||
# with that commit. Keyed on the README rather than the branch so it is
|
||||
# idempotent and behaves identically whether the repo has a bare `main`
|
||||
# or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed.
|
||||
readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main")
|
||||
if readme is None:
|
||||
await self._gitea.create_file(
|
||||
org,
|
||||
content_repo,
|
||||
"README.md",
|
||||
content=readme_text,
|
||||
message=_stamp_single(f"chore: initialise content repo for {project_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=ae,
|
||||
)
|
||||
result = await self._gitea.update_file(
|
||||
org,
|
||||
registry_repo,
|
||||
"projects.yaml",
|
||||
content=projects_yaml_new,
|
||||
sha=projects_yaml_sha,
|
||||
message=_stamp_single(f"chore: create project {project_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"create_project",
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"project_id": project_id, "content_repo": content_repo},
|
||||
)
|
||||
return result
|
||||
|
||||
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
||||
|
||||
async def open_idea_pr(
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""§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
|
||||
Reference in New Issue
Block a user