Boot: - Editor now picks local vs server mode based on URL flag, sign-in state, and a stale local-mode flag. Signed-in users are no longer bounced to IndexedDB if they had previously clicked "Use locally". Local mode: - New LocalWorkspaceClient (src/static/local-workspace.js) with pluggable IndexedDB and File System Access backends. Picked folder handles persist across reloads with a Reconnect button when the permission lapses. - Static-only host: scripts/serve_static_editor.py serves src/static/ with COOP/COEP so SharedArrayBuffer-backed sims keep working. - Bundled MicroPython stubs ship under src/static/bundled-lib/ for static hosting; FastAPI also exposes them at /api/public/lib-bundle. Workspace import / export: - Zero-dep ZIP encoder + reader (STORE + DEFLATE via DecompressionStream). Export/Import buttons in the workspace badge work in both local and server modes; imports are confined to code/. Pin / ADC / Serial simulation: - machine.py grows ADC, UART, expanded Pin, and PWM mocks, all driven by SharedArrayBuffer when cross-origin isolated and falling back to postMessage + [pin-out] stdout markers otherwise — pins, ADC slider, and serial input now keep working over plain HTTP / LAN-IP origins. - NeoPixel pins are claimed via a [pin-claim] marker and dropped from the Pins panel so the data line doesn't flicker per write(). - New demos: adc_slider_demo.py, pin_demo.py, serial_demo.py. Lib layout: - Single source of truth at repo lib/; workspace/lib/ caching layer removed and the directory deleted. Filesystem service reads stubs directly from PROJECT_ROOT/lib. UI: - Home page slimmed to "Sign in" + "Use locally" with optional editor / manage-users links. Admin user/invite UI moved to /users. - Workspace badge gains storage indicator, Folder…/Reconnect, Export, Import, and Exit controls. - Mobile-friendly tweaks: safer-area padding, larger touch targets, iOS-zoom-proof serial input, file-tree highlight fix. Tests: - test_auth.py patches PROJECT_ROOT for the lib-shared test so the repo-root lib refactor stays green. test_api.py asserts the new "LED Editor" branding. Co-authored-by: Cursor <cursoragent@cursor.com>
394 lines
17 KiB
Python
394 lines
17 KiB
Python
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
|