mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 14:56:25 +00:00
110772580a
24 new tests: setup/login/session lifecycle, invites, resets (with and without SMTP), admin guards and the last-admin lockout guard, resource reassignment on user deletion, cross-user 404s on projects/skills/ connectors/plugins/models/schedules/commands/memory, private model backends rejected at spawn/schedule time, and claude_gen materializing only shared + owner rows at launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
282 lines
12 KiB
Python
282 lines
12 KiB
Python
"""Per-user separation: users see shared resources plus their own — never each
|
|
other's — across projects, agents, schedules, memory, activity, and the Claude page
|
|
resources; the control layer applies only the owner's rows at launch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def users(client):
|
|
"""Three actors: an admin, and two regular users (alice, bob)."""
|
|
r = client.post("/auth/setup", json={"email": "admin@x.co", "password": "admin-pass-1"})
|
|
admin = {"Authorization": f"Bearer {r.json()['token']}"}
|
|
out = {"admin": admin, "admin_user": r.json()["user"]}
|
|
for name in ("alice", "bob"):
|
|
invite = client.post(
|
|
"/auth/users", json={"email": f"{name}@x.co"}, headers=admin
|
|
).json()
|
|
token = invite["invite_url"].split("token=")[1]
|
|
r = client.post("/auth/reset", json={"token": token, "password": f"{name}-pass-1"})
|
|
out[name] = {"Authorization": f"Bearer {r.json()['token']}"}
|
|
out[f"{name}_user"] = r.json()["user"]
|
|
return out
|
|
|
|
|
|
def _mkproject(client, headers, pid):
|
|
r = client.post("/projects", json={"id": pid, "root_dir": f"/tmp/{pid}"}, headers=headers)
|
|
assert r.status_code == 201
|
|
return r.json()
|
|
|
|
|
|
# ---- projects --------------------------------------------------------------------------
|
|
|
|
|
|
def test_projects_are_invisible_across_users(client, users):
|
|
_mkproject(client, users["alice"], "alices")
|
|
_mkproject(client, users["bob"], "bobs")
|
|
|
|
assert [p["id"] for p in client.get("/projects", headers=users["alice"]).json()] == ["alices"]
|
|
assert [p["id"] for p in client.get("/projects", headers=users["bob"]).json()] == ["bobs"]
|
|
# Existence is not leaked: someone else's project 404s, as do its nested routes.
|
|
assert client.get("/projects/bobs", headers=users["alice"]).status_code == 404
|
|
assert client.get("/projects/bobs/agents", headers=users["alice"]).status_code == 404
|
|
assert client.delete("/projects/bobs", headers=users["alice"]).status_code == 404
|
|
# Admin sees and can manage everything.
|
|
assert {p["id"] for p in client.get("/projects", headers=users["admin"]).json()} == {
|
|
"alices", "bobs",
|
|
}
|
|
|
|
|
|
def test_shared_projects_visible_but_admin_managed(client, users, env):
|
|
token_headers = {"Authorization": f"Bearer {env['token']}"}
|
|
_mkproject(client, token_headers, "sharedproj") # env token => shared (owner NULL)
|
|
|
|
assert client.get("/projects/sharedproj", headers=users["alice"]).status_code == 200
|
|
# Viewing yes; mutating no — shared rows are admin-managed.
|
|
r = client.patch(
|
|
"/projects/sharedproj", json={"root_dir": "/tmp/x"}, headers=users["alice"]
|
|
)
|
|
assert r.status_code == 403
|
|
assert client.patch(
|
|
"/projects/sharedproj", json={"root_dir": "/tmp/x"}, headers=users["admin"]
|
|
).status_code == 200
|
|
|
|
|
|
def test_owner_operates_own_project_without_admin(client, users, fake_launch, tmp_path):
|
|
"""A regular user drives the full lifecycle on their own project (spawn needs a
|
|
worker; here we only prove the API-side gates: enqueue allowed, 404 for others)."""
|
|
_mkproject(client, users["alice"], "alices")
|
|
r = client.post(
|
|
"/projects/alices/agents/spawn",
|
|
json={"name": "a1", "task": "do the thing"},
|
|
headers=users["alice"],
|
|
)
|
|
assert r.status_code == 202
|
|
assert r.json()["requested_by"].startswith("user:")
|
|
# Bob can't even see the project, let alone spawn into it.
|
|
r = client.post(
|
|
"/projects/alices/agents/spawn",
|
|
json={"name": "b1", "task": "sneaky"},
|
|
headers=users["bob"],
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_admin_can_reassign_owner(client, users):
|
|
_mkproject(client, users["alice"], "alices")
|
|
bob_id = users["bob_user"]["id"]
|
|
r = client.patch(
|
|
"/projects/alices", json={"owner_user_id": bob_id}, headers=users["admin"]
|
|
)
|
|
assert r.status_code == 200 and r.json()["owner_user_id"] == bob_id
|
|
assert client.get("/projects/alices", headers=users["alice"]).status_code == 404
|
|
assert client.get("/projects/alices", headers=users["bob"]).status_code == 200
|
|
# Owners themselves cannot hand projects around.
|
|
r = client.patch(
|
|
"/projects/alices", json={"owner_user_id": None}, headers=users["bob"]
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
|
|
# ---- claude page resources (skills / connectors / plugins / models) --------------------
|
|
|
|
|
|
def test_skills_are_separated_and_shared_rows_common(client, users, env):
|
|
token_headers = {"Authorization": f"Bearer {env['token']}"}
|
|
mk = lambda h, name: client.post( # noqa: E731
|
|
"/claude/skills", json={"name": name, "content": "# x"}, headers=h
|
|
)
|
|
assert mk(token_headers, "shared-skill").status_code == 201
|
|
alice_skill = mk(users["alice"], "alice-skill")
|
|
assert alice_skill.status_code == 201
|
|
assert alice_skill.json()["owner_user_id"] == users["alice_user"]["id"]
|
|
assert mk(users["bob"], "bob-skill").status_code == 201
|
|
|
|
names = lambda h: {s["name"] for s in client.get("/claude/skills", headers=h).json()} # noqa: E731
|
|
assert names(users["alice"]) == {"shared-skill", "alice-skill"}
|
|
assert names(users["bob"]) == {"shared-skill", "bob-skill"}
|
|
assert names(users["admin"]) == {"shared-skill", "alice-skill", "bob-skill"}
|
|
|
|
# Cross-user mutation 404s (invisible); shared mutation 403s for non-admins.
|
|
alice_id = alice_skill.json()["id"]
|
|
assert client.delete(f"/claude/skills/{alice_id}", headers=users["bob"]).status_code == 404
|
|
shared_id = next(
|
|
s["id"] for s in client.get("/claude/skills", headers=users["admin"]).json()
|
|
if s["name"] == "shared-skill"
|
|
)
|
|
assert client.patch(
|
|
f"/claude/skills/{shared_id}", json={"enabled": False}, headers=users["alice"]
|
|
).status_code == 403
|
|
assert client.delete(f"/claude/skills/{alice_id}", headers=users["alice"]).status_code == 200
|
|
|
|
|
|
def test_connectors_plugins_models_follow_same_rules(client, users):
|
|
a, b = users["alice"], users["bob"]
|
|
r = client.post(
|
|
"/claude/connectors",
|
|
json={"name": "alice-mcp", "transport": "http", "url": "https://a.example/mcp"},
|
|
headers=a,
|
|
)
|
|
assert r.status_code == 201
|
|
r = client.post(
|
|
"/claude/plugins",
|
|
json={"name": "alice-plug", "marketplace": "mp", "marketplace_repo": "o/r"},
|
|
headers=a,
|
|
)
|
|
assert r.status_code == 201
|
|
r = client.post(
|
|
"/claude/models",
|
|
json={"name": "alice-model", "base_url": "http://localhost:4000", "model": "m"},
|
|
headers=a,
|
|
)
|
|
assert r.status_code == 201
|
|
model_id = r.json()["id"]
|
|
|
|
assert client.get("/claude/connectors", headers=b).json() == []
|
|
assert client.get("/claude/plugins", headers=b).json() == []
|
|
assert client.get("/claude/models", headers=b).json() == []
|
|
|
|
# Bob can't spawn or schedule onto Alice's private model backend.
|
|
_mkproject(client, b, "bobs")
|
|
r = client.post(
|
|
"/projects/bobs/agents/spawn",
|
|
json={"name": "b1", "task": "t", "model_id": model_id},
|
|
headers=b,
|
|
)
|
|
assert r.status_code == 400 and "not found" in r.json()["detail"]
|
|
r = client.post(
|
|
"/projects/bobs/schedules",
|
|
json={"name_prefix": "s", "task": "t", "interval_seconds": 3600, "model_id": model_id},
|
|
headers=b,
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ---- schedules, activity, memory -------------------------------------------------------
|
|
|
|
|
|
def test_schedules_follow_project_visibility(client, users):
|
|
_mkproject(client, users["alice"], "alices")
|
|
r = client.post(
|
|
"/projects/alices/schedules",
|
|
json={"name_prefix": "nightly", "task": "t", "interval_seconds": 3600},
|
|
headers=users["alice"],
|
|
)
|
|
assert r.status_code == 201
|
|
sid = r.json()["id"]
|
|
|
|
assert client.get("/schedules", headers=users["bob"]).json() == []
|
|
assert client.get("/projects/alices/schedules", headers=users["bob"]).status_code == 404
|
|
assert client.patch(
|
|
f"/schedules/{sid}", json={"enabled": False}, headers=users["bob"]
|
|
).status_code == 404
|
|
assert len(client.get("/schedules", headers=users["admin"]).json()) == 1
|
|
assert client.delete(f"/schedules/{sid}", headers=users["alice"]).status_code == 200
|
|
|
|
|
|
def test_activity_feed_is_scoped(client, users):
|
|
_mkproject(client, users["alice"], "alices")
|
|
r = client.post(
|
|
"/projects/alices/agents/spawn",
|
|
json={"name": "a1", "task": "t"},
|
|
headers=users["alice"],
|
|
)
|
|
cmd_id = r.json()["id"]
|
|
|
|
assert client.get("/commands", headers=users["bob"]).json() == []
|
|
assert client.get(f"/commands/{cmd_id}", headers=users["bob"]).status_code == 404
|
|
assert client.get(f"/commands/{cmd_id}", headers=users["alice"]).status_code == 200
|
|
assert len(client.get("/commands", headers=users["admin"]).json()) == 1
|
|
|
|
|
|
def test_memory_notes_follow_project_visibility(client, users):
|
|
_mkproject(client, users["alice"], "alices")
|
|
r = client.post(
|
|
"/memory/notes",
|
|
json={"title": "alice fact", "body": "b", "kind": "fact", "project_id": "alices"},
|
|
headers=users["alice"],
|
|
)
|
|
assert r.status_code == 201
|
|
note_id = r.json()["id"]
|
|
|
|
# Global notes reach every user's agents, so only admins write them.
|
|
r = client.post(
|
|
"/memory/notes", json={"title": "global", "body": "b", "kind": "fact"},
|
|
headers=users["alice"],
|
|
)
|
|
assert r.status_code == 403
|
|
assert client.post(
|
|
"/memory/notes", json={"title": "global", "body": "b", "kind": "fact"},
|
|
headers=users["admin"],
|
|
).status_code == 201
|
|
|
|
bob_titles = {n["title"] for n in client.get("/memory/notes", headers=users["bob"]).json()}
|
|
assert bob_titles == {"global"} # global visible, alice's project note not
|
|
assert client.get(f"/memory/notes/{note_id}", headers=users["bob"]).status_code == 404
|
|
graph = client.get("/memory/graph", headers=users["bob"]).json()
|
|
assert {n["title"] for n in graph["notes"]} == {"global"}
|
|
|
|
|
|
# ---- control layer: what a launch materializes -----------------------------------------
|
|
|
|
|
|
def test_launch_applies_only_owner_and_shared_rows(client, users, conn, tmp_path):
|
|
"""claude_gen.apply for a project owned by alice syncs shared + alice's skills and
|
|
connectors — never bob's."""
|
|
from handler.control import claude_gen
|
|
from handler.db import repository as repo
|
|
|
|
alice_id = users["alice_user"]["id"]
|
|
bob_id = users["bob_user"]["id"]
|
|
repo.create_claude_skill(conn, "shared-skill", "# s")
|
|
repo.create_claude_skill(conn, "alice-skill", "# a", owner_user_id=alice_id)
|
|
repo.create_claude_skill(conn, "bob-skill", "# b", owner_user_id=bob_id)
|
|
repo.create_claude_connector(
|
|
conn, "alice-mcp", "http", url="https://a.example/mcp", owner_user_id=alice_id
|
|
)
|
|
repo.create_claude_connector(
|
|
conn, "bob-mcp", "http", url="https://b.example/mcp", owner_user_id=bob_id
|
|
)
|
|
|
|
workdir = tmp_path / "wd"
|
|
workdir.mkdir()
|
|
summary = claude_gen.apply(str(workdir), conn=conn, visible_to=alice_id)
|
|
assert summary["skills_written"] == 2 # shared + alice's
|
|
|
|
import json
|
|
import os
|
|
|
|
mcp = json.load(open(claude_gen.mcp_config_path(str(workdir))))
|
|
assert "alice-mcp" in mcp["mcpServers"] and "bob-mcp" not in mcp["mcpServers"]
|
|
skills_root = os.path.expanduser("~/.claude/skills")
|
|
synced = set(os.listdir(skills_root))
|
|
assert {"shared-skill", "alice-skill"} <= synced and "bob-skill" not in synced
|
|
|
|
# A shared/legacy project (owner None) gets shared rows only.
|
|
summary = claude_gen.apply(str(workdir), conn=conn, visible_to=None)
|
|
assert summary["skills_written"] == 1
|
|
synced = set(os.listdir(skills_root))
|
|
assert "alice-skill" not in synced and "shared-skill" in synced
|