test_templates.py
1 """Project template library tests. 2 3 Covers the publish → list → instantiate round-trip plus the visibility 4 rules (private vs team vs public) and ownership checks on update/delete. 5 The LLM access wiring is already covered by the existing `create_project` 6 path; these tests just verify templates add the right abstraction on top. 7 """ 8 from __future__ import annotations 9 10 import json 11 import random 12 13 import pytest 14 from fastapi.testclient import TestClient 15 16 from restai.config import RESTAI_DEFAULT_PASSWORD 17 from restai.main import app 18 19 20 ADMIN = ("admin", RESTAI_DEFAULT_PASSWORD) 21 22 23 @pytest.fixture(scope="module") 24 def client(): 25 with TestClient(app) as c: 26 yield c 27 28 29 @pytest.fixture(scope="module") 30 def source_project(client): 31 """Create an agent project we can publish templates from. Module-scoped 32 so multiple tests share the same source.""" 33 teams = client.get("/teams", auth=ADMIN).json().get("teams", []) or [] 34 if not teams: 35 pytest.skip("no team available") 36 llms = (client.get("/info", auth=ADMIN).json() or {}).get("llms") or [] 37 if not llms: 38 pytest.skip("no LLMs configured") 39 40 name = f"template_src_{random.randint(0, 999999)}" 41 r = client.post( 42 "/projects", 43 json={"name": name, "type": "agent", "llm": llms[0]["name"], "team_id": teams[0]["id"]}, 44 auth=ADMIN, 45 ) 46 if r.status_code not in (200, 201): 47 pytest.skip(f"could not create source project: {r.status_code} {r.text}") 48 body = r.json() 49 # Create returns {"project": <id>}, not a full record. 50 pid = body.get("id") or body.get("project") 51 assert pid, f"unexpected create response: {body}" 52 yield {"id": pid, "team_id": teams[0]["id"], "llm": llms[0]["name"]} 53 client.delete(f"/projects/{pid}", auth=ADMIN) 54 55 56 # ─── Publish + list + get ────────────────────────────────────────────── 57 58 def test_publish_private_template(client, source_project): 59 r = client.post( 60 f"/projects/{source_project['id']}/publish-template", 61 json={"name": "test-private-tpl", "description": "hi", "visibility": "private"}, 62 auth=ADMIN, 63 ) 64 assert r.status_code == 201, r.text 65 body = r.json() 66 assert body["name"] == "test-private-tpl" 67 assert body["visibility"] == "private" 68 assert body["project_type"] == "agent" 69 assert body["suggested_llm"] == source_project["llm"] 70 tid = body["id"] 71 try: 72 # It shows up in our list 73 listing = client.get("/templates", auth=ADMIN).json() 74 ids = [t["id"] for t in listing] 75 assert tid in ids 76 77 # And individually 78 r = client.get(f"/templates/{tid}", auth=ADMIN) 79 assert r.status_code == 200 80 assert r.json()["name"] == "test-private-tpl" 81 finally: 82 client.delete(f"/templates/{tid}", auth=ADMIN) 83 84 85 def test_publish_team_visibility_requires_team(client): 86 """A template-less agent project (no team_id) can't go to team scope. 87 We can't easily create a teamless project via the API — but we can 88 assert the 400 path for a missing team on an unusual request. Use 89 an ad-hoc project with the API if it allows null team; otherwise 90 skip.""" 91 teams = client.get("/teams", auth=ADMIN).json().get("teams", []) or [] 92 if not teams: 93 pytest.skip("no team available") 94 # Validation happens server-side when visibility='team' and source 95 # has no team. Our projects always have a team through the API, 96 # so this path is only hit on raw DB state. Skip instead of faking. 97 pytest.skip("all projects created via API have a team; path covered by code review") 98 99 100 def test_list_filters_public_for_other_users(client, source_project): 101 """Create a second (non-admin) user and confirm they see PUBLIC 102 templates but not PRIVATE ones that aren't theirs.""" 103 other_username = f"tplviewer_{random.randint(0,999999)}" 104 other_password = "test_pw_123" 105 r = client.post("/users", json={"username": other_username, "password": other_password}, auth=ADMIN) 106 assert r.status_code in (200, 201) 107 other_auth = (other_username, other_password) 108 109 try: 110 # Admin publishes one PRIVATE + one PUBLIC 111 priv = client.post( 112 f"/projects/{source_project['id']}/publish-template", 113 json={"name": "only-admin-sees", "visibility": "private"}, 114 auth=ADMIN, 115 ).json() 116 pub = client.post( 117 f"/projects/{source_project['id']}/publish-template", 118 json={"name": "everyone-sees", "visibility": "public"}, 119 auth=ADMIN, 120 ).json() 121 122 try: 123 # Non-admin sees public, not private 124 listing = client.get("/templates", auth=other_auth).json() 125 ids = [t["id"] for t in listing] 126 assert pub["id"] in ids, "public template must be visible to other users" 127 assert priv["id"] not in ids, "private template must NOT be visible to other users" 128 129 # Direct fetch: 404 on private, 200 on public 130 assert client.get(f"/templates/{priv['id']}", auth=other_auth).status_code == 404 131 assert client.get(f"/templates/{pub['id']}", auth=other_auth).status_code == 200 132 finally: 133 client.delete(f"/templates/{priv['id']}", auth=ADMIN) 134 client.delete(f"/templates/{pub['id']}", auth=ADMIN) 135 finally: 136 client.delete(f"/users/{other_username}", auth=ADMIN) 137 138 139 def test_update_requires_owner(client, source_project): 140 """Non-owner, non-admin can't edit someone else's template.""" 141 r = client.post( 142 f"/projects/{source_project['id']}/publish-template", 143 json={"name": "owner-only", "visibility": "public"}, 144 auth=ADMIN, 145 ) 146 tid = r.json()["id"] 147 148 other_username = f"tpledit_{random.randint(0,999999)}" 149 client.post("/users", json={"username": other_username, "password": "x"}, auth=ADMIN) 150 other_auth = (other_username, "x") 151 try: 152 r = client.patch(f"/templates/{tid}", json={"name": "hijacked"}, auth=other_auth) 153 assert r.status_code == 403 154 # Owner CAN edit 155 r = client.patch(f"/templates/{tid}", json={"name": "renamed"}, auth=ADMIN) 156 assert r.status_code == 200 157 assert r.json()["name"] == "renamed" 158 finally: 159 client.delete(f"/templates/{tid}", auth=ADMIN) 160 client.delete(f"/users/{other_username}", auth=ADMIN) 161 162 163 # ─── Instantiate ─────────────────────────────────────────────────────── 164 165 def test_instantiate_creates_new_project(client, source_project): 166 """Publish a template carrying a bespoke system prompt, then 167 instantiate it and verify the new project has that prompt.""" 168 # Set a distinctive system prompt on the source first. 169 client.patch( 170 f"/projects/{source_project['id']}", 171 json={"system": "You are the instantiation-test bot."}, 172 auth=ADMIN, 173 ) 174 175 tpl = client.post( 176 f"/projects/{source_project['id']}/publish-template", 177 json={"name": "inst-tpl", "visibility": "public"}, 178 auth=ADMIN, 179 ).json() 180 tid = tpl["id"] 181 182 new_name = f"inst_proj_{random.randint(0,999999)}" 183 new_pid = None 184 try: 185 r = client.post( 186 f"/templates/{tid}/instantiate", 187 json={"name": new_name, "team_id": source_project["team_id"], "llm": source_project["llm"]}, 188 auth=ADMIN, 189 ) 190 assert r.status_code == 201, r.text 191 body = r.json() 192 assert body["name"] == new_name 193 new_pid = body["id"] 194 195 # Fetch the new project — system prompt must match the template 196 proj = client.get(f"/projects/{new_pid}", auth=ADMIN).json() 197 assert proj["system"] == "You are the instantiation-test bot." 198 199 # use_count bumped 200 t_after = client.get(f"/templates/{tid}", auth=ADMIN).json() 201 assert t_after["use_count"] == 1 202 finally: 203 if new_pid: 204 client.delete(f"/projects/{new_pid}", auth=ADMIN) 205 client.delete(f"/templates/{tid}", auth=ADMIN) 206 207 208 def test_instantiate_rejects_duplicate_name(client, source_project): 209 tpl = client.post( 210 f"/projects/{source_project['id']}/publish-template", 211 json={"name": "dup-check-tpl", "visibility": "public"}, 212 auth=ADMIN, 213 ).json() 214 tid = tpl["id"] 215 try: 216 # source_project.id already exists with its name — reuse that name 217 # to trigger the 409. 218 src = client.get(f"/projects/{source_project['id']}", auth=ADMIN).json() 219 r = client.post( 220 f"/templates/{tid}/instantiate", 221 json={"name": src["name"], "team_id": source_project["team_id"], "llm": source_project["llm"]}, 222 auth=ADMIN, 223 ) 224 assert r.status_code == 409 225 finally: 226 client.delete(f"/templates/{tid}", auth=ADMIN) 227 228 229 def test_delete_requires_owner(client, source_project): 230 tpl = client.post( 231 f"/projects/{source_project['id']}/publish-template", 232 json={"name": "del-check", "visibility": "public"}, 233 auth=ADMIN, 234 ).json() 235 tid = tpl["id"] 236 237 other_username = f"tpldel_{random.randint(0,999999)}" 238 client.post("/users", json={"username": other_username, "password": "x"}, auth=ADMIN) 239 try: 240 r = client.delete(f"/templates/{tid}", auth=(other_username, "x")) 241 assert r.status_code == 403 242 # Owner can 243 r = client.delete(f"/templates/{tid}", auth=ADMIN) 244 assert r.status_code == 200 245 finally: 246 client.delete(f"/users/{other_username}", auth=ADMIN)