Files
treg/tests/test_sandbox.py
SToneX e1e92cc15f test: drain background writes around every test
Many fixtures call reset_db() themselves without draining the previous
test's background writes first. On Postgres a call record committed
between reset_db's delete of callrecord and its delete of org broke the
foreign key and errored the next test's setup. test_orgs_isolation's env
fixture left one behind for test_orgs_mgmt in CI. One that lands after
the reset would instead attach to the next test's rewound ids.

An autouse fixture now drains before and after every test. Autouse
fixtures set up first and tear down last, so it brackets every other
fixture's reset, and the drains that clients and anon ran at setup are
no longer needed.
2026-09-28 13:33:08 +08:00

240 lines
12 KiB
Python

"""Landing-page sandbox STUDIO — the anonymous, throwaway team a visitor builds in-browser and
keeps using from their terminal for a TTL.
Covers: minting a team without auth; the visitor building their own secret + endpoints with the
sandbox token (real product endpoints); synthetic calls that inject for real but never hit the
network (the safety boundary); per-sandbox caps; skill export; org-scoped resolution; rate limit; GC.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from conftest import drain_background_writes, make_upstream
from treg import sandbox
from treg.routers.onboard import SANDBOX_RATE_MAX
from treg.api import app
from treg.infra.db import reset_db, session_maker
from treg.models import Secret, Tool, User
@pytest.fixture
async def anon():
"""A fresh, UNAUTHENTICATED client + clean rate-limit state (the mint endpoint is the anon door).
reset_db() also clears the DB-backed sandbox throttle (the `ephemeral` table)."""
await reset_db()
app.state.http = AsyncClient(transport=ASGITransport(app=make_upstream()), base_url="http://upstream")
try:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://registry") as c:
yield c
finally:
await drain_background_writes()
await app.state.http.aclose()
def _h(tok: str) -> dict:
return {"X-Treg-Token": tok}
STRIPE = sandbox.DEFAULTS[0] # {"secret":"STRIPE_KEY","value":…,"tool":"stripe","base":…}
async def test_mint_creates_a_starter_team(anon):
r = await anon.post("/demo/sandbox")
assert r.status_code == 200, r.text
s = r.json()
assert s["token"] and s["org_slug"].startswith("sbx-")
# one live endpoint seeded (stripe); both keys in the vault (posthog is vault-only, for the add-row)
tools = (await anon.get("/tools", headers=_h(s["token"]))).json()
assert {t["name"] for t in tools} == {d["tool"] for d in sandbox.DEFAULTS if d.get("tool")} # stripe
secrets = (await anon.get("/secrets", headers=_h(s["token"]))).json()
assert {x["name"] for x in secrets} == {d["secret"] for d in sandbox.DEFAULTS} # STRIPE_KEY + POSTHOG_KEY
async def test_call_synthesizes_and_injects_without_network(anon):
"""The safety boundary: the call injects for REAL (visible) but never leaves treg."""
tok = (await anon.post("/demo/sandbox")).json()["token"]
r = await anon.get(f"/call/{STRIPE['base']}/{STRIPE['example']['path']}", headers=_h(tok))
assert r.status_code == 200, r.text
body = r.json()
assert body["sandbox"] is True
assert body["injected"]["headers"]["Authorization"] == f"Bearer {STRIPE['value']}"
assert body["data"]["object"] == "list" # brand-shaped dummy body
async def test_visitor_builds_own_secret_and_endpoint(anon):
tok = (await anon.post("/demo/sandbox")).json()["token"]
# add my own secret + endpoint via the SAME product endpoints the dashboard uses
assert (await anon.post("/secrets", json={"name": "MYKEY", "value": "s3cr3t", "kind": "env"},
headers=_h(tok))).status_code == 200
sid = [s for s in (await anon.get("/secrets", headers=_h(tok))).json() if s["name"] == "MYKEY"][0]["id"]
r = await anon.post("/tools", json={
"name": "weather", "base_url": "https://api.weather.io",
"bindings": [{"secret_id": sid, "injector": "env", "location": "header",
"name": "X-Api-Key", "format": "{secret}"}]}, headers=_h(tok))
assert r.status_code == 200, r.text
call = (await anon.get("/call/https://api.weather.io/today", headers=_h(tok))).json()
assert call["injected"]["headers"]["X-Api-Key"] == "s3cr3t"
async def test_caps(anon):
tok = (await anon.post("/demo/sandbox")).json()["token"] # starts seeded
n_secrets = len(sandbox.DEFAULTS) # every DEFAULT seeds a secret
n_tools = sum(1 for d in sandbox.DEFAULTS if d.get("tool")) # only entries with a "tool" seed an endpoint
# secrets: seeded=n_secrets, add up to MAX, then reject
for i in range(sandbox.MAX_SECRETS - n_secrets):
assert (await anon.post("/secrets", json={"name": f"S{i}", "value": "v"}, headers=_h(tok))).status_code == 200
over_secret = await anon.post(
"/secrets", json={"name": "OVER", "value": "v"}, headers=_h(tok))
assert over_secret.status_code == 422
assert over_secret.json()["detail"] == "the sandbox is limited to 3 secrets — sign up for more"
# endpoints: seeded=n_tools, add up to MAX, then reject
for i in range(sandbox.MAX_TOOLS - n_tools):
assert (await anon.post("/tools", json={"name": f"t{i}", "base_url": f"https://h{i}.io"},
headers=_h(tok))).status_code == 200
over_tool = await anon.post(
"/tools", json={"name": "over", "base_url": "https://x.io"}, headers=_h(tok))
assert over_tool.status_code == 422
assert over_tool.json()["detail"] == "the sandbox is limited to 3 endpoints — sign up for more"
async def test_call_still_org_scoped(anon):
"""Interception doesn't loosen resolution — an unregistered host still 404s."""
tok = (await anon.post("/demo/sandbox")).json()["token"]
assert (await anon.get("/call/https://not-registered.com/", headers=_h(tok))).status_code == 404
async def test_export_skill(anon):
tok = (await anon.post("/demo/sandbox")).json()["token"]
r = await anon.get("/demo/sandbox/skill", headers=_h(tok))
assert r.status_code == 200, r.text
exp = r.json()
assert exp["manifest"]["tools"] and exp["manifest"]["secrets"]
assert STRIPE["tool"] in exp["treg_json"]
assert "SKILL" in exp["skill_md"].upper() or "skill" in exp["skill_md"]
# a manifest never carries real secret values — only placeholders
assert STRIPE["value"] not in exp["treg_json"]
async def test_skill_install_script(anon):
r = await anon.get("/skills/posthog-insights/install.sh", params={"token": "sbx_tok_123"})
assert r.status_code == 200, r.text
body = r.text
assert body.startswith("#!/bin/sh")
assert ".claude/skills" in body and "sbx_tok_123" in body # writes the folder + bakes the token
assert "/call/https://app.posthog.com" in body # calls through the treg proxy
assert (await anon.get("/skills/nope/install.sh")).status_code == 404
async def test_export_skill_rejects_non_sandbox(anon):
tok = (await anon.post("/users", json={"email": "real@x.io"})).json()["token"] # a normal org
assert (await anon.get("/demo/sandbox/skill", headers=_h(tok))).status_code == 400
async def test_rate_limited_per_ip(anon):
for _ in range(SANDBOX_RATE_MAX):
assert (await anon.post("/demo/sandbox")).status_code == 200
rejected = await anon.post("/demo/sandbox")
assert rejected.status_code == 429
assert rejected.json()["detail"] == "too many demo sandboxes from here — try again later"
async def test_gc_reaps_expired_sandboxes(anon):
tok = (await anon.post("/demo/sandbox")).json()["token"]
# gc deletes the visitor's org rows. A fire-and-forget audit or archive write still in flight
# holds the SQLite write lock, gc skips that visitor rather than raising, and the count comes
# back one short. Drain what the requests above scheduled before reaping.
await drain_background_writes()
async with session_maker() as db:
u = (await db.execute(
select(User).where(User.email.like(f"visitor-%@{sandbox.SANDBOX_DOMAIN}")))).scalar_one()
u.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=sandbox.SANDBOX_TTL_MIN + 5)
await db.commit()
assert await sandbox.gc(db) == 1
assert (await anon.get(f"/call/{STRIPE['base']}/{STRIPE['example']['path']}", headers=_h(tok))).status_code in (401, 404)
async with session_maker() as db:
assert not (await db.execute(select(Tool))).scalars().all()
assert not (await db.execute(select(Secret))).scalars().all()
async def test_gc_reaps_a_sandbox_that_made_an_idempotent_call(anon):
"""The production failure of 2026-09-02, end to end. A visitor's call carrying an
`Idempotency-Key` leaves an `IdempotentCall` row pointing at their membership. The sandbox
reaper kept its OWN list of org-scoped tables, which never learned about that table, so Postgres
refused to delete the membership and `gc` raised - from inside `mint_sandbox`, so every visitor
after the first expired one got a 500 instead of a sandbox.
SQLite does not enforce foreign keys, so on SQLite this test only sees the orphaned row; on
Postgres (CI) it sees the raise itself. Both are the same bug."""
from treg.models import IdempotentCall, Membership, Org
tok = (await anon.post("/demo/sandbox")).json()["token"]
r = await anon.get(f"/call/{STRIPE['base']}/{STRIPE['example']['path']}",
headers={**_h(tok), "Idempotency-Key": "retry-1"})
assert r.status_code == 200, r.text
await drain_background_writes()
async with session_maker() as db:
assert (await db.execute(select(IdempotentCall))).scalars().all(), (
"the call did not leave an IdempotentCall row - this test no longer reproduces the bug")
u = (await db.execute(
select(User).where(User.email.like(f"visitor-%@{sandbox.SANDBOX_DOMAIN}")))).scalar_one()
u.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=sandbox.SANDBOX_TTL_MIN + 5)
await db.commit()
assert await sandbox.gc(db) == 1
async with session_maker() as db:
for model in (IdempotentCall, Membership, Org, User):
assert not (await db.execute(select(model))).scalars().all(), f"{model.__name__} rows survived gc"
# and the front door still opens afterwards
assert (await anon.post("/demo/sandbox")).status_code == 200
async def test_a_failing_reaper_does_not_close_the_front_door(anon, monkeypatch):
"""gc runs inside the mint request. Whatever it raises is a bug to log, never a 500 for the
visitor - that coupling is what turned one undeletable sandbox into six hours of downtime."""
from treg.application.onboard import sandbox as onboard_sandbox
async def boom(db):
raise RuntimeError("reaper bug")
monkeypatch.setattr(onboard_sandbox, "gc", boom)
r = await anon.post("/demo/sandbox")
assert r.status_code == 200, r.text
assert r.json()["token"]
async def test_gc_skips_a_sandbox_it_cannot_delete_and_reaps_the_rest(anon, monkeypatch):
"""Reaping is per visitor, not all-or-nothing. Before, one undeletable sandbox rolled back every
other delete too, and since gc only runs from mint, nothing was ever reaped again."""
from treg.application.onboard import sandbox as onboard_sandbox
from treg.models import Org
slugs = [(await anon.post("/demo/sandbox")).json()["org_slug"] for _ in range(2)]
poisoned = slugs[0]
real_cascade = onboard_sandbox.cascade_delete_org
async def cascade_unless_poisoned(org, db):
if org.slug == poisoned:
raise RuntimeError("this one will not delete")
await real_cascade(org, db)
monkeypatch.setattr(onboard_sandbox, "cascade_delete_org", cascade_unless_poisoned)
await drain_background_writes()
async with session_maker() as db:
for u in (await db.execute(
select(User).where(User.email.like(f"visitor-%@{sandbox.SANDBOX_DOMAIN}")))).scalars().all():
u.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=sandbox.SANDBOX_TTL_MIN + 5)
await db.commit()
assert await sandbox.gc(db) == 1
async with session_maker() as db:
assert {o.slug for o in (await db.execute(select(Org))).scalars().all()} == {poisoned}
# once the poisoned one is deletable again, the next reap takes it
monkeypatch.setattr(onboard_sandbox, "cascade_delete_org", real_cascade)
async with session_maker() as db:
assert await sandbox.gc(db) == 1
async with session_maker() as db:
assert not (await db.execute(select(Org))).scalars().all()