"""§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 -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