/ tests / test_templates.py
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)