Add browser Python editor with Pyodide, user auth, and workspace API

- FastAPI serves static UI, file CRUD under code/ and read-only lib/
- Pyodide worker runs Python and Jedi completions in the browser
- SQLite accounts: login/register, session cookies, superuser user management
- Optional EDITOR_API_KEY, AUTH_* env vars, .env.example
- Pipenv, pytest, Selenium smoke test, README

Made-with: Cursor
This commit is contained in:
2026-05-01 14:33:26 +12:00
parent d245ecd353
commit f204109a84
40 changed files with 4950 additions and 2 deletions

24
tests/conftest.py Normal file
View File

@@ -0,0 +1,24 @@
import importlib
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
with TestClient(main.app) as test_client:
yield test_client

286
tests/test_api.py Normal file
View File

@@ -0,0 +1,286 @@
import importlib
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
def test_root_serves_html(client):
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
def test_editor_route_serves_editor_html(client):
response = client.get("/editor")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Python Editor" in response.text
def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path):
(tmp_path / ".hidden.txt").write_text("secret", encoding="utf-8")
(tmp_path / "visible.txt").write_text("hello", encoding="utf-8")
(tmp_path / "folder").mkdir()
response = client.get("/api/files")
assert response.status_code == 200
files = response.json()["files"]
names = {item["name"] for item in files}
assert ".hidden.txt" not in names
assert "visible.txt" in names
assert "folder" in names
def test_list_files_missing_directory_returns_404(client):
response = client.get("/api/files", params={"path": "does-not-exist"})
assert response.status_code == 404
def test_save_and_read_file_roundtrip(client, tmp_path):
response = client.post("/api/file/code/docs/readme.txt", json={"content": "doc body"})
assert response.status_code == 200
assert (tmp_path / "code" / "docs" / "readme.txt").read_text(encoding="utf-8") == "doc body"
read_response = client.get("/api/file/code/docs/readme.txt")
assert read_response.status_code == 200
assert read_response.json()["content"] == "doc body"
def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
response = client.post("/api/file/code/code/main.py", json={"content": "print('ok')"})
assert response.status_code == 200
assert (tmp_path / "code" / "main.py").read_text(encoding="utf-8") == "print('ok')"
assert not (tmp_path / "code" / "code" / "main.py").exists()
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
lib_dir = tmp_path / "lib"
lib_dir.mkdir(exist_ok=True)
(lib_dir / "helper.py").write_text("x = 1\n", encoding="utf-8")
code_dir = tmp_path / "code"
code_dir.mkdir()
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
assert save_blocked.status_code == 403
delete_blocked = client.delete("/api/file/lib/helper.py")
assert delete_blocked.status_code == 403
move_blocked = client.post(
"/api/file-move",
json={"source_path": "code/main.py", "destination_folder": "lib"},
)
assert move_blocked.status_code == 403
def test_only_code_is_writable(client, tmp_path):
blocked_file = client.post("/api/file/notes.txt", json={"content": "nope"})
assert blocked_file.status_code == 403
blocked_folder = client.post("/api/folder/new/archive", json={"path": "ignored"})
assert blocked_folder.status_code == 403
blocked_prompt = client.post("/api/file/prompts/a.txt", json={"content": "nope"})
assert blocked_prompt.status_code == 403
allowed_code = client.post("/api/file/code/a.txt", json={"content": "ok"})
assert allowed_code.status_code == 200
def test_read_file_errors_for_directory_and_missing(client, tmp_path):
(tmp_path / "docs").mkdir()
dir_response = client.get("/api/file/docs")
assert dir_response.status_code == 400
missing_response = client.get("/api/file/missing.txt")
assert missing_response.status_code == 404
def test_read_file_non_utf8_returns_400(client, tmp_path):
(tmp_path / "bin.dat").write_bytes(b"\xff\xfe\x00")
response = client.get("/api/file/bin.dat")
assert response.status_code == 400
def test_delete_file_success_and_errors(client, tmp_path):
target = tmp_path / "code" / "delete-me.txt"
target.parent.mkdir()
target.write_text("x", encoding="utf-8")
ok = client.delete("/api/file/code/delete-me.txt")
assert ok.status_code == 200
assert not target.exists()
missing = client.delete("/api/file/code/delete-me.txt")
assert missing.status_code == 404
(tmp_path / "code" / "dir").mkdir(parents=True)
directory = client.delete("/api/file/code/dir")
assert directory.status_code == 400
def test_move_file_to_another_folder(client, tmp_path):
source = tmp_path / "code" / "docs" / "note.txt"
source.parent.mkdir(parents=True)
source.write_text("hello", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
response = client.post(
"/api/file-move",
json={"source_path": "code/docs/note.txt", "destination_folder": "code/archive"},
)
assert response.status_code == 200
assert response.json()["new_path"] == "code/archive/note.txt"
assert not source.exists()
assert (tmp_path / "code" / "archive" / "note.txt").exists()
def test_move_file_errors(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "note.txt").write_text("x", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
(tmp_path / "code" / "archive" / "note.txt").write_text("x", encoding="utf-8")
conflict = client.post(
"/api/file-move",
json={"source_path": "code/docs/note.txt", "destination_folder": "code/archive"},
)
assert conflict.status_code == 409
missing = client.post(
"/api/file-move",
json={"source_path": "code/missing.txt", "destination_folder": "code/archive"},
)
assert missing.status_code == 404
def test_move_folder_to_another_folder(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "note.txt").write_text("x", encoding="utf-8")
(tmp_path / "code" / "archive").mkdir(parents=True)
response = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/archive"},
)
assert response.status_code == 200
assert response.json()["new_path"] == "code/archive/docs"
assert response.json()["moved_type"] == "folder"
assert (tmp_path / "code" / "archive" / "docs" / "note.txt").exists()
assert not (tmp_path / "code" / "docs").exists()
def test_move_folder_errors(client, tmp_path):
(tmp_path / "code" / "docs").mkdir(parents=True)
(tmp_path / "code" / "docs" / "nested").mkdir()
(tmp_path / "code" / "archive").mkdir(parents=True)
(tmp_path / "code" / "archive" / "docs").mkdir()
into_child = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/docs/nested"},
)
assert into_child.status_code == 400
name_conflict = client.post(
"/api/file-move",
json={"source_path": "code/docs", "destination_folder": "code/archive"},
)
assert name_conflict.status_code == 409
def test_path_escape_is_blocked(client):
response = client.post("/api/file/%2E%2E/evil.txt", json={"content": "nope"})
assert response.status_code == 400
def test_folder_create_and_delete(client, tmp_path):
create = client.post("/api/folder/new/code/new-folder", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "code" / "new-folder").is_dir()
exists = client.post("/api/folder/new/code/new-folder", json={"path": "ignored"})
assert exists.status_code == 400
delete = client.delete("/api/folder/code/new-folder")
assert delete.status_code == 200
assert not (tmp_path / "code" / "new-folder").exists()
def test_create_folder_collapses_duplicate_scoped_prefix(client, tmp_path):
(tmp_path / "code").mkdir()
create = client.post("/api/folder/new/code/code/nested", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "code" / "nested").is_dir()
assert not (tmp_path / "code" / "code").exists()
def test_folder_delete_errors(client, tmp_path):
missing = client.delete("/api/folder/code/missing")
assert missing.status_code == 404
(tmp_path / "code").mkdir()
(tmp_path / "code" / "file.txt").write_text("x", encoding="utf-8")
not_dir = client.delete("/api/folder/code/file.txt")
assert not_dir.status_code == 400
def test_workspace_py_sources_returns_python_files(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
(tmp_path / "lib").mkdir(exist_ok=True)
(tmp_path / "lib" / "util.py").write_text("def f():\n pass\n", encoding="utf-8")
response = client.get("/api/workspace/py-sources")
assert response.status_code == 200
files = response.json()["files"]
assert files["code/app.py"] == "x = 1\n"
assert "lib/util.py" in files
def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
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_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("EDITOR_API_KEY", "secret-token")
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 app_client:
blocked = app_client.get("/api/files")
assert blocked.status_code == 401
ok = app_client.get("/api/files", headers={"Authorization": "Bearer secret-token"})
assert ok.status_code == 200
def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
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_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
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)
assert not (tmp_path / "lib").exists()
with TestClient(main.app) as _client:
_client.get("/api/auth/status")
assert (tmp_path / "lib").is_dir()

133
tests/test_auth.py Normal file
View File

@@ -0,0 +1,133 @@
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.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)
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}
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_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_superuser_lists_and_creates_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": "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
created = client.post(
"/api/users",
json={"username": "sub", "password": "password99", "is_superuser": False},
)
assert created.status_code == 200
assert created.json()["username"] == "sub"
assert created.json()["is_superuser"] is False
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_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

101
tests/test_browser.py Normal file
View File

@@ -0,0 +1,101 @@
import importlib
import socket
import subprocess
import sys
import time
from pathlib import Path
import pytest
def _is_port_open(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.3)
return sock.connect_ex(("127.0.0.1", port)) == 0
@pytest.mark.integration
def test_browser_create_file_not_forced_into_new(tmp_path):
playwright = pytest.importorskip("playwright.sync_api")
sync_playwright = playwright.sync_playwright
editor_dir = Path(__file__).resolve().parents[1]
port = 8123
env = dict(
**__import__("os").environ,
WORKSPACE_ROOT=str(tmp_path),
AUTH_ENABLED="false",
AUTH_DATABASE_PATH=str(tmp_path / "playwright_auth.db"),
)
server = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"app:app",
"--app-dir",
"src",
"--host",
"127.0.0.1",
"--port",
str(port),
],
cwd=str(editor_dir),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
for _ in range(50):
if _is_port_open(port):
break
time.sleep(0.1)
else:
pytest.fail("Server did not start in time")
with sync_playwright() as p:
try:
browser = p.chromium.launch()
except Exception as exc: # pragma: no cover
pytest.skip(f"Playwright browser not installed: {exc}")
page = browser.new_page()
page.goto(f"http://127.0.0.1:{port}/editor", wait_until="networkidle")
page.click("#new-file-btn")
page.fill("#new-filename", "browser-test.txt")
page.click("#create-file-btn")
page.wait_for_timeout(500)
browser.close()
assert (tmp_path / "code" / "browser-test.txt").exists()
assert not (tmp_path / "new" / "browser-test.txt").exists()
finally:
server.terminate()
try:
server.wait(timeout=3)
except subprocess.TimeoutExpired:
server.kill()
def test_new_file_uses_api_file_route(tmp_path, monkeypatch):
import editor_app.config as config
import editor_app.db.session as db_sess
import editor_app.main as main
from fastapi.testclient import TestClient
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth_route.db"))
config.WORKSPACE_ROOT = tmp_path
db_sess.reset_engine()
importlib.reload(main)
client = TestClient(main.app)
response = client.post(
"/api/file/code/routing-check.txt",
json={"content": "ok"},
)
assert response.status_code == 200
assert (tmp_path / "code" / "routing-check.txt").exists()
assert not (tmp_path / "new" / "routing-check.txt").exists()

45
tests/test_internal.py Normal file
View File

@@ -0,0 +1,45 @@
import importlib
from pathlib import Path
import pytest
def test_load_env_file_sets_missing_keys_only(tmp_path, monkeypatch):
import editor_app.config as config
env_file = tmp_path / ".env"
env_file.write_text(
"# comment\nFOO=bar\nBAZ='quoted'\nEXISTING=from_file\n", encoding="utf-8"
)
monkeypatch.setenv("EXISTING", "kept")
monkeypatch.delenv("FOO", raising=False)
monkeypatch.delenv("BAZ", raising=False)
config.load_env_file(env_file)
assert __import__("os").environ["FOO"] == "bar"
assert __import__("os").environ["BAZ"] == "quoted"
assert __import__("os").environ["EXISTING"] == "kept"
def test_load_env_file_ignores_missing_file(tmp_path):
import editor_app.config as config
config.load_env_file(tmp_path / "missing.env")
def test_collect_python_sources_skips_hidden_and_binary(tmp_path):
import editor_app.config as config
import editor_app.services.filesystem as filesystem
(tmp_path / "code").mkdir()
(tmp_path / "code" / "ok.py").write_text("a = 1\n", encoding="utf-8")
(tmp_path / "code" / ".venv").mkdir()
(tmp_path / "code" / ".venv" / "skip.py").write_text("x\n", encoding="utf-8")
(tmp_path / "bad.py").write_bytes(b"\xff\xff")
config.WORKSPACE_ROOT = tmp_path
out = filesystem.collect_python_sources()
assert out["code/ok.py"] == "a = 1\n"
assert "bad.py" not in out

View File

@@ -0,0 +1,48 @@
"""Selenium smoke tests — need a running app (see README)."""
from __future__ import annotations
import os
import urllib.error
import urllib.request
import pytest
pytest.importorskip("selenium.webdriver")
def _server_reachable(base_url: str, timeout: float = 2.0) -> bool:
try:
urllib.request.urlopen(f"{base_url.rstrip('/')}/", timeout=timeout)
return True
except (urllib.error.URLError, OSError):
return False
@pytest.fixture
def driver():
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
opts = Options()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
try:
browser = webdriver.Chrome(options=opts)
except Exception as exc: # pragma: no cover
pytest.skip(f"Chrome / ChromeDriver not available: {exc}")
yield browser
browser.quit()
@pytest.mark.selenium
def test_home_page_title(driver):
base = os.environ.get("SELENIUM_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
if not _server_reachable(base):
pytest.skip(
f"No server at {base}. In another terminal run: "
"pipenv run dev (then re-run this test, or set SELENIUM_BASE_URL)."
)
driver.get(f"{base}/")
assert "Python Editor" in driver.title