Add community node protocol spec, Agent Skills, and make the failing tests hermetic
DevPlace CI / test (push) Failing after 31m42s
DevPlace CI / test (push) Failing after 31m42s
Community nodes (Phase 1 of plan.md): the protocol specification in services/nodes/spec.py as the single source of truth, GET /nodes/spec.json, a generated protocol docs page, the Community nodes API docs group, and the implementation plan plus the residential proxy research. The no-open-proxy rule is enforced in the spec (open_proxy cannot be set). Agent Skills: register the Skills API docs group, cascade skills on account deletion, add the nested CLAUDE.md, README and routes docs, and cover the parser, the fetcher (end to end against a mock origin), the data layer and the routes with 73 unit and 16 api tests. Tests made hermetic with the repo's existing patterns instead of ambient environment: embeddings tests set OPENROUTER_API_KEY via monkeypatch, the workspace tests install FakeBackend via runtime.set_backend, and the skills install tests use run_async. DockerCliBackend now returns 127 (command not found) when the CLI is missing instead of raising FileNotFoundError, so a host without docker answers with a clean error rather than a 500. The Providers tab empty state no longer names the Model routes tab. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -194,3 +194,34 @@ def test_provider_edit_page_404_for_missing_provider(seeded_db):
|
||||
admin.post(f"{BASE_URL}/admin/gateway/providers/{missing}/delete").status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_provider_protocol_round_trip(seeded_db):
|
||||
admin = admin_session(seeded_db)
|
||||
name = _unique_gateway("protop").lower()
|
||||
|
||||
created = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers",
|
||||
json={
|
||||
"name": name,
|
||||
"base_url": "https://api.meta.ai/v1/responses",
|
||||
"api_key": "sk-test",
|
||||
"is_active": True,
|
||||
"protocol": "responses",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200, created.text[:300]
|
||||
assert created.json()["provider"]["protocol"] == "responses"
|
||||
|
||||
listed = admin.get(f"{BASE_URL}/admin/gateway/providers").json()
|
||||
match = next((p for p in listed["providers"] if p["name"] == name), None)
|
||||
assert match is not None
|
||||
assert match["protocol"] == "responses"
|
||||
|
||||
bad = admin.post(
|
||||
f"{BASE_URL}/admin/gateway/providers",
|
||||
json={"name": _unique_gateway("protobad").lower(), "protocol": "soap"},
|
||||
)
|
||||
assert bad.status_code in (400, 422)
|
||||
|
||||
assert admin.delete(f"{BASE_URL}/admin/gateway/providers/{name}").status_code == 200
|
||||
|
||||
@@ -225,3 +225,50 @@ def test_an_explicitly_disabled_account_still_cannot_log_in(seeded_db):
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_login_json_returns_api_key_and_member_flag(seeded_db):
|
||||
_, name = _member()
|
||||
row = _db_user(name)
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
headers=JSON_audit_log,
|
||||
data={"email": f"{name}@t.dev", "password": "secret123"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200, response.text[:300]
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
assert body["data"]["username"] == name
|
||||
assert body["data"]["email"] == f"{name}@t.dev"
|
||||
assert body["data"]["api_key"] == row["api_key"]
|
||||
assert body["data"]["is_admin"] is False
|
||||
|
||||
|
||||
def test_login_json_returns_api_key_and_admin_flag(seeded_db):
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
headers=JSON_audit_log,
|
||||
data={
|
||||
"email": seeded_db["alice"]["email"],
|
||||
"password": seeded_db["alice"]["password"],
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200, response.text[:300]
|
||||
body = response.json()
|
||||
assert body["data"]["username"] == "alice_test"
|
||||
assert body["data"]["email"] == seeded_db["alice"]["email"]
|
||||
assert body["data"]["api_key"] == _db_user("alice_test")["api_key"]
|
||||
assert body["data"]["is_admin"] is True
|
||||
|
||||
|
||||
def test_login_failure_json_leaks_no_key(app_server):
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
headers=JSON_audit_log,
|
||||
data={"email": f"{_unique('aunokey')}@t.dev", "password": "wrongpassword"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "api_key" not in response.text
|
||||
|
||||
@@ -36,3 +36,41 @@ def test_signup_short_username_rerenders_with_message(app_server):
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "Username must be between 3 and 32 characters" in r.text
|
||||
|
||||
|
||||
def test_signup_json_returns_api_key_and_admin_flag(seeded_db):
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
name = f"sgk{int(time.time() * 1000000)}"
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
headers={"Accept": "application/json"},
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["data"]["username"] == name
|
||||
assert body["data"]["email"] == f"{name}@t.dev"
|
||||
assert body["data"]["is_admin"] is False
|
||||
refresh_snapshot()
|
||||
row = get_table("users").find_one(username=name)
|
||||
assert row is not None
|
||||
assert body["data"]["api_key"] == row["api_key"]
|
||||
me = requests.get(
|
||||
f"{BASE_URL}/profile",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"X-API-KEY": body["data"]["api_key"],
|
||||
},
|
||||
)
|
||||
assert me.status_code == 200
|
||||
assert me.json()["profile_user"]["username"] == name
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,39 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import requests
|
||||
|
||||
from devplacepy.services.nodes.spec import SPEC
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_spec_json_is_public_and_matches_the_spec(app_server):
|
||||
response = requests.get(f"{BASE_URL}/nodes/spec.json")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
assert response.json() == SPEC.describe()
|
||||
|
||||
|
||||
def test_spec_json_never_describes_an_open_proxy(app_server):
|
||||
body = requests.get(f"{BASE_URL}/nodes/spec.json").json()
|
||||
assert body["open_proxy"] is False
|
||||
assert body["allowed_ports"] == [80, 443]
|
||||
|
||||
|
||||
def test_protocol_docs_page_renders_every_spec_value(app_server):
|
||||
response = requests.get(f"{BASE_URL}/docs/nodes-protocol.html")
|
||||
assert response.status_code == 200
|
||||
page = response.text
|
||||
for frame in SPEC.frames:
|
||||
assert f"<code>{frame.type}</code>" in page
|
||||
for destination in SPEC.destinations:
|
||||
assert destination.suffix in page
|
||||
for refusal in SPEC.refusals:
|
||||
assert refusal.code in page
|
||||
for name in SPEC.limits.describe():
|
||||
assert name in page
|
||||
|
||||
|
||||
def test_nodes_api_docs_group_lists_the_spec_endpoint(app_server):
|
||||
response = requests.get(f"{BASE_URL}/docs/nodes.html")
|
||||
assert response.status_code == 200
|
||||
assert "/nodes/spec.json" in response.text
|
||||
@@ -707,4 +707,59 @@ def test_the_owner_gets_the_privacy_controls(app_server):
|
||||
assert "Only the account holder can change this." not in html
|
||||
|
||||
|
||||
def _profile_json_body(session, username):
|
||||
return session.get(
|
||||
f"{BASE_URL}/profile/{username}",
|
||||
headers={"Accept": "application/json"},
|
||||
).json()
|
||||
|
||||
|
||||
def test_owner_sees_own_key_and_member_flag(app_server, seeded_db):
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
session, name = _privacy_member("keyo")
|
||||
refresh_snapshot()
|
||||
expected = get_table("users").find_one(username=name)["api_key"]
|
||||
body = _profile_json_body(session, name)
|
||||
assert body["profile_is_admin"] is False
|
||||
assert body["viewer_is_admin"] is False
|
||||
assert body["is_owner"] is True
|
||||
assert body["can_view_api_key"] is True
|
||||
assert body["api_key"] == expected
|
||||
|
||||
|
||||
def test_admin_sees_member_key_and_flags(app_server, seeded_db):
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
_, name = _privacy_member("keya")
|
||||
refresh_snapshot()
|
||||
expected = get_table("users").find_one(username=name)["api_key"]
|
||||
body = _profile_json_body(_admin_session(), name)
|
||||
assert body["profile_is_admin"] is False
|
||||
assert body["viewer_is_admin"] is True
|
||||
assert body["is_owner"] is False
|
||||
assert body["can_view_api_key"] is True
|
||||
assert body["api_key"] == expected
|
||||
|
||||
|
||||
def test_admin_profile_marks_admin_flag_for_guest(app_server, seeded_db):
|
||||
body = requests.get(
|
||||
f"{BASE_URL}/profile/alice_test",
|
||||
headers={"Accept": "application/json"},
|
||||
).json()
|
||||
assert body["profile_is_admin"] is True
|
||||
assert body["viewer_is_admin"] is False
|
||||
assert body["can_view_api_key"] is False
|
||||
assert body["api_key"] is None
|
||||
|
||||
|
||||
def test_stranger_gets_no_key(app_server, seeded_db):
|
||||
_, name = _privacy_member("keys")
|
||||
stranger, _ = _privacy_member("keyv")
|
||||
body = _profile_json_body(stranger, name)
|
||||
assert body["profile_is_admin"] is False
|
||||
assert body["can_view_api_key"] is False
|
||||
assert body["api_key"] is None
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import pytest
|
||||
|
||||
from devplacepy.content import can_manage_workspace, can_open_workspace
|
||||
from devplacepy.database import get_table, init_db, set_setting
|
||||
from devplacepy.services.containers import activity, api, store
|
||||
from devplacepy.services.containers import activity, api, runtime, store
|
||||
from devplacepy.services.containers.backend.fake import FakeBackend
|
||||
from devplacepy.services.containers.workspace import (
|
||||
editor,
|
||||
flags,
|
||||
@@ -29,7 +30,9 @@ HTTP_TIMEOUT = 30
|
||||
def _workspace_db():
|
||||
init_db()
|
||||
set_setting("workspace_enabled", "1")
|
||||
runtime.set_backend(FakeBackend())
|
||||
yield
|
||||
runtime.set_backend(None)
|
||||
for table in (
|
||||
"instances",
|
||||
"tunnels",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,185 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database.skills import get_skill_row, upsert_skill
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def _user_skills(name):
|
||||
return get_table("users").find_one(username=name)
|
||||
|
||||
|
||||
def _member_skills():
|
||||
name = f"skl{uuid.uuid4().hex[:10]}"
|
||||
requests.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
row = _user_skills(name)
|
||||
session = requests.Session()
|
||||
session.headers.update({"X-API-KEY": row["api_key"], **JSON})
|
||||
return session, row
|
||||
|
||||
|
||||
def _admin_skills(seeded_db):
|
||||
session = requests.Session()
|
||||
session.headers.update({"X-API-KEY": _user_skills("alice_test")["api_key"], **JSON})
|
||||
return session
|
||||
|
||||
|
||||
def _seed_skills(user_uid, name, description="Does a thing."):
|
||||
row, _ = upsert_skill(
|
||||
user_uid,
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"body": "# Body\n\nSteps.",
|
||||
"files": json.dumps({"scripts/run.py": "print(1)"}),
|
||||
"source_url": f"https://example.org/{name}/SKILL.md",
|
||||
"license": "",
|
||||
"compatibility": "",
|
||||
"metadata": json.dumps({}),
|
||||
"allowed_tools": "",
|
||||
"sha256": "0" * 64,
|
||||
"skipped": json.dumps([]),
|
||||
"warnings": json.dumps([]),
|
||||
},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def test_skills_require_a_signed_in_user(app_server):
|
||||
assert requests.get(f"{BASE_URL}/skills", headers=JSON, allow_redirects=False).status_code == 401
|
||||
assert requests.post(
|
||||
f"{BASE_URL}/skills/install",
|
||||
data={"url": "https://example.org"},
|
||||
headers=JSON,
|
||||
allow_redirects=False,
|
||||
).status_code == 401
|
||||
assert requests.get(f"{BASE_URL}/skills/anything", headers=JSON, allow_redirects=False).status_code == 401
|
||||
|
||||
|
||||
def test_list_and_detail_json(app_server):
|
||||
session, user = _member_skills()
|
||||
_seed_skills(user["uid"], "listed", "Listed skill.")
|
||||
listing = session.get(f"{BASE_URL}/skills").json()
|
||||
assert listing["skill_total"] == 1
|
||||
assert listing["skill_limit"] == 50
|
||||
assert [item["name"] for item in listing["items"]] == ["listed"]
|
||||
assert "body" not in listing["items"][0]
|
||||
detail = session.get(f"{BASE_URL}/skills/listed").json()["skill"]
|
||||
assert detail["body"] == "# Body\n\nSteps."
|
||||
assert detail["files"] == {"scripts/run.py": "print(1)"}
|
||||
|
||||
|
||||
def test_html_pages_render_and_are_noindex(app_server):
|
||||
session, user = _member_skills()
|
||||
_seed_skills(user["uid"], "paged", "Paged skill.")
|
||||
html_headers = {"Accept": "text/html"}
|
||||
listing = session.get(f"{BASE_URL}/skills", headers=html_headers)
|
||||
assert listing.status_code == 200
|
||||
assert "paged" in listing.text
|
||||
assert "noindex" in listing.text
|
||||
detail = session.get(f"{BASE_URL}/skills/paged", headers=html_headers)
|
||||
assert detail.status_code == 200
|
||||
assert "noindex" in detail.text
|
||||
|
||||
|
||||
def test_skills_are_invisible_to_other_users(app_server):
|
||||
owner_session, owner = _member_skills()
|
||||
other_session, _ = _member_skills()
|
||||
_seed_skills(owner["uid"], "private-one")
|
||||
assert other_session.get(f"{BASE_URL}/skills/private-one").status_code == 404
|
||||
assert other_session.get(f"{BASE_URL}/skills").json()["items"] == []
|
||||
assert other_session.post(f"{BASE_URL}/skills/private-one/enabled", data={"enabled": "false"}).status_code == 404
|
||||
assert other_session.post(f"{BASE_URL}/skills/private-one/delete").status_code == 404
|
||||
assert get_skill_row(owner["uid"], "private-one") is not None
|
||||
|
||||
|
||||
def test_toggle_flips_and_sets_explicitly(app_server):
|
||||
session, user = _member_skills()
|
||||
_seed_skills(user["uid"], "toggled")
|
||||
assert session.post(f"{BASE_URL}/skills/toggled/enabled").json() == {"name": "toggled", "enabled": False}
|
||||
assert session.post(f"{BASE_URL}/skills/toggled/enabled").json() == {"name": "toggled", "enabled": True}
|
||||
assert session.post(f"{BASE_URL}/skills/toggled/enabled", data={"enabled": "false"}).json()["enabled"] is False
|
||||
assert get_skill_row(user["uid"], "toggled")["enabled"] == 0
|
||||
assert session.post(f"{BASE_URL}/skills/nope/enabled").status_code == 404
|
||||
|
||||
|
||||
def test_delete_is_soft_and_audited(app_server):
|
||||
session, user = _member_skills()
|
||||
row = _seed_skills(user["uid"], "doomed")
|
||||
assert session.post(f"{BASE_URL}/skills/doomed/delete").json() == {"deleted": True, "name": "doomed"}
|
||||
assert get_skill_row(user["uid"], "doomed") is None
|
||||
physical = get_table("skills").find_one(uid=row["uid"])
|
||||
assert physical["deleted_at"] is not None and physical["deleted_by"] == user["uid"]
|
||||
assert session.get(f"{BASE_URL}/skills/doomed").status_code == 404
|
||||
keys = {event["event_key"] for event in get_table("audit_log").find(target_uid=row["uid"])}
|
||||
assert "skill.delete" in keys
|
||||
|
||||
|
||||
def test_a_member_cannot_delete_another_users_skill(app_server):
|
||||
member, _ = _member_skills()
|
||||
_, owner = _member_skills()
|
||||
_seed_skills(owner["uid"], "protected")
|
||||
response = member.post(f"{BASE_URL}/skills/protected/delete", data={"owner": owner["username"]})
|
||||
assert response.status_code == 403
|
||||
assert get_skill_row(owner["uid"], "protected") is not None
|
||||
|
||||
|
||||
def test_an_administrator_can_delete_another_users_skill(seeded_db):
|
||||
_, owner = _member_skills()
|
||||
_seed_skills(owner["uid"], "admin-removed")
|
||||
admin = _admin_skills(seeded_db)
|
||||
assert admin.post(
|
||||
f"{BASE_URL}/skills/admin-removed/delete", data={"owner": owner["username"]}
|
||||
).status_code == 200
|
||||
assert get_skill_row(owner["uid"], "admin-removed") is None
|
||||
missing = admin.post(f"{BASE_URL}/skills/x/delete", data={"owner": "no_such_user_xyz"})
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_install_failures_are_400_with_a_reason(app_server):
|
||||
session, _ = _member_skills()
|
||||
for url in (
|
||||
"ftp://example.org/SKILL.md",
|
||||
"https://user:pw@example.org/SKILL.md",
|
||||
"http://127.0.0.1:1/SKILL.md",
|
||||
"http://169.254.169.254/SKILL.md",
|
||||
):
|
||||
response = session.post(f"{BASE_URL}/skills/install", data={"url": url})
|
||||
assert response.status_code == 400, url
|
||||
assert response.json()["error"]["message"], url
|
||||
assert session.get(f"{BASE_URL}/skills").json()["skill_total"] == 0
|
||||
|
||||
|
||||
def test_install_failure_from_a_browser_redirects_with_the_error(app_server):
|
||||
session, _ = _member_skills()
|
||||
response = session.post(
|
||||
f"{BASE_URL}/skills/install",
|
||||
data={"url": "ftp://example.org/SKILL.md"},
|
||||
headers={"Accept": "text/html"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"].startswith("/skills?error=")
|
||||
|
||||
|
||||
def test_install_validates_the_form(app_server):
|
||||
session, _ = _member_skills()
|
||||
assert session.post(f"{BASE_URL}/skills/install", data={"url": ""}).status_code in (400, 422)
|
||||
@@ -0,0 +1,157 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import devplacepy.main as m
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database.skills import get_skill_row
|
||||
from devplacepy.routers import skills as skills_router
|
||||
from devplacepy.services.skills import limits
|
||||
from devplacepy.services.skills.fetcher import FetchedSkill, SkillFetchError, SkippedFile
|
||||
from devplacepy.services.skills.parser import parse_skill_md
|
||||
from devplacepy.services.skills.prompt import skill_index_lines
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def _signup_install():
|
||||
name = f"ins{uuid.uuid4().hex[:10]}"
|
||||
requests.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return get_table("users").find_one(username=name)
|
||||
|
||||
|
||||
def _fetched(name="installed-skill", description="Installed through the route."):
|
||||
parsed = parse_skill_md(f"---\nname: {name}\ndescription: {description}\n---\n# Body\n\n`scripts/run.py`")
|
||||
return FetchedSkill(
|
||||
parsed=parsed,
|
||||
source_url=f"https://skills.example/{name}/SKILL.md",
|
||||
files={"scripts/run.py": "print(1)"},
|
||||
skipped=[SkippedFile("assets/logo.png", "binary or unsupported file type; only text files are stored")],
|
||||
warnings=["a warning"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app_server):
|
||||
return TestClient(m.app)
|
||||
|
||||
|
||||
def _headers(user):
|
||||
return {"X-API-KEY": user["api_key"], **JSON}
|
||||
|
||||
|
||||
def test_install_through_the_route_stores_audits_and_feeds_the_prompt(client, monkeypatch):
|
||||
user = _signup_install()
|
||||
|
||||
async def fake_fetch(url):
|
||||
return _fetched()
|
||||
|
||||
monkeypatch.setattr(skills_router, "fetch_skill", fake_fetch)
|
||||
response = client.post("/skills/install", data={"url": "https://skills.example/x"}, headers=_headers(user))
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["action"] == "install"
|
||||
skill = body["skill"]
|
||||
assert skill["name"] == "installed-skill"
|
||||
assert skill["files"] == {"scripts/run.py": "print(1)"}
|
||||
assert skill["skipped"] == [
|
||||
{"path": "assets/logo.png", "reason": "binary or unsupported file type; only text files are stored"}
|
||||
]
|
||||
assert skill["warnings"] == ["a warning"]
|
||||
assert len(skill["sha256"]) == 64
|
||||
assert get_skill_row(user["uid"], "installed-skill")["enabled"] == 1
|
||||
assert "installed-skill: Installed through the route." in skill_index_lines(user["uid"])
|
||||
audit = [e for e in get_table("audit_log").find(target_uid=skill["uid"]) if e["event_key"] == "skill.install"]
|
||||
assert len(audit) == 1 and audit[0]["result"] == "success"
|
||||
|
||||
|
||||
def test_reinstalling_updates_in_place_and_keeps_the_uid(client, monkeypatch):
|
||||
user = _signup_install()
|
||||
calls = {"description": "First."}
|
||||
|
||||
async def fake_fetch(url):
|
||||
return _fetched(description=calls["description"])
|
||||
|
||||
monkeypatch.setattr(skills_router, "fetch_skill", fake_fetch)
|
||||
first = client.post("/skills/install", data={"url": "https://skills.example/x"}, headers=_headers(user)).json()
|
||||
calls["description"] = "Second."
|
||||
second = client.post("/skills/install", data={"url": "https://skills.example/x"}, headers=_headers(user)).json()
|
||||
assert first["action"] == "install" and second["action"] == "update"
|
||||
assert first["skill"]["uid"] == second["skill"]["uid"]
|
||||
assert second["skill"]["description"] == "Second."
|
||||
listing = client.get("/skills", headers=_headers(user)).json()
|
||||
assert listing["skill_total"] == 1
|
||||
keys = [e["event_key"] for e in get_table("audit_log").find(target_uid=first["skill"]["uid"])]
|
||||
assert "skill.install" in keys and "skill.update" in keys
|
||||
|
||||
|
||||
def test_a_failed_fetch_is_audited_as_a_failure_and_stores_nothing(client, monkeypatch):
|
||||
user = _signup_install()
|
||||
|
||||
async def failing_fetch(url):
|
||||
raise SkillFetchError("the server returned 500")
|
||||
|
||||
monkeypatch.setattr(skills_router, "fetch_skill", failing_fetch)
|
||||
response = client.post("/skills/install", data={"url": "https://skills.example/x"}, headers=_headers(user))
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["message"] == "the server returned 500"
|
||||
assert client.get("/skills", headers=_headers(user)).json()["skill_total"] == 0
|
||||
failures = [
|
||||
e
|
||||
for e in get_table("audit_log").find(actor_uid=user["uid"])
|
||||
if e["event_key"] == "skill.install" and e["result"] == "failure"
|
||||
]
|
||||
assert len(failures) == 1
|
||||
|
||||
|
||||
def test_the_per_user_cap_refuses_a_new_install_but_allows_an_update(client, monkeypatch):
|
||||
user = _signup_install()
|
||||
current = {"name": "cap-0"}
|
||||
|
||||
async def fake_fetch(url):
|
||||
return _fetched(name=current["name"])
|
||||
|
||||
monkeypatch.setattr(skills_router, "fetch_skill", fake_fetch)
|
||||
for index in range(limits.SKILLS_PER_USER_MAX):
|
||||
current["name"] = f"cap-{index}"
|
||||
assert client.post("/skills/install", data={"url": "https://s.example/x"}, headers=_headers(user)).status_code == 200
|
||||
current["name"] = "one-too-many"
|
||||
refused = client.post("/skills/install", data={"url": "https://s.example/x"}, headers=_headers(user))
|
||||
assert refused.status_code == 400
|
||||
assert "at most" in refused.json()["error"]["message"]
|
||||
current["name"] = "cap-0"
|
||||
assert client.post("/skills/install", data={"url": "https://s.example/x"}, headers=_headers(user)).json()["action"] == "update"
|
||||
|
||||
|
||||
def test_a_browser_install_redirects_to_the_list(client, monkeypatch):
|
||||
user = _signup_install()
|
||||
|
||||
async def fake_fetch(url):
|
||||
return _fetched(name="browser-skill")
|
||||
|
||||
monkeypatch.setattr(skills_router, "fetch_skill", fake_fetch)
|
||||
response = client.post(
|
||||
"/skills/install",
|
||||
data={"url": "https://skills.example/x"},
|
||||
headers={"X-API-KEY": user["api_key"], "Accept": "text/html"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/skills"
|
||||
assert get_skill_row(user["uid"], "browser-skill") is not None
|
||||
@@ -0,0 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import requests
|
||||
from tests.api.uploads.uploadurl import _key, _upload_settings # noqa: F401
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_generate_image_requires_auth():
|
||||
response = requests.post(f"{BASE_URL}/uploads/generate-image", data={"prompt": "a cat"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_generate_image_rejects_empty_prompt():
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/uploads/generate-image",
|
||||
headers={"X-API-KEY": _key()},
|
||||
data={"prompt": ""},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert response.status_code != 201
|
||||
|
||||
|
||||
def test_generate_image_upstream_failure_is_a_clean_json_error():
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/uploads/generate-image",
|
||||
headers={"X-API-KEY": _key()},
|
||||
data={"prompt": "a lighthouse at dusk"},
|
||||
)
|
||||
assert response.status_code in (201, 502)
|
||||
body = response.json()
|
||||
if response.status_code == 502:
|
||||
assert body["error"]
|
||||
else:
|
||||
assert body["uid"]
|
||||
Reference in New Issue
Block a user