999c4b65ef
Implements the M3-frontend slice of the §22 multi-project track, per docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in #10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began. Frontend: - DeploymentProvider boots GET /api/deployment → {name, tagline, defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback. - /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout fetches /api/projects/:id, applies per-project theme (reset on switch), provides ProjectContext, guards the corpus (served only for the default; others get NotServedPlaceholder — decouples this slice from Plan B). - Directory at / (2+ projects) with N=1 redirect into the single project; ProjectSwitcher in deployment chrome; entry-noun by project type. - VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand reads now use deployment.name via context; static <title>RFC</title> + JS document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals via lib/entryPaths. Backend: - GET /api/deployment returns default_project_id (the guard contract). - Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> → /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to the backend. Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass); clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds a registry (see CHANGELOG 0.35.0 step 5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
5.2 KiB
Python
133 lines
5.2 KiB
Python
"""§22.9/§22.5 — GET /api/deployment + GET /api/projects/:id with visibility."""
|
|
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 _add_project(pid, name, vis, typ="document"):
|
|
from app import db
|
|
db.conn().execute(
|
|
"INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
|
"VALUES (?, ?, ?, ?, ?, 'super-draft')",
|
|
(pid, name, typ, pid, vis),
|
|
)
|
|
|
|
|
|
def test_deployment_lists_public_omits_gated_and_unlisted_for_anon(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_project("pub", "Public", "public")
|
|
_add_project("gat", "Gated", "gated")
|
|
_add_project("unl", "Unlisted", "unlisted")
|
|
r = client.get("/api/deployment")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["name"] == "Test Deployment"
|
|
ids = {p["id"] for p in body["projects"]}
|
|
assert "pub" in ids and "default" in ids # both public
|
|
assert "gat" not in ids # gated, anon not a member
|
|
assert "unl" not in ids # unlisted never enumerated
|
|
|
|
|
|
def test_projects_id_404_for_gated_non_member(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_project("gat", "Gated", "gated")
|
|
assert client.get("/api/projects/gat").status_code == 404
|
|
|
|
|
|
def test_projects_id_returns_config_for_public(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
from app import db
|
|
db.conn().execute(
|
|
"UPDATE projects SET config_json = ? WHERE id = 'default'",
|
|
('{"theme": {"accent": "#5b5bd6"}}',),
|
|
)
|
|
r = client.get("/api/projects/default")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["id"] == "default"
|
|
assert body["type"] == "document"
|
|
assert body["visibility"] == "public"
|
|
assert body["theme"] == {"accent": "#5b5bd6"}
|
|
|
|
|
|
def test_projects_id_unlisted_readable_by_direct_id(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_project("unl", "Unlisted", "unlisted")
|
|
assert client.get("/api/projects/unl").status_code == 200
|
|
|
|
|
|
def test_projects_id_unknown_returns_404_even_for_superuser(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")
|
|
assert client.get("/api/projects/does-not-exist").status_code == 404
|
|
|
|
|
|
def test_projects_id_unknown_returns_404_for_anon(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
assert client.get("/api/projects/nope").status_code == 404
|
|
|
|
|
|
def test_deployment_includes_default_project_id(app_with_fake_gitea):
|
|
# §22.10 / M3-frontend guard contract: the frontend learns which project
|
|
# is the corpus-served (default) one from the deployment payload.
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
body = client.get("/api/deployment").json()
|
|
assert body["default_project_id"] == "default"
|
|
|
|
|
|
def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
|
# §22.10 / §5: old corpus-root /rfc/<slug> → 308 /p/<default>/e/<slug>.
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
r = client.get("/rfc/human", follow_redirects=False)
|
|
assert r.status_code == 308
|
|
assert r.headers["location"] == "/p/default/e/human"
|
|
|
|
|
|
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
|
# The old per-RFC PR deep link is preserved too.
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
r = client.get("/rfc/human/pr/7", follow_redirects=False)
|
|
assert r.status_code == 308
|
|
assert r.headers["location"] == "/p/default/e/human/pr/7"
|
|
|
|
|
|
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
r = client.get("/proposals/42", follow_redirects=False)
|
|
assert r.status_code == 308
|
|
assert r.headers["location"] == "/p/default/proposals/42"
|
|
|
|
|
|
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
|
from app import db
|
|
app, _ = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
_add_project("teamx", "Team X", "gated")
|
|
provision_user_row(user_id=5, login="mia", role="contributor")
|
|
db.conn().execute(
|
|
"INSERT INTO project_members (project_id, user_id, role) VALUES ('teamx', 5, 'project_viewer')"
|
|
)
|
|
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
|
|
# member sees the gated project in the deployment directory
|
|
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
|
|
assert "teamx" in ids
|
|
# member can read it directly
|
|
r = client.get("/api/projects/teamx")
|
|
assert r.status_code == 200
|
|
assert r.json()["id"] == "teamx"
|