Add admin invites and user workspace management tools.
Implement invite-token registration with optional email delivery, add admin UI actions for creating invites and opening user workspaces, and support superuser workspace override while preserving per-user code isolation with shared read-only lib. Made-with: Cursor
This commit is contained in:
@@ -11,6 +11,8 @@ def _reload_app(tmp_path, monkeypatch, **env):
|
||||
|
||||
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)
|
||||
@@ -26,7 +28,7 @@ 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}
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False}
|
||||
|
||||
|
||||
def test_register_login_and_api_access(tmp_path, monkeypatch):
|
||||
@@ -85,6 +87,53 @@ def test_register_closed(tmp_path, monkeypatch):
|
||||
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_lists_and_creates_users(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
@@ -121,6 +170,93 @@ def test_non_superuser_cannot_list_users(tmp_path, monkeypatch):
|
||||
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):
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user