`workspace/` is runtime state (per-user folders, no-auth dev's `code/`) and shouldn't be in git. The same files were previously committed under both `workspace/code/` and `src/static/bundled-demos/`, which forced a Docker `diff -q` sync check and leaked user-scoped paths into version control. - /workspace/ added to .gitignore; all previously tracked files removed via `git rm --cached`. - src/static/bundled-demos/ becomes the single source of truth: panel16 demos, led_tutorial, led_patterns, neopixel demos, and main.py move here alongside the existing canonical demos. - New BUNDLED_DEMOS_DIR config; user_workspace seeders read from it. - main.py lifespan seeds WORKSPACE_ROOT/code/ on startup so a fresh clone running `pipenv run dev` still gets the full sample set (existing files never overwritten — user edits survive restarts). - Dockerfile drops `COPY workspace` and the diff sanity check. - README/LED_TUTORIAL repointed at the new canonical paths. - test_led_patterns loads led_patterns.py from bundled-demos. - test_api uses mkdir(exist_ok=True) for `code/` (startup pre-creates). Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
11 KiB
Python
295 lines
11 KiB
Python
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 "LED 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 "LED Editor" in response.text
|
|
|
|
|
|
def test_lib_served_directly_from_repo_bundle(client, tmp_path):
|
|
"""`lib/` reads come straight from repo `lib/`; no workspace cache is created."""
|
|
resp = client.get("/api/file/lib/machine.py")
|
|
assert resp.status_code == 200
|
|
body = resp.json().get("content", "")
|
|
assert "class Pin" in body and len(body) > 0
|
|
assert not (tmp_path / "lib").exists()
|
|
|
|
|
|
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):
|
|
code_dir = tmp_path / "code"
|
|
code_dir.mkdir(exist_ok=True)
|
|
(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/machine.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(exist_ok=True)
|
|
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(exist_ok=True)
|
|
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(exist_ok=True)
|
|
(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(exist_ok=True)
|
|
(tmp_path / "code" / "app.py").write_text("x = 1\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"
|
|
# Bundled stubs from repo `lib/` are merged in automatically.
|
|
assert "lib/machine.py" in files
|
|
assert "lib/neopixel.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_does_not_seed_workspace_lib(tmp_path, monkeypatch):
|
|
"""Bundle stubs are read directly from repo `lib/`, so startup must not create
|
|
a `WORKSPACE_ROOT/lib` cache."""
|
|
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 not (tmp_path / "lib").exists()
|