import importlib import pytest from fastapi.testclient import TestClient def _reload_app(tmp_path, monkeypatch, **env): import editor_app.config as config import editor_app.db.session as db_sess import editor_app.main as main monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db")) monkeypatch.setenv("AUTH_REGISTER_OPEN", "true") monkeypatch.setenv("AUTH_INVITE_ONLY", "false") monkeypatch.delenv("EDITOR_API_KEY", raising=False) monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False) monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False) for k, v in env.items(): monkeypatch.setenv(k, v) monkeypatch.setattr(config, "WORKSPACE_ROOT", tmp_path) db_sess.reset_engine() importlib.reload(main) return main.app def test_auth_status_public(tmp_path, monkeypatch): with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client: r = client.get("/api/auth/status") assert r.status_code == 200 assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False} def test_auth_invite_only_defaults_on(monkeypatch, tmp_path): """When AUTH_INVITE_ONLY is unset, require invites (deployment-safe default).""" import editor_app.config as config import editor_app.db.session as db_sess import editor_app.main as main monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db")) monkeypatch.setenv("AUTH_REGISTER_OPEN", "true") monkeypatch.setenv("AUTH_ENABLED", "true") monkeypatch.delenv("AUTH_INVITE_ONLY", raising=False) monkeypatch.delenv("EDITOR_API_KEY", raising=False) monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False) monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False) config.WORKSPACE_ROOT = tmp_path db_sess.reset_engine() importlib.reload(main) with TestClient(main.app) as client: st = client.get("/api/auth/status") assert st.status_code == 200 assert st.json()["invite_required"] is True denied = client.post("/api/auth/register", json={"username": "noc", "password": "password99"}) assert denied.status_code == 403 def test_register_login_and_api_access(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: assert client.get("/api/files").status_code == 401 reg = client.post("/api/auth/register", json={"username": "alice", "password": "hunter2000"}) assert reg.status_code == 200 body = reg.json() assert body["username"] == "alice" assert body["is_superuser"] is True login = client.post("/api/auth/login", json={"username": "alice", "password": "hunter2000"}) assert login.status_code == 200 ok = client.get("/api/files") assert ok.status_code == 200 me = client.get("/api/auth/me") assert me.status_code == 200 assert me.json()["user"]["username"] == "alice" out = client.post("/api/auth/logout") assert out.status_code == 200 assert client.get("/api/files").status_code == 401 def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: reg = client.post("/api/auth/register", json={"username": "alice", "password": "password99"}) assert reg.status_code == 200 assert reg.json()["username"] == "alice" uid = reg.json()["id"] code_root = tmp_path / "users" / f"alice-{uid}" / "code" on_disk = code_root / "main.py" assert on_disk.is_file() assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n' canonical = ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py") for fname in canonical: cp = code_root / fname assert cp.is_file(), f"missing bundled copy {fname} (workspace/code must ship with app)" text = cp.read_text(encoding="utf-8") assert len(text.strip()) > 20 assert "from led_patterns" not in text assert "import led_patterns" not in text assert client.post("/api/auth/login", json={"username": "alice", "password": "password99"}).status_code == 200 fetched = client.get("/api/file/code/main.py") assert fetched.status_code == 200 assert fetched.json()["filename"] == "main.py" assert 'Hello, World!' in fetched.json()["content"] chase = client.get("/api/file/code/pattern_chase_demo.py") assert chase.status_code == 200 assert "knight_rider_scanner_frame" in chase.json()["content"] def test_second_user_not_superuser(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: r1 = client.post("/api/auth/register", json={"username": "usr1", "password": "password99"}) assert r1.status_code == 200 assert r1.json()["is_superuser"] is True r2 = client.post("/api/auth/register", json={"username": "usr2", "password": "password99"}) assert r2.status_code == 200 assert r2.json()["is_superuser"] is False def test_register_duplicate_username(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: assert client.post("/api/auth/register", json={"username": "dupuser", "password": "password99"}).status_code == 200 dup = client.post("/api/auth/register", json={"username": "dupuser", "password": "otherpass1"}) assert dup.status_code == 409 def test_register_closed(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="false") ) as client: r = client.post("/api/auth/register", json={"username": "bob", "password": "password99"}) assert r.status_code == 403 def test_admin_can_create_invite_and_register_with_token(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None}) client.post("/api/auth/login", json={"username": "admin", "password": "password99"}) invite = client.post("/api/users/invites", json={"email": "newuser@example.com", "expires_days": 7}) assert invite.status_code == 200 invite_url = invite.json()["invite_url"] token = invite_url.split("invite=", 1)[1] client.post("/api/auth/logout") reg = client.post( "/api/auth/register", json={"username": "newuser", "password": "password99", "invite_token": token}, ) assert reg.status_code == 200 assert reg.json()["username"] == "newuser" def test_admin_can_create_link_only_invite(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None}) client.post("/api/auth/login", json={"username": "admin", "password": "password99"}) invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7}) assert invite.status_code == 200 body = invite.json() assert body["invite_url"].startswith("http://") assert body["delivered"] is False def test_invite_required_blocks_public_register(tmp_path, monkeypatch): with TestClient( _reload_app( tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true", AUTH_INVITE_ONLY="true", ) ) as client: blocked = client.post("/api/auth/register", json={"username": "plain", "password": "password99"}) assert blocked.status_code == 403 def test_superuser_can_patch_user_account(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "admin", "password": "password99"}) client.post("/api/auth/login", json={"username": "admin", "password": "password99"}) invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7}) token = invite.json()["invite_url"].split("invite=", 1)[1] client.post("/api/auth/logout") sub = client.post( "/api/auth/register", json={"username": "subacc", "password": "original99", "invite_token": token}, ).json() uid = sub["id"] client.post("/api/auth/login", json={"username": "admin", "password": "password99"}) pat = client.patch( f"/api/users/{uid}", json={"username": "renamed", "is_superuser": True, "password": "renewedpw8"}, ) assert pat.status_code == 200 assert pat.json()["username"] == "renamed" assert pat.json()["is_superuser"] is True client.post("/api/auth/logout") assert client.post( "/api/auth/login", json={"username": "renamed", "password": "renewedpw8"}, ).status_code == 200 def test_superuser_cannot_demote_last_admin(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "solo_admin", "password": "password99"}) client.post("/api/auth/login", json={"username": "solo_admin", "password": "password99"}) uid = client.get("/api/auth/me").json()["user"]["id"] demote = client.patch( f"/api/users/{uid}", json={"username": "solo_admin", "is_superuser": False}, ) assert demote.status_code == 400 detail = demote.json().get("detail") or "" assert "last" in detail.lower() or "administrator" in detail.lower() def test_superuser_lists_users_after_invite_signup(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "admin", "password": "password99"}) client.post("/api/auth/login", json={"username": "admin", "password": "password99"}) listed = client.get("/api/users") assert listed.status_code == 200 assert len(listed.json()) == 1 invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7}) assert invite.status_code == 200 token = invite.json()["invite_url"].split("invite=", 1)[1] client.post("/api/auth/logout") reg = client.post( "/api/auth/register", json={"username": "sub", "password": "password99", "invite_token": token}, ) assert reg.status_code == 200 assert reg.json()["username"] == "sub" assert reg.json()["is_superuser"] is False client.post("/api/auth/login", json={"username": "admin", "password": "password99"}) names = {u["username"] for u in client.get("/api/users").json()} assert names == {"admin", "sub"} def test_non_superuser_cannot_list_users(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "boss", "password": "password99"}) client.post("/api/auth/login", json={"username": "boss", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/register", json={"username": "peon", "password": "password99"}) client.post("/api/auth/login", json={"username": "peon", "password": "password99"}) denied = client.get("/api/users") assert denied.status_code == 403 def test_users_have_isolated_workspaces(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "alice", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/register", json={"username": "bob", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/login", json={"username": "alice", "password": "password99"}) save = client.post("/api/file/code/only_alice.py", json={"content": "owner = 'alice'\n"}) assert save.status_code == 200 client.post("/api/auth/logout") client.post("/api/auth/login", json={"username": "bob", "password": "password99"}) missing = client.get("/api/file/code/only_alice.py") assert missing.status_code == 404 listing = client.get("/api/files", params={"path": "code"}) assert listing.status_code == 200 names = {item["name"] for item in listing.json()["files"]} assert "only_alice.py" not in names def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch): """`lib/` lives at the repo root (single source of truth), not under WORKSPACE_ROOT, so we patch `config.PROJECT_ROOT` to give the test its own isolated lib bundle.""" import editor_app.config as config shared_lib = tmp_path / "lib" shared_lib.mkdir(parents=True, exist_ok=True) (shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8") # Mirror canonical demo files so `_seed_canonical_demos_into_code` still works. real_demos = config.PROJECT_ROOT / "workspace" / "code" fake_demos = tmp_path / "workspace" / "code" fake_demos.mkdir(parents=True, exist_ok=True) for fname in ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py"): src = real_demos / fname if src.is_file(): (fake_demos / fname).write_text(src.read_text(encoding="utf-8"), encoding="utf-8") monkeypatch.setattr(config, "PROJECT_ROOT", tmp_path) with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "alice", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/register", json={"username": "bob", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/login", json={"username": "alice", "password": "password99"}) lib_read = client.get("/api/file/lib/shared.py") assert lib_read.status_code == 200 assert "VALUE = 42" in lib_read.json()["content"] lib_write = client.post("/api/file/lib/shared.py", json={"content": "VALUE = 0\n"}) assert lib_write.status_code == 403 client.post("/api/auth/logout") client.post("/api/auth/login", json={"username": "bob", "password": "password99"}) lib_read_bob = client.get("/api/file/lib/shared.py") assert lib_read_bob.status_code == 200 assert "VALUE = 42" in lib_read_bob.json()["content"] def test_superuser_can_open_other_user_workspace(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "admin", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/register", json={"username": "bob", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/login", json={"username": "bob", "password": "password99"}) me = client.get("/api/auth/me").json() bob_id = me["user"]["id"] client.post("/api/file/code/bob_only.py", json={"content": "owner='bob'\n"}) client.post("/api/auth/logout") client.post("/api/auth/login", json={"username": "admin", "password": "password99"}) as_bob = client.get("/api/file/code/bob_only.py", params={"workspace_user_id": bob_id}) assert as_bob.status_code == 200 assert "owner='bob'" in as_bob.json()["content"] def test_non_admin_cannot_override_workspace_user(tmp_path, monkeypatch): with TestClient( _reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") ) as client: client.post("/api/auth/register", json={"username": "admin", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/register", json={"username": "alice", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/register", json={"username": "bob", "password": "password99"}) client.post("/api/auth/logout") client.post("/api/auth/login", json={"username": "alice", "password": "password99"}) denied = client.get("/api/files", params={"workspace_user_id": 1}) assert denied.status_code == 403 def test_login_serves_page(client): r = client.get("/login") assert r.status_code == 200 assert "Sign in" in r.text def test_register_serves_page(client): r = client.get("/register") assert r.status_code == 200 assert "Create account" in r.text