chore: reorganize test files into domain-specific subdirectories under tests/
Split the monolithic test directory into three tiers (unit, api, e2e) with a path-mirroring directory structure. Added corresponding Makefile targets (test-unit, test-api, test-e2e) and updated all documentation references (CLAUDE.md, README.md, testing-cicd.html, testing-framework.html, testing-make.html) to reflect the new layout and naming conventions.
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.audit import store, query
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.audit.categories import category_for
|
||||
from devplacepy.services.audit.record import _sanitize_summary, _actor_from, _coerce_scalar
|
||||
def _now_audit():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tables():
|
||||
store.ensure_tables()
|
||||
yield
|
||||
def _cli_user(role="Member"):
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
username = f"auditcli_{generate_uid()[:8]}"
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"username": username,
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": generate_uid(),
|
||||
"role": role,
|
||||
"is_active": True,
|
||||
"created_at": _now_audit(),
|
||||
}
|
||||
)
|
||||
return username
|
||||
def _latest(event_key, **match):
|
||||
rows = sorted(
|
||||
get_table(store.AUDIT_TABLE).find(event_key=event_key),
|
||||
key=lambda r: r.get("created_at", ""),
|
||||
reverse=True,
|
||||
)
|
||||
for row in rows:
|
||||
if all(row.get(k) == v for k, v in match.items()):
|
||||
return row
|
||||
return None
|
||||
_counter_audit = [0]
|
||||
def audit_uid_marker():
|
||||
_counter_audit[0] += 1
|
||||
return f"{int(datetime.now(timezone.utc).timestamp() * 1000)}_{_counter_audit[0]}"
|
||||
|
||||
|
||||
def test_category_for_known_prefixes():
|
||||
assert category_for("auth.login.success") == "auth"
|
||||
assert category_for("vote.post.up") == "engagement"
|
||||
assert category_for("reaction.add") == "engagement"
|
||||
assert category_for("file.write.create") == "project_files"
|
||||
assert category_for("dir.create") == "project_files"
|
||||
assert category_for("job.zip.complete") == "project_files"
|
||||
assert category_for("admin.setting.update") == "admin"
|
||||
assert category_for("container.instance.start") == "container"
|
||||
assert category_for("devii.turn") == "devii"
|
||||
assert category_for("cli.role.set") == "cli"
|
||||
assert category_for("reward.xp.grant") == "reward"
|
||||
assert category_for("security.authz.denied") == "security"
|
||||
assert category_for("proxy.access") == "ingress"
|
||||
assert category_for("ai.gateway.call") == "ai"
|
||||
|
||||
|
||||
def test_category_for_unknown_falls_back_to_first_segment():
|
||||
assert category_for("totallyunknown.event") == "totallyunknown"
|
||||
assert category_for("") == "other"
|
||||
|
||||
|
||||
def test_sanitize_summary_strips_html_and_truncates():
|
||||
assert _sanitize_summary("<b>hi</b> <script>x()</script> there") == "hi x() there"
|
||||
assert _sanitize_summary(None) is None
|
||||
assert _sanitize_summary("") is None
|
||||
long = "a" * 500
|
||||
out = _sanitize_summary(long)
|
||||
assert len(out) <= 140
|
||||
assert out.endswith("...")
|
||||
|
||||
|
||||
def test_actor_from_resolves_role():
|
||||
assert _actor_from(None)["actor_kind"] == "guest"
|
||||
assert _actor_from(None)["actor_role"] == "guest"
|
||||
member = _actor_from({"uid": "u1", "username": "m", "role": "Member"})
|
||||
assert member["actor_kind"] == "user" and member["actor_role"] == "member"
|
||||
admin = _actor_from({"uid": "u2", "username": "a", "role": "Admin"})
|
||||
assert admin["actor_role"] == "admin"
|
||||
|
||||
|
||||
def test_coerce_scalar():
|
||||
assert _coerce_scalar(None) is None
|
||||
assert _coerce_scalar(True) == "1"
|
||||
assert _coerce_scalar(False) == "0"
|
||||
assert _coerce_scalar(5) == "5"
|
||||
assert _coerce_scalar("x") == "x"
|
||||
|
||||
|
||||
def test_record_system_inserts_row_with_fields():
|
||||
key = f"test.unit.basic.{audit_uid_marker()}"
|
||||
uid = audit.record_system(
|
||||
key,
|
||||
actor_kind="user",
|
||||
actor_uid="actor-1",
|
||||
actor_username="alice",
|
||||
actor_role="admin",
|
||||
target_type="post",
|
||||
target_uid="post-1",
|
||||
target_label="Hello",
|
||||
old_value=0,
|
||||
new_value=1,
|
||||
summary="<i>did</i> a thing",
|
||||
metadata={"k": "v", "n": 3},
|
||||
result="success",
|
||||
)
|
||||
assert uid
|
||||
row = store.get_event(uid)
|
||||
assert row["event_key"] == key
|
||||
assert row["category"] == "test"
|
||||
assert row["actor_kind"] == "user"
|
||||
assert row["actor_uid"] == "actor-1"
|
||||
assert row["actor_role"] == "admin"
|
||||
assert row["target_type"] == "post"
|
||||
assert row["old_value"] == "0" and row["new_value"] == "1"
|
||||
assert row["summary"] == "did a thing"
|
||||
assert row["result"] == "success"
|
||||
import json
|
||||
|
||||
assert json.loads(row["metadata"]) == {"k": "v", "n": 3}
|
||||
|
||||
|
||||
def test_record_system_auto_appends_actor_link():
|
||||
uid = audit.record_system(
|
||||
f"test.unit.actorlink.{audit_uid_marker()}",
|
||||
actor_kind="user",
|
||||
actor_uid="actor-2",
|
||||
actor_username="bob",
|
||||
)
|
||||
links = store.get_links(uid)
|
||||
actor_links = [link for link in links if link["relation"] == "actor"]
|
||||
assert len(actor_links) == 1
|
||||
assert actor_links[0]["object_uid"] == "actor-2"
|
||||
|
||||
|
||||
def test_record_system_explicit_links_preserved():
|
||||
uid = audit.record_system(
|
||||
f"test.unit.links.{audit_uid_marker()}",
|
||||
actor_kind="user",
|
||||
actor_uid="actor-3",
|
||||
links=[audit.target("project", "proj-1", "P"), audit.author("author-1")],
|
||||
)
|
||||
links = store.get_links(uid)
|
||||
relations = sorted(link["relation"] for link in links)
|
||||
assert relations == ["actor", "author", "target"]
|
||||
|
||||
|
||||
def test_record_never_raises(monkeypatch):
|
||||
def boom(_row):
|
||||
raise RuntimeError("db exploded")
|
||||
|
||||
monkeypatch.setattr(store, "insert_event", boom)
|
||||
# must swallow the error and return None rather than propagate
|
||||
assert audit.record_system("test.unit.raise", actor_kind="system") is None
|
||||
assert audit.record(None, "test.unit.raise2", user=None) is None
|
||||
|
||||
|
||||
def test_record_with_none_request_uses_explicit_user():
|
||||
uid = audit.record(
|
||||
None,
|
||||
f"test.unit.norequest.{audit_uid_marker()}",
|
||||
user={"uid": "u9", "username": "carol", "role": "Member"},
|
||||
summary="no request object",
|
||||
)
|
||||
row = store.get_event(uid)
|
||||
assert row["actor_uid"] == "u9"
|
||||
assert row["actor_role"] == "member"
|
||||
assert row["origin"] == "web"
|
||||
assert row["request_method"] is None
|
||||
|
||||
|
||||
def test_query_list_events_filters():
|
||||
marker = f"qfilter{audit_uid_marker()}"
|
||||
key = f"test.qf.{marker}"
|
||||
for i in range(3):
|
||||
audit.record_system(
|
||||
key,
|
||||
actor_kind="user",
|
||||
actor_uid=f"u{i}",
|
||||
actor_role="member",
|
||||
result="success" if i < 2 else "failure",
|
||||
summary=f"event {marker} number {i}",
|
||||
)
|
||||
rows, pagination = query.list_events({"event_key": key}, 1)
|
||||
assert pagination["total"] == 3
|
||||
rows_fail, pag_fail = query.list_events({"event_key": key, "result": "failure"}, 1)
|
||||
assert pag_fail["total"] == 1
|
||||
rows_q, pag_q = query.list_events({"q": f"{marker} number 1"}, 1)
|
||||
assert pag_q["total"] >= 1
|
||||
assert all(marker in (r["summary"] or "") for r in rows_q)
|
||||
|
||||
|
||||
def test_query_list_events_pagination():
|
||||
key = f"test.qpage.{audit_uid_marker()}"
|
||||
for i in range(30):
|
||||
audit.record_system(key, actor_kind="system", summary=f"page row {i}")
|
||||
rows1, pag1 = query.list_events({"event_key": key}, 1)
|
||||
assert pag1["total"] == 30
|
||||
assert pag1["total_pages"] == 2
|
||||
assert len(rows1) == 25
|
||||
rows2, pag2 = query.list_events({"event_key": key}, 2)
|
||||
assert len(rows2) == 5
|
||||
# newest first ordering: page 1 first row is newer than page 2 last row
|
||||
assert rows1[0]["created_at"] >= rows2[-1]["created_at"]
|
||||
|
||||
|
||||
def test_query_date_range():
|
||||
key = f"test.qdate.{audit_uid_marker()}"
|
||||
audit.record_system(key, actor_kind="system", summary="dated")
|
||||
rows, pag = query.list_events(
|
||||
{"event_key": key, "date_from": "1999-01-01", "date_to": "2099-12-31"}, 1
|
||||
)
|
||||
assert pag["total"] == 1
|
||||
rows_none, pag_none = query.list_events(
|
||||
{"event_key": key, "date_to": "2000-01-01"}, 1
|
||||
)
|
||||
assert pag_none["total"] == 0
|
||||
|
||||
|
||||
def test_filter_options_returns_distinct():
|
||||
key = f"test.opts.{audit_uid_marker()}"
|
||||
audit.record_system(key, actor_kind="cli", actor_role="system", origin="cli")
|
||||
query._options_cache.clear()
|
||||
options = query.filter_options()
|
||||
assert key in options["event_key"]
|
||||
assert "cli" in options["origin"]
|
||||
assert "category" in options and "result" in options
|
||||
|
||||
|
||||
def test_get_event_with_links():
|
||||
uid = audit.record_system(
|
||||
f"test.unit.detail.{audit_uid_marker()}",
|
||||
actor_kind="user",
|
||||
actor_uid="u-detail",
|
||||
links=[audit.target("gist", "g1", "G")],
|
||||
)
|
||||
data = query.get_event_with_links(uid)
|
||||
assert data is not None
|
||||
assert data["event"]["uid"] == uid
|
||||
relations = {link["relation"] for link in data["links"]}
|
||||
assert "target" in relations and "actor" in relations
|
||||
assert query.get_event_with_links("nonexistent-uid") is None
|
||||
|
||||
|
||||
def test_sweep_removes_old_rows_and_links():
|
||||
ancient = "1990-01-01T00:00:00+00:00"
|
||||
recent_key = f"test.sweep.recent.{audit_uid_marker()}"
|
||||
old_uids = []
|
||||
for i in range(3):
|
||||
uid = store.insert_event(
|
||||
{
|
||||
"event_key": f"test.sweep.old.{i}",
|
||||
"category": "test",
|
||||
"actor_kind": "system",
|
||||
"created_at": ancient,
|
||||
"result": "success",
|
||||
}
|
||||
)
|
||||
store.insert_links(uid, [audit.target("post", f"p{i}")])
|
||||
old_uids.append(uid)
|
||||
recent_uid = audit.record_system(recent_key, actor_kind="system")
|
||||
removed_links, removed_events = store.sweep("2000-01-01T00:00:00+00:00")
|
||||
assert removed_events >= 3
|
||||
assert removed_links >= 3
|
||||
for uid in old_uids:
|
||||
assert store.get_event(uid) is None
|
||||
assert store.get_links(uid) == []
|
||||
# the recent row survives
|
||||
assert store.get_event(recent_uid) is not None
|
||||
|
||||
|
||||
def test_sweep_zero_cutoff_is_safe():
|
||||
# a cutoff far in the past removes nothing recent
|
||||
recent_uid = audit.record_system(
|
||||
f"test.sweep.keep.{audit_uid_marker()}", actor_kind="system"
|
||||
)
|
||||
store.sweep("1970-01-01T00:00:00+00:00")
|
||||
assert store.get_event(recent_uid) is not None
|
||||
|
||||
|
||||
def test_retention_service_prunes_old_rows():
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.database import set_setting
|
||||
from devplacepy.services.audit import AuditService
|
||||
|
||||
ancient = "1991-01-01T00:00:00+00:00"
|
||||
old_uid = store.insert_event(
|
||||
{
|
||||
"event_key": f"test.retention.old.{audit_uid_marker()}",
|
||||
"category": "test",
|
||||
"actor_kind": "system",
|
||||
"created_at": ancient,
|
||||
"result": "success",
|
||||
}
|
||||
)
|
||||
recent_uid = audit.record_system(
|
||||
f"test.retention.recent.{audit_uid_marker()}", actor_kind="system"
|
||||
)
|
||||
set_setting("audit_log_retention_days", "30")
|
||||
run_async(AuditService().run_once())
|
||||
assert store.get_event(old_uid) is None
|
||||
assert store.get_event(recent_uid) is not None
|
||||
|
||||
|
||||
def test_retention_service_disabled_when_zero():
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.database import set_setting
|
||||
from devplacepy.services.audit import AuditService
|
||||
|
||||
ancient_uid = store.insert_event(
|
||||
{
|
||||
"event_key": f"test.retention.keep.{audit_uid_marker()}",
|
||||
"category": "test",
|
||||
"actor_kind": "system",
|
||||
"created_at": "1992-01-01T00:00:00+00:00",
|
||||
"result": "success",
|
||||
}
|
||||
)
|
||||
set_setting("audit_log_retention_days", "0")
|
||||
try:
|
||||
run_async(AuditService().run_once())
|
||||
assert store.get_event(ancient_uid) is not None
|
||||
finally:
|
||||
set_setting("audit_log_retention_days", "90")
|
||||
|
||||
|
||||
def test_cli_role_set_recorded(local_db):
|
||||
import argparse
|
||||
from devplacepy import cli
|
||||
|
||||
username = _cli_user(role="Member")
|
||||
uid = get_table("users").find_one(username=username)["uid"]
|
||||
cli.cmd_role_set(argparse.Namespace(username=username, role="admin"))
|
||||
event = _latest("cli.role.set", target_uid=uid)
|
||||
assert event is not None
|
||||
assert event["actor_kind"] == "cli"
|
||||
assert event["origin"] == "cli"
|
||||
import json
|
||||
|
||||
meta = json.loads(event["metadata"])
|
||||
assert meta["old"] == "Member" and meta["new"] == "Admin"
|
||||
links = store.get_links(event["uid"])
|
||||
assert any(link["object_uid"] == uid for link in links)
|
||||
|
||||
|
||||
def test_cli_apikey_reset_recorded(local_db):
|
||||
import argparse
|
||||
from devplacepy import cli
|
||||
|
||||
username = _cli_user()
|
||||
uid = get_table("users").find_one(username=username)["uid"]
|
||||
cli.cmd_apikey_reset(argparse.Namespace(username=username))
|
||||
event = _latest("cli.apikey.reset", target_uid=uid)
|
||||
assert event is not None
|
||||
# the new key value is never written into the audit row
|
||||
new_key = get_table("users").find_one(uid=uid)["api_key"]
|
||||
assert new_key not in (event.get("summary") or "")
|
||||
assert new_key not in (event.get("metadata") or "")
|
||||
|
||||
|
||||
def test_cli_apikey_backfill_recorded(local_db):
|
||||
import argparse
|
||||
from devplacepy import cli
|
||||
|
||||
cli.cmd_apikey_backfill(argparse.Namespace())
|
||||
event = _latest("cli.apikey.backfill")
|
||||
assert event is not None
|
||||
assert event["actor_kind"] == "cli"
|
||||
assert (event.get("metadata") or "").find("count") != -1
|
||||
@@ -0,0 +1,190 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import timedelta
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL, run_async
|
||||
from devplacepy.database import db, get_table, init_db, refresh_snapshot
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy.services.containers import api, store, runtime
|
||||
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
||||
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
|
||||
from devplacepy.services.containers.backend.fake import FakeBackend
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.containers.api import INSTANCE_LABEL
|
||||
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
|
||||
_CONTAINER_TABLES = (
|
||||
"instances",
|
||||
"instance_events",
|
||||
"instance_metrics",
|
||||
"instance_schedules",
|
||||
)
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_containers():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
fake = FakeBackend()
|
||||
runtime.set_backend(fake)
|
||||
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "ws")
|
||||
pid = "ctest-p1"
|
||||
project = {"uid": pid, "slug": "ctest", "title": "C", "user_uid": "ctest-u1"}
|
||||
user = {"uid": "ctest-u1", "username": "ctestadmin"}
|
||||
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
|
||||
yield {"fake": fake, "project": project, "user": user}
|
||||
runtime.set_backend(None)
|
||||
for table in _CONTAINER_TABLES:
|
||||
if table in db.tables:
|
||||
for row in [r for r in get_table(table).find()]:
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table(table).delete(uid=row["uid"])
|
||||
for row in list(get_table("project_files").find()):
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table("project_files").delete(uid=row["uid"])
|
||||
def _ready_instance(env, **kwargs):
|
||||
return run_async(
|
||||
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
||||
)
|
||||
def _promote_admin(username: str) -> None:
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=username)
|
||||
if user:
|
||||
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
|
||||
def _api_key(username: str) -> str:
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=username)["api_key"]
|
||||
|
||||
|
||||
def test_build_run_argv_exact():
|
||||
spec = RunSpec(
|
||||
image="ppy:latest",
|
||||
name="inst",
|
||||
labels={INSTANCE_LABEL: "u1"},
|
||||
env={"A": "B"},
|
||||
cpu_limit="1.5",
|
||||
mem_limit="512m",
|
||||
ports=[PortMapping(8080, 80)],
|
||||
mounts=[Mount("/ws", "/app")],
|
||||
restart_policy="on-failure",
|
||||
command=["python", "app.py"],
|
||||
)
|
||||
argv = build_run_argv(spec)
|
||||
assert argv[:5] == ["docker", "run", "-d", "--name", "inst"]
|
||||
assert "--label" in argv and f"{INSTANCE_LABEL}=u1" in argv
|
||||
assert "--cpus" in argv and "1.5" in argv
|
||||
assert "-p" in argv and "8080:80/tcp" in argv
|
||||
assert "-v" in argv and "/ws:/app:rw" in argv
|
||||
assert "--restart" in argv and "on-failure" in argv
|
||||
assert argv[-3:] == ["ppy:latest", "python", "app.py"]
|
||||
|
||||
|
||||
def test_never_policy_not_passed_to_docker():
|
||||
spec = RunSpec(image="ppy:latest", name="n", restart_policy="never")
|
||||
assert "--restart" not in build_run_argv(spec)
|
||||
|
||||
|
||||
def test_parse_size():
|
||||
assert parse_size("1.0GiB") == 1024**3
|
||||
assert parse_size("512MB") == 512 * 1024**2
|
||||
|
||||
|
||||
def test_create_instance_uses_shared_image(env):
|
||||
inst = run_async(
|
||||
api.create_instance(
|
||||
env["project"], name="inst", actor=("user", env["user"]["uid"])
|
||||
)
|
||||
)
|
||||
assert inst["name"] == "inst"
|
||||
assert inst["owner_uid"] == "ctest-u1"
|
||||
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
||||
assert spec.image == config.CONTAINER_IMAGE
|
||||
assert any(m.container == "/app" for m in spec.mounts)
|
||||
|
||||
|
||||
def test_create_instance_requires_built_image(env):
|
||||
async def no_image(ref):
|
||||
return False
|
||||
|
||||
env["fake"].image_exists = no_image
|
||||
with pytest.raises(api.ContainerError):
|
||||
run_async(api.create_instance(env["project"], name="inst"))
|
||||
|
||||
|
||||
def test_reconcile_launches_and_stops(env):
|
||||
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
||||
assert inst["desired_state"] == store.DESIRED_RUNNING
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
assert inst["status"] == store.ST_RUNNING and inst["container_id"]
|
||||
assert [r.name for r in run_async(env["fake"].ps())] == [inst["slug"]]
|
||||
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["status"] == store.ST_STOPPED
|
||||
|
||||
|
||||
def test_reconcile_reaps_orphan(env):
|
||||
fake = env["fake"]
|
||||
run_async(
|
||||
fake.run(
|
||||
RunSpec(
|
||||
image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"}
|
||||
)
|
||||
)
|
||||
)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
assert not run_async(fake.ps())
|
||||
assert fake.removed
|
||||
|
||||
|
||||
def test_reconcile_removes_marked_instance(env):
|
||||
inst = _ready_instance(env, autostart=True)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
api.mark_for_removal(inst)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"]) is None
|
||||
assert not run_async(env["fake"].ps())
|
||||
|
||||
|
||||
def test_schedule_fires(env):
|
||||
inst = _ready_instance(env, autostart=False)
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
past = Schedule(kind="once", run_at=now_utc() - timedelta(hours=1))
|
||||
api.add_schedule(inst, "start", past)
|
||||
service = ContainerService()
|
||||
run_async(service._fire_schedules())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
||||
|
||||
|
||||
def test_ingress_validation(env):
|
||||
from devplacepy.services.containers.backend.base import PortMapping
|
||||
|
||||
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == (
|
||||
"zwoeks",
|
||||
8899,
|
||||
)
|
||||
assert api.validate_ingress("", None, []) == ("", 0)
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("BAD SLUG", None, [PortMapping(80, 80)])
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("x", 9999, [PortMapping(80, 80)])
|
||||
store.create_instance(
|
||||
{
|
||||
"uid": "z",
|
||||
"project_uid": "ctest-p1",
|
||||
"name": "z",
|
||||
"ports_json": "[]",
|
||||
"ingress_slug": "taken",
|
||||
}
|
||||
)
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("taken", None, [PortMapping(80, 80)])
|
||||
@@ -0,0 +1,56 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
from devplacepy.services.devii.agentic.loop import _run_tool_call
|
||||
from tests.conftest import run_async
|
||||
class _FakeDispatcher:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def dispatch(self, name, arguments):
|
||||
self.calls.append((name, arguments))
|
||||
return json.dumps({"status": "ok"})
|
||||
def _run(call, dispatcher=None):
|
||||
return json.loads(run_async(_run_tool_call(dispatcher, call)))
|
||||
|
||||
|
||||
def test_truncated_arguments_reported_not_dispatched():
|
||||
dispatcher = _FakeDispatcher()
|
||||
call = {
|
||||
"function": {
|
||||
"name": "project_write_file",
|
||||
"arguments": '{"path":"a.md","content":"# hi',
|
||||
}
|
||||
}
|
||||
out = _run(call, dispatcher)
|
||||
assert out["error"] == "tool_input_truncated"
|
||||
assert "one write tool call per turn" in out["message"]
|
||||
assert dispatcher.calls == []
|
||||
|
||||
|
||||
def test_non_object_arguments_rejected():
|
||||
dispatcher = _FakeDispatcher()
|
||||
out = _run({"function": {"name": "x", "arguments": '"a string"'}}, dispatcher)
|
||||
assert out["error"] == "tool_input_error"
|
||||
assert dispatcher.calls == []
|
||||
|
||||
|
||||
def test_valid_string_arguments_dispatched():
|
||||
dispatcher = _FakeDispatcher()
|
||||
out = _run({"function": {"name": "vote", "arguments": '{"value":1}'}}, dispatcher)
|
||||
assert out["status"] == "ok"
|
||||
assert dispatcher.calls == [("vote", {"value": 1})]
|
||||
|
||||
|
||||
def test_valid_dict_arguments_dispatched():
|
||||
dispatcher = _FakeDispatcher()
|
||||
out = _run({"function": {"name": "vote", "arguments": {"value": -1}}}, dispatcher)
|
||||
assert out["status"] == "ok"
|
||||
assert dispatcher.calls == [("vote", {"value": -1})]
|
||||
|
||||
|
||||
def test_missing_arguments_defaults_to_empty_object():
|
||||
dispatcher = _FakeDispatcher()
|
||||
out = _run({"function": {"name": "auth_status"}}, dispatcher)
|
||||
assert out["status"] == "ok"
|
||||
assert dispatcher.calls == [("auth_status", {})]
|
||||
@@ -0,0 +1,177 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, set_setting
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.services.devii.config import (
|
||||
effective_daily_limit,
|
||||
FIELD_USER_DAILY_USD,
|
||||
FIELD_GUEST_DAILY_USD,
|
||||
FIELD_ADMIN_DAILY_USD,
|
||||
)
|
||||
from devplacepy.services.devii.store import UsageLedger
|
||||
LEDGER = "devii_usage_ledger"
|
||||
def _now_iso_devii_quota():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
def _seed_ledger(owner_kind, owner_id, cost, n=1):
|
||||
table = get_table(LEDGER)
|
||||
for _ in range(n):
|
||||
table.insert(
|
||||
{
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"created_at": _now_iso_devii_quota(),
|
||||
"cost_usd": cost,
|
||||
}
|
||||
)
|
||||
def _ensure_devii():
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.devii import DeviiService
|
||||
|
||||
if service_manager.get_service("devii") is None:
|
||||
service_manager.register(DeviiService())
|
||||
return service_manager.get_service("devii")
|
||||
def _make_user_devii_quota(role="Member"):
|
||||
uid = generate_uid()
|
||||
username = f"quota_{uid[-12:]}"
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": generate_uid(),
|
||||
"role": role,
|
||||
"is_active": True,
|
||||
}
|
||||
)
|
||||
return uid, username
|
||||
|
||||
|
||||
def test_effective_limit_resolver():
|
||||
cfg = {
|
||||
FIELD_USER_DAILY_USD: 1.0,
|
||||
FIELD_GUEST_DAILY_USD: 0.05,
|
||||
FIELD_ADMIN_DAILY_USD: 0.0,
|
||||
}
|
||||
assert effective_daily_limit(cfg, "user", False) == 1.0
|
||||
assert effective_daily_limit(cfg, "guest", False) == 0.05
|
||||
assert effective_daily_limit(cfg, "user", True) == 0.0
|
||||
cfg[FIELD_ADMIN_DAILY_USD] = 5.0
|
||||
assert effective_daily_limit(cfg, "user", True) == 5.0
|
||||
|
||||
|
||||
def test_admin_exempt_by_default(local_db):
|
||||
from devplacepy.routers.profile import _ai_quota
|
||||
|
||||
_ensure_devii()
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
set_setting("devii_admin_daily_usd", "0.0")
|
||||
try:
|
||||
uid, _ = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 5.0)
|
||||
member_view = _ai_quota(uid, is_admin=False)
|
||||
admin_view = _ai_quota(uid, is_admin=True)
|
||||
assert member_view["used_pct"] == 100.0
|
||||
assert admin_view["used_pct"] == 0.0
|
||||
finally:
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
set_setting("devii_admin_daily_usd", "0.0")
|
||||
|
||||
|
||||
def test_admin_limit_is_zero_by_default(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_admin_daily_usd", "0.0")
|
||||
try:
|
||||
assert svc.daily_limit_for("user", True) == 0.0
|
||||
assert svc.daily_limit_for("user", False) == 1.0
|
||||
finally:
|
||||
set_setting("devii_admin_daily_usd", "0.0")
|
||||
|
||||
|
||||
def test_admin_cap_configurable(local_db):
|
||||
from devplacepy.routers.profile import _ai_quota
|
||||
|
||||
_ensure_devii()
|
||||
set_setting("devii_admin_daily_usd", "2.0")
|
||||
try:
|
||||
uid, _ = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 1.0)
|
||||
quota = _ai_quota(uid, is_admin=True)
|
||||
assert quota["used_pct"] == 50.0
|
||||
finally:
|
||||
set_setting("devii_admin_daily_usd", "0.0")
|
||||
|
||||
|
||||
def test_guest_cap_configurable(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_guest_daily_usd", "0.10")
|
||||
try:
|
||||
assert svc.daily_limit_for("guest") == 0.10
|
||||
finally:
|
||||
set_setting("devii_guest_daily_usd", "0.05")
|
||||
|
||||
|
||||
def test_reset_single_owner(local_db):
|
||||
ledger = UsageLedger()
|
||||
uid, _ = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 0.5, n=3)
|
||||
assert ledger.spent_24h("user", uid) == 1.5
|
||||
removed = ledger.reset("user", uid)
|
||||
assert removed == 3
|
||||
assert ledger.spent_24h("user", uid) == 0.0
|
||||
|
||||
|
||||
def test_reset_guests_only_leaves_users(local_db):
|
||||
ledger = UsageLedger()
|
||||
guest_id = f"guest-{generate_uid()[:8]}"
|
||||
user_id, _ = _make_user_devii_quota()
|
||||
_seed_ledger("guest", guest_id, 0.02, n=2)
|
||||
_seed_ledger("user", user_id, 0.5, n=1)
|
||||
ledger.reset_owner_kind("guest")
|
||||
assert ledger.spent_24h("guest", guest_id) == 0.0
|
||||
assert ledger.spent_24h("user", user_id) == 0.5
|
||||
|
||||
|
||||
def test_reset_all(local_db):
|
||||
ledger = UsageLedger()
|
||||
guest_id = f"guest-{generate_uid()[:8]}"
|
||||
user_id, _ = _make_user_devii_quota()
|
||||
_seed_ledger("guest", guest_id, 0.02, n=2)
|
||||
_seed_ledger("user", user_id, 0.5, n=2)
|
||||
ledger.reset_all()
|
||||
assert ledger.spent_24h("guest", guest_id) == 0.0
|
||||
assert ledger.spent_24h("user", user_id) == 0.0
|
||||
|
||||
|
||||
def test_cli_reset_quota_for_user(local_db):
|
||||
from devplacepy.cli import cmd_devii_reset_quota
|
||||
|
||||
uid, username = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 0.5, n=2)
|
||||
cmd_devii_reset_quota(SimpleNamespace(username=username, guests=False, all=False))
|
||||
assert get_table(LEDGER).count(owner_kind="user", owner_id=uid) == 0
|
||||
|
||||
|
||||
def test_cli_reset_quota_guests(local_db):
|
||||
from devplacepy.cli import cmd_devii_reset_quota
|
||||
|
||||
guest_id = f"guest-{generate_uid()[:8]}"
|
||||
user_id, _ = _make_user_devii_quota()
|
||||
_seed_ledger("guest", guest_id, 0.02, n=2)
|
||||
_seed_ledger("user", user_id, 0.5, n=1)
|
||||
cmd_devii_reset_quota(SimpleNamespace(username=None, guests=True, all=False))
|
||||
assert get_table(LEDGER).count(owner_kind="guest", owner_id=guest_id) == 0
|
||||
assert get_table(LEDGER).count(owner_kind="user", owner_id=user_id) == 1
|
||||
|
||||
|
||||
def test_cli_reset_quota_all(local_db):
|
||||
from devplacepy.cli import cmd_devii_reset_quota
|
||||
|
||||
guest_id = f"guest-{generate_uid()[:8]}"
|
||||
user_id, _ = _make_user_devii_quota()
|
||||
_seed_ledger("guest", guest_id, 0.02, n=1)
|
||||
_seed_ledger("user", user_id, 0.5, n=1)
|
||||
cmd_devii_reset_quota(SimpleNamespace(username=None, guests=False, all=True))
|
||||
assert get_table(LEDGER).count() == 0
|
||||
@@ -0,0 +1,230 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
init_db,
|
||||
refresh_snapshot,
|
||||
set_setting,
|
||||
)
|
||||
from devplacepy.services.gitea import runtime, store
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.gitea.enhance import enhance_ticket
|
||||
from devplacepy.services.gitea.fake import FakeGiteaClient
|
||||
from devplacepy.services.gitea.service import BugTrackerService
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.bug_create_service import BugCreateService
|
||||
from tests.conftest import run_async
|
||||
_counter_bugs_gitea = [0]
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_bugs_gitea():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
def gitea_env():
|
||||
fake = FakeGiteaClient()
|
||||
runtime.set_client(fake)
|
||||
set_setting("gitea_base_url", "https://gitea.test")
|
||||
set_setting("gitea_owner", "retoor")
|
||||
set_setting("gitea_repo", "pydevplace")
|
||||
set_setting("gitea_token", "test-token")
|
||||
set_setting("bug_ai_enhance", "0")
|
||||
yield fake
|
||||
runtime.set_client(None)
|
||||
for table in ("bug_tickets", "bug_comment_authors"):
|
||||
for row in list(get_table(table).find()):
|
||||
get_table(table).delete(uid=row["uid"])
|
||||
for row in list(get_table("jobs").find(kind="bug_create")):
|
||||
get_table("jobs").delete(uid=row["uid"])
|
||||
def _make_user_bugs_gitea():
|
||||
_counter_bugs_gitea[0] += 1
|
||||
uid = f"bugtest-user-{_counter_bugs_gitea[0]}"
|
||||
username = f"bugtester{_counter_bugs_gitea[0]}"
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"role": "member",
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"is_active": True,
|
||||
}
|
||||
)
|
||||
return uid, username
|
||||
def _unread(user_uid):
|
||||
return [
|
||||
n
|
||||
for n in get_table("notifications").find(user_uid=user_uid)
|
||||
if n.get("type") == "bug"
|
||||
]
|
||||
def _drive_jobs():
|
||||
async def run():
|
||||
svc = BugCreateService()
|
||||
for _ in range(200):
|
||||
await svc.run_once()
|
||||
refresh_snapshot()
|
||||
pending = [
|
||||
r
|
||||
for r in get_table("jobs").find(kind="bug_create")
|
||||
if r["status"] in ("pending", "running")
|
||||
]
|
||||
if not pending and not svc._inflight:
|
||||
return
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
run_async(run())
|
||||
def _role_user(role: str):
|
||||
uid, username = _make_user_bugs_gitea()
|
||||
get_table("users").update({"uid": uid, "role": role}, ["uid"])
|
||||
return uid, username
|
||||
def _open_ticket(fake, role: str):
|
||||
import devplacepy.main as m
|
||||
from starlette.testclient import TestClient
|
||||
from devplacepy.utils import create_session
|
||||
|
||||
uid, _ = _role_user(role)
|
||||
issue = run_async(fake.create_issue("Submit does nothing", "details"))
|
||||
number = int(issue["number"])
|
||||
store.record_ticket(
|
||||
number=number,
|
||||
author_uid=uid,
|
||||
original_title="Submit does nothing",
|
||||
original_description="details",
|
||||
enhanced_title="Submit does nothing",
|
||||
html_url=issue.get("html_url", ""),
|
||||
status="open",
|
||||
)
|
||||
client = TestClient(m.app)
|
||||
client.cookies.set("session", create_session(uid))
|
||||
return number, client
|
||||
|
||||
|
||||
def test_fake_client_create_and_list(gitea_env):
|
||||
async def go():
|
||||
issue = await gitea_env.create_issue("Crash on save", "details")
|
||||
assert issue["number"] == 1
|
||||
assert issue["state"] == "open"
|
||||
issues, total = await gitea_env.list_issues(state="open")
|
||||
assert total == 1
|
||||
assert issues[0]["number"] == 1
|
||||
|
||||
run_async(go())
|
||||
|
||||
|
||||
def test_fake_client_state_and_comments(gitea_env):
|
||||
async def go():
|
||||
issue = await gitea_env.create_issue("t", "b")
|
||||
number = issue["number"]
|
||||
await gitea_env.create_comment(number, "first")
|
||||
fetched = await gitea_env.get_issue(number)
|
||||
assert fetched["comments"] == 1
|
||||
closed = await gitea_env.set_state(number, "closed")
|
||||
assert closed["state"] == "closed"
|
||||
open_issues, _ = await gitea_env.list_issues(state="open")
|
||||
assert open_issues == []
|
||||
all_issues, total = await gitea_env.list_issues(state="all")
|
||||
assert total == 1
|
||||
|
||||
run_async(go())
|
||||
|
||||
|
||||
def test_store_records_and_maps(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
store.record_ticket(7, uid, "orig", "desc", "Better", "https://x/7", "open")
|
||||
assert store.author_uid_for_issue(7) == uid
|
||||
assert store.author_map([7, 8]) == {7: uid}
|
||||
cid = store.record_comment_author(99, 7, uid)
|
||||
assert cid
|
||||
assert store.comment_author_map([99, 100]) == {99: uid}
|
||||
assert store.local_comment_ids(7) == {99}
|
||||
|
||||
|
||||
def test_enhance_fallback_when_disabled(gitea_env):
|
||||
result = run_async(enhance_ticket("Title", "Body text", gitea_config()))
|
||||
assert result.enhanced is False
|
||||
assert "## Summary" in result.body
|
||||
assert "## Steps to Reproduce" in result.body
|
||||
assert result.title == "Title"
|
||||
|
||||
|
||||
def test_create_job_files_issue(gitea_env):
|
||||
uid, username = _make_user_bugs_gitea()
|
||||
job_uid = queue.enqueue(
|
||||
"bug_create",
|
||||
{"author_uid": uid, "title": "Login broken", "description": "cannot log in"},
|
||||
"user",
|
||||
uid,
|
||||
"Login broken",
|
||||
)
|
||||
_drive_jobs()
|
||||
job = queue.get_job(job_uid)
|
||||
assert job["status"] == "done"
|
||||
number = job["result"]["number"]
|
||||
assert number == 1
|
||||
ticket = store.get_ticket(number)
|
||||
assert ticket["author_uid"] == uid
|
||||
assert _unread(uid)
|
||||
issue = run_async(gitea_env.get_issue(number))
|
||||
assert username in issue["body"]
|
||||
|
||||
|
||||
def test_create_job_fails_without_config(gitea_env):
|
||||
set_setting("gitea_token", "")
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
job_uid = queue.enqueue(
|
||||
"bug_create",
|
||||
{"author_uid": uid, "title": "x", "description": "y"},
|
||||
"user",
|
||||
uid,
|
||||
"x",
|
||||
)
|
||||
_drive_jobs()
|
||||
job = queue.get_job(job_uid)
|
||||
assert job["status"] == "failed"
|
||||
assert "not configured" in job["error"].lower()
|
||||
|
||||
|
||||
def test_poller_notifies_on_developer_reply(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||
number = issue["number"]
|
||||
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||
store.update_ticket_cache(number, "open", 0)
|
||||
|
||||
gitea_env.add_external_comment(number, "developer", "looking into it")
|
||||
before = len(_unread(uid))
|
||||
run_async(BugTrackerService().run_once())
|
||||
refresh_snapshot()
|
||||
assert len(_unread(uid)) == before + 1
|
||||
assert int(store.get_ticket(number)["last_comment_count"]) == 1
|
||||
|
||||
|
||||
def test_poller_ignores_local_comment(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||
number = issue["number"]
|
||||
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||
store.update_ticket_cache(number, "open", 0)
|
||||
|
||||
comment = run_async(gitea_env.create_comment(number, "me again"))
|
||||
store.record_comment_author(comment["id"], number, uid)
|
||||
before = len(_unread(uid))
|
||||
run_async(BugTrackerService().run_once())
|
||||
refresh_snapshot()
|
||||
assert len(_unread(uid)) == before
|
||||
|
||||
|
||||
def test_poller_notifies_on_status_change(gitea_env):
|
||||
uid, _ = _make_user_bugs_gitea()
|
||||
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||
number = issue["number"]
|
||||
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||
store.update_ticket_cache(number, "open", 0)
|
||||
|
||||
run_async(gitea_env.set_state(number, "closed"))
|
||||
before = len(_unread(uid))
|
||||
run_async(BugTrackerService().run_once())
|
||||
refresh_snapshot()
|
||||
assert len(_unread(uid)) == before + 1
|
||||
assert store.get_ticket(number)["last_status"] == "closed"
|
||||
@@ -0,0 +1,212 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from devplacepy.database import init_db, get_table, refresh_snapshot
|
||||
from devplacepy import project_files
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.fork_service import ForkService
|
||||
from tests.conftest import run_async
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_fork_jobs():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
def fork_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.jobs.fork_service.STAGING_DIR", tmp_path / "staging"
|
||||
)
|
||||
monkeypatch.setattr("devplacepy.project_files.PROJECT_FILES_DIR", tmp_path / "pf")
|
||||
yield tmp_path
|
||||
jobs = get_table("jobs")
|
||||
for row in list(jobs.find(kind="fork")):
|
||||
jobs.delete(uid=row["uid"])
|
||||
projects = get_table("projects")
|
||||
files = get_table("project_files")
|
||||
for project in list(projects.find()):
|
||||
if str(project.get("user_uid", "")).startswith("forktest-owner"):
|
||||
for node in list(files.find(project_uid=project["uid"])):
|
||||
files.delete(uid=node["uid"])
|
||||
projects.delete(uid=project["uid"])
|
||||
for node in list(files.find()):
|
||||
if str(node.get("project_uid", "")).startswith("forktest"):
|
||||
files.delete(uid=node["uid"])
|
||||
forks = get_table("project_forks")
|
||||
for relation in list(forks.find()):
|
||||
if str(relation.get("forked_by_uid", "")).startswith("forktest-owner"):
|
||||
forks.delete(uid=relation["uid"])
|
||||
users = get_table("users")
|
||||
for user in list(users.find()):
|
||||
if str(user.get("uid", "")).startswith("forktest-owner"):
|
||||
users.delete(uid=user["uid"])
|
||||
_counter_fork_jobs = [0]
|
||||
def _make_source_project(*, is_private=False, binary=False):
|
||||
_counter_fork_jobs[0] += 1
|
||||
pid = f"forktest-{_counter_fork_jobs[0]}"
|
||||
owner_uid = f"forktest-owner-{_counter_fork_jobs[0]}"
|
||||
user = {"uid": owner_uid, "username": f"forktester{_counter_fork_jobs[0]}"}
|
||||
get_table("users").insert(
|
||||
{"uid": owner_uid, "username": user["username"], "xp": 0, "level": 1}
|
||||
)
|
||||
get_table("projects").insert(
|
||||
{
|
||||
"uid": pid,
|
||||
"user_uid": owner_uid,
|
||||
"slug": f"{pid}-source",
|
||||
"title": "Source Project",
|
||||
"description": "the original",
|
||||
"project_type": "software",
|
||||
"platforms": "linux",
|
||||
"status": "Released",
|
||||
"is_private": 1 if is_private else 0,
|
||||
"read_only": 0,
|
||||
"stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
project_files.write_text_file(pid, user, "README.md", "# hello\nworld")
|
||||
project_files.write_text_file(pid, user, "src/app.py", "print(1)\n")
|
||||
if binary:
|
||||
project_files.store_upload(pid, user, "assets", "logo.bin", bytes(range(256)))
|
||||
return pid, owner_uid
|
||||
def _tree(directory):
|
||||
root = Path(directory)
|
||||
out = {}
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file():
|
||||
out[path.relative_to(root).as_posix()] = path.read_bytes()
|
||||
return out
|
||||
def _process_fork_jobs():
|
||||
async def drive():
|
||||
svc = ForkService()
|
||||
for _ in range(400):
|
||||
await svc.run_once()
|
||||
refresh_snapshot()
|
||||
pending = [
|
||||
r
|
||||
for r in get_table("jobs").find(kind="fork")
|
||||
if r["status"] in ("pending", "running")
|
||||
]
|
||||
if not pending and not svc._inflight:
|
||||
return
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
run_async(drive())
|
||||
def _enqueue(source_uid, owner_uid, title="My Fork"):
|
||||
return queue.enqueue(
|
||||
"fork",
|
||||
{"source_project_uid": source_uid, "title": title, "forked_by_uid": owner_uid},
|
||||
"user",
|
||||
owner_uid,
|
||||
title,
|
||||
)
|
||||
|
||||
|
||||
def test_enqueue_creates_pending_job(fork_env):
|
||||
uid = _enqueue("p", "forktest-owner-x")
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "pending"
|
||||
assert job["kind"] == "fork"
|
||||
assert job["payload"]["source_project_uid"] == "p"
|
||||
|
||||
|
||||
def test_process_creates_fork_with_files(fork_env, tmp_path):
|
||||
pid, owner_uid = _make_source_project(binary=True)
|
||||
uid = _enqueue(pid, owner_uid, title="Forked Copy")
|
||||
_process_fork_jobs()
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "done"
|
||||
result = job["result"]
|
||||
new_uid = result["project_uid"]
|
||||
forked = get_table("projects").find_one(uid=new_uid)
|
||||
assert forked is not None
|
||||
assert forked["user_uid"] == owner_uid
|
||||
assert forked["title"] == "Forked Copy"
|
||||
assert result["project_url"] == f"/projects/{forked['slug']}"
|
||||
|
||||
source_dir = tmp_path / "exp_source"
|
||||
fork_dir = tmp_path / "exp_fork"
|
||||
project_files.export_to_dir(pid, "", source_dir)
|
||||
project_files.export_to_dir(new_uid, "", fork_dir)
|
||||
assert _tree(fork_dir) == _tree(source_dir)
|
||||
|
||||
|
||||
def test_fork_relation_direction(fork_env):
|
||||
pid, owner_uid = _make_source_project()
|
||||
uid = _enqueue(pid, owner_uid)
|
||||
_process_fork_jobs()
|
||||
new_uid = queue.get_job(uid)["result"]["project_uid"]
|
||||
relation = get_table("project_forks").find_one(forked_project_uid=new_uid)
|
||||
assert relation is not None
|
||||
assert relation["source_project_uid"] == pid
|
||||
assert relation["forked_project_uid"] == new_uid
|
||||
assert relation["forked_by_uid"] == owner_uid
|
||||
|
||||
|
||||
def test_fork_copies_binary_file(fork_env, tmp_path):
|
||||
pid, owner_uid = _make_source_project(binary=True)
|
||||
uid = _enqueue(pid, owner_uid)
|
||||
_process_fork_jobs()
|
||||
new_uid = queue.get_job(uid)["result"]["project_uid"]
|
||||
fork_dir = tmp_path / "binfork"
|
||||
project_files.export_to_dir(new_uid, "", fork_dir)
|
||||
assert (fork_dir / "assets" / "logo.bin").read_bytes() == bytes(range(256))
|
||||
|
||||
|
||||
def test_fork_preserves_private_flag(fork_env):
|
||||
pid, owner_uid = _make_source_project(is_private=True)
|
||||
uid = _enqueue(pid, owner_uid)
|
||||
_process_fork_jobs()
|
||||
new_uid = queue.get_job(uid)["result"]["project_uid"]
|
||||
assert get_table("projects").find_one(uid=new_uid)["is_private"] == 1
|
||||
|
||||
|
||||
def test_missing_source_fails_job_without_orphan(fork_env):
|
||||
before = {p["uid"] for p in get_table("projects").find()}
|
||||
uid = _enqueue("does-not-exist", "forktest-owner-missing")
|
||||
_process_fork_jobs()
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "failed"
|
||||
assert "source project not found" in job["error"].lower()
|
||||
after = {p["uid"] for p in get_table("projects").find()}
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_cleanup_keeps_project(fork_env):
|
||||
pid, owner_uid = _make_source_project()
|
||||
uid = _enqueue(pid, owner_uid)
|
||||
_process_fork_jobs()
|
||||
job = queue.get_job(uid)
|
||||
new_uid = job["result"]["project_uid"]
|
||||
ForkService().cleanup(job)
|
||||
assert get_table("projects").find_one(uid=new_uid) is not None
|
||||
|
||||
|
||||
def test_retention_sweep_keeps_project(fork_env):
|
||||
pid, owner_uid = _make_source_project()
|
||||
uid = _enqueue(pid, owner_uid)
|
||||
_process_fork_jobs()
|
||||
new_uid = queue.get_job(uid)["result"]["project_uid"]
|
||||
get_table("jobs").update(
|
||||
{"uid": uid, "expires_at": "2000-01-01T00:00:00+00:00"}, ["uid"]
|
||||
)
|
||||
svc = ForkService()
|
||||
run_async(svc.run_once())
|
||||
refresh_snapshot()
|
||||
assert queue.get_job(uid) is None
|
||||
assert get_table("projects").find_one(uid=new_uid) is not None
|
||||
|
||||
|
||||
def test_orphan_running_recovered_on_enable(fork_env):
|
||||
uid = _enqueue("p", "forktest-owner-o")
|
||||
get_table("jobs").update(
|
||||
{"uid": uid, "status": "running", "started_at": "2020-01-01T00:00:00+00:00"},
|
||||
["uid"],
|
||||
)
|
||||
svc = ForkService()
|
||||
run_async(svc.on_enable())
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "pending"
|
||||
assert job["retry_count"] == 1
|
||||
@@ -0,0 +1,401 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import zipfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from devplacepy.database import init_db, get_table, refresh_snapshot
|
||||
from devplacepy import project_files
|
||||
from devplacepy.project_files import ProjectFileError
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.zip_service import ZipService
|
||||
from devplacepy.services.jobs import zip_worker
|
||||
from tests.conftest import run_async
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_zip_jobs():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
def zip_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.jobs.zip_service.ZIPS_DIR", tmp_path / "zips"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.services.jobs.zip_service.STAGING_DIR", tmp_path / "staging"
|
||||
)
|
||||
monkeypatch.setattr("devplacepy.project_files.PROJECT_FILES_DIR", tmp_path / "pf")
|
||||
yield tmp_path
|
||||
jobs = get_table("jobs")
|
||||
for row in list(jobs.find(kind="zip")):
|
||||
jobs.delete(uid=row["uid"])
|
||||
files = get_table("project_files")
|
||||
for row in list(files.find()):
|
||||
if str(row.get("project_uid", "")).startswith("ziptest"):
|
||||
files.delete(uid=row["uid"])
|
||||
_pid_counter = [0]
|
||||
def _make_project(text=None, binary=False):
|
||||
_pid_counter[0] += 1
|
||||
pid = f"ziptest-{_pid_counter[0]}"
|
||||
user = {"uid": f"ziptest-owner-{_pid_counter[0]}"}
|
||||
project_files.write_text_file(pid, user, "README.md", "# hello\nworld")
|
||||
project_files.write_text_file(pid, user, "src/app.py", "print(1)\n")
|
||||
if binary:
|
||||
project_files.store_upload(pid, user, "assets", "logo.bin", bytes(range(256)))
|
||||
return pid, user
|
||||
def _process_zip_jobs():
|
||||
async def drive():
|
||||
svc = ZipService()
|
||||
for _ in range(400):
|
||||
await svc.run_once()
|
||||
refresh_snapshot()
|
||||
pending = [
|
||||
r
|
||||
for r in get_table("jobs").find(kind="zip")
|
||||
if r["status"] in ("pending", "running")
|
||||
]
|
||||
if not pending and not svc._inflight:
|
||||
return
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
run_async(drive())
|
||||
|
||||
|
||||
def test_enqueue_creates_pending_job(zip_env):
|
||||
uid = queue.enqueue("zip", {"a": 1}, "user", "u1", "Name")
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "pending"
|
||||
assert job["kind"] == "zip"
|
||||
assert job["payload"] == {"a": 1}
|
||||
assert job["result"] == {}
|
||||
assert job["owner_kind"] == "user" and job["owner_id"] == "u1"
|
||||
assert job["created_at"] and job["expires_at"] == ""
|
||||
|
||||
|
||||
def test_get_job_missing_returns_none(zip_env):
|
||||
assert queue.get_job("does-not-exist") is None
|
||||
|
||||
|
||||
def test_touch_job_extends_expiry(zip_env):
|
||||
uid = queue.enqueue("zip", {}, "guest", "g1", "x")
|
||||
queue.touch_job(uid, 3600)
|
||||
job = queue.get_job(uid)
|
||||
assert job["last_accessed_at"] and job["expires_at"]
|
||||
|
||||
|
||||
def test_list_jobs_filters(zip_env):
|
||||
a = queue.enqueue("zip", {}, "user", "owner-A", "a")
|
||||
queue.enqueue("zip", {}, "user", "owner-B", "b")
|
||||
only_a = queue.list_jobs(kind="zip", owner=("user", "owner-A"))
|
||||
assert [j["uid"] for j in only_a] == [a]
|
||||
|
||||
|
||||
def test_zip_worker_stats_and_crc(tmp_path):
|
||||
src = tmp_path / "src"
|
||||
(src / "sub").mkdir(parents=True)
|
||||
(src / "a.txt").write_text("hello")
|
||||
(src / "sub" / "b.txt").write_text("world")
|
||||
out = tmp_path / "out.zip"
|
||||
stats = zip_worker._build(str(src), str(out))
|
||||
assert stats["file_count"] == 2
|
||||
assert stats["dir_count"] == 1
|
||||
assert stats["bytes_in"] == 10
|
||||
assert stats["bytes_out"] == out.stat().st_size
|
||||
expected_crc = 0
|
||||
with open(out, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
expected_crc = zlib.crc32(chunk, expected_crc)
|
||||
assert stats["crc32"] == (expected_crc & 0xFFFFFFFF)
|
||||
assert sorted(zipfile.ZipFile(out).namelist()) == ["a.txt", "sub/", "sub/b.txt"]
|
||||
|
||||
|
||||
def test_process_whole_project(zip_env):
|
||||
pid, _ = _make_project(binary=True)
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"Proj",
|
||||
)
|
||||
_process_zip_jobs()
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "done"
|
||||
assert job["duration_ms"] >= 0
|
||||
assert job["expires_at"]
|
||||
result = job["result"]
|
||||
names = sorted(zipfile.ZipFile(result["local_path"]).namelist())
|
||||
assert names == ["README.md", "assets/", "assets/logo.bin", "src/", "src/app.py"]
|
||||
assert result["file_count"] == 3 and result["dir_count"] == 2
|
||||
assert job["bytes_out"] == Path(result["local_path"]).stat().st_size
|
||||
|
||||
|
||||
def test_process_subtree_folder(zip_env):
|
||||
pid, _ = _make_project()
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": pid, "path": "src"}},
|
||||
"user",
|
||||
"u",
|
||||
"src",
|
||||
)
|
||||
_process_zip_jobs()
|
||||
result = queue.get_job(uid)["result"]
|
||||
assert sorted(zipfile.ZipFile(result["local_path"]).namelist()) == [
|
||||
"src/",
|
||||
"src/app.py",
|
||||
]
|
||||
|
||||
|
||||
def test_process_single_file(zip_env):
|
||||
pid, _ = _make_project()
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": pid, "path": "src/app.py"}},
|
||||
"user",
|
||||
"u",
|
||||
"app.py",
|
||||
)
|
||||
_process_zip_jobs()
|
||||
result = queue.get_job(uid)["result"]
|
||||
assert zipfile.ZipFile(result["local_path"]).namelist() == ["app.py"]
|
||||
|
||||
|
||||
def test_final_name_format_and_strip_zip(zip_env):
|
||||
pid, _ = _make_project()
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"My Project.ZIP",
|
||||
)
|
||||
_process_zip_jobs()
|
||||
result = queue.get_job(uid)["result"]
|
||||
name = result["final_name"]
|
||||
assert name.endswith(".my-project.zip")
|
||||
crc_part = name.split(".", 1)[0]
|
||||
assert len(crc_part) == 8 and int(crc_part, 16) >= 0
|
||||
assert crc_part == result["crc32"]
|
||||
|
||||
|
||||
def test_blank_preferred_name_defaults(zip_env):
|
||||
pid, _ = _make_project()
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"",
|
||||
)
|
||||
_process_zip_jobs()
|
||||
assert queue.get_job(uid)["result"]["final_name"].endswith(".download.zip")
|
||||
|
||||
|
||||
def test_identical_content_replaces_same_name(zip_env):
|
||||
pid, _ = _make_project()
|
||||
payload = {"source": {"type": "project_tree", "project_uid": pid, "path": ""}}
|
||||
uid1 = queue.enqueue("zip", payload, "user", "u", "thing")
|
||||
_process_zip_jobs()
|
||||
name1 = queue.get_job(uid1)["result"]["final_name"]
|
||||
uid2 = queue.enqueue("zip", payload, "user", "u", "thing")
|
||||
_process_zip_jobs()
|
||||
name2 = queue.get_job(uid2)["result"]["final_name"]
|
||||
assert name1 == name2
|
||||
|
||||
|
||||
def test_cleanup_removes_artifact(zip_env):
|
||||
pid, _ = _make_project()
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"thing",
|
||||
)
|
||||
_process_zip_jobs()
|
||||
job = queue.get_job(uid)
|
||||
path = Path(job["result"]["local_path"])
|
||||
assert path.is_file()
|
||||
ZipService().cleanup(job)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_unsupported_source_fails_job(zip_env):
|
||||
uid = queue.enqueue("zip", {"source": {"type": "evil"}}, "user", "u", "x")
|
||||
_process_zip_jobs()
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "failed"
|
||||
assert "unsupported" in job["error"].lower()
|
||||
|
||||
|
||||
def test_normalize_path_rejects_parent(zip_env):
|
||||
with pytest.raises(ProjectFileError):
|
||||
project_files.normalize_path("a/../../b")
|
||||
with pytest.raises(ProjectFileError):
|
||||
project_files.normalize_path("../etc/passwd")
|
||||
|
||||
|
||||
def test_export_blocks_malicious_db_path(zip_env, tmp_path):
|
||||
pid = "ziptest-evil"
|
||||
get_table("project_files").insert(
|
||||
{
|
||||
"uid": "evil-node",
|
||||
"project_uid": pid,
|
||||
"user_uid": "u",
|
||||
"path": "../escape.txt",
|
||||
"name": "escape.txt",
|
||||
"parent_path": "",
|
||||
"type": "file",
|
||||
"content": "pwned",
|
||||
"is_binary": 0,
|
||||
"stored_name": None,
|
||||
"directory": None,
|
||||
"mime_type": "text/plain",
|
||||
"size": 5,
|
||||
"created_at": "x",
|
||||
"updated_at": "x",
|
||||
}
|
||||
)
|
||||
dest = tmp_path / "dest"
|
||||
with pytest.raises(ProjectFileError):
|
||||
project_files.export_to_dir(pid, "", dest)
|
||||
assert not (tmp_path / "escape.txt").exists()
|
||||
|
||||
|
||||
def test_traversal_payload_marks_job_failed(zip_env):
|
||||
pid = "ziptest-evil2"
|
||||
get_table("project_files").insert(
|
||||
{
|
||||
"uid": "evil-node2",
|
||||
"project_uid": pid,
|
||||
"user_uid": "u",
|
||||
"path": "../../escape2.txt",
|
||||
"name": "escape2.txt",
|
||||
"parent_path": "",
|
||||
"type": "file",
|
||||
"content": "pwned",
|
||||
"is_binary": 0,
|
||||
"stored_name": None,
|
||||
"directory": None,
|
||||
"mime_type": "text/plain",
|
||||
"size": 6,
|
||||
"created_at": "x",
|
||||
"updated_at": "x",
|
||||
}
|
||||
)
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"evil",
|
||||
)
|
||||
_process_zip_jobs()
|
||||
assert queue.get_job(uid)["status"] == "failed"
|
||||
|
||||
|
||||
def test_orphan_running_recovered_on_enable(zip_env):
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": "p", "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"n",
|
||||
)
|
||||
get_table("jobs").update(
|
||||
{"uid": uid, "status": "running", "started_at": "2020-01-01T00:00:00+00:00"},
|
||||
["uid"],
|
||||
)
|
||||
svc = ZipService()
|
||||
run_async(svc.on_enable())
|
||||
job = queue.get_job(uid)
|
||||
assert job["status"] == "pending"
|
||||
assert job["retry_count"] == 1
|
||||
|
||||
|
||||
def test_orphan_exceeds_retry_limit_fails(zip_env):
|
||||
uid = queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": "p", "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"n",
|
||||
)
|
||||
get_table("jobs").update(
|
||||
{
|
||||
"uid": uid,
|
||||
"status": "running",
|
||||
"retry_count": 3,
|
||||
"started_at": "2020-01-01T00:00:00+00:00",
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
svc = ZipService()
|
||||
run_async(svc.on_enable())
|
||||
assert queue.get_job(uid)["status"] == "failed"
|
||||
|
||||
|
||||
def test_retention_sweep_deletes_expired(zip_env, tmp_path):
|
||||
artifact = tmp_path / "old.zip"
|
||||
artifact.write_bytes(b"PK\x05\x06" + b"\x00" * 18)
|
||||
uid = queue.enqueue("zip", {}, "user", "u", "old")
|
||||
get_table("jobs").update(
|
||||
{
|
||||
"uid": uid,
|
||||
"status": "done",
|
||||
"result": '{"local_path": "%s"}' % artifact.as_posix(),
|
||||
"expires_at": "2000-01-01T00:00:00+00:00",
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
svc = ZipService()
|
||||
run_async(svc.run_once())
|
||||
refresh_snapshot()
|
||||
assert queue.get_job(uid) is None
|
||||
assert not artifact.exists()
|
||||
|
||||
|
||||
def test_retention_keeps_unexpired(zip_env, tmp_path):
|
||||
uid = queue.enqueue("zip", {}, "user", "u", "fresh")
|
||||
get_table("jobs").update(
|
||||
{
|
||||
"uid": uid,
|
||||
"status": "done",
|
||||
"result": "{}",
|
||||
"expires_at": "2999-01-01T00:00:00+00:00",
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
svc = ZipService()
|
||||
run_async(svc.run_once())
|
||||
refresh_snapshot()
|
||||
assert queue.get_job(uid) is not None
|
||||
|
||||
|
||||
def test_max_concurrent_caps_inflight(zip_env, monkeypatch):
|
||||
for _ in range(4):
|
||||
queue.enqueue(
|
||||
"zip",
|
||||
{"source": {"type": "project_tree", "project_uid": "p", "path": ""}},
|
||||
"user",
|
||||
"u",
|
||||
"n",
|
||||
)
|
||||
|
||||
async def fake_process(self, job):
|
||||
await asyncio.sleep(0.2)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(ZipService, "process", fake_process)
|
||||
monkeypatch.setattr(ZipService, "max_concurrent", lambda self: 2)
|
||||
|
||||
async def check():
|
||||
svc = ZipService()
|
||||
svc._refill()
|
||||
count = len(svc._inflight)
|
||||
for entry in svc._inflight.values():
|
||||
entry["task"].cancel()
|
||||
return count
|
||||
|
||||
assert run_async(check()) == 2
|
||||
@@ -0,0 +1,267 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import httpx
|
||||
from devplacepy.services import news as news_mod
|
||||
from devplacepy.services import base as base_mod
|
||||
from devplacepy.services.news import (
|
||||
NewsService,
|
||||
_extract_grade,
|
||||
_get_ai_key,
|
||||
_get_article_images,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
from tests.conftest import run_async
|
||||
API_URL = "http://news.test/api"
|
||||
AI_URL = "http://ai.test/v1/chat"
|
||||
LINK_HIGH = "http://news.test/high"
|
||||
LINK_LOW = "http://news.test/low"
|
||||
class FakeResp_news_service:
|
||||
def __init__(self, json_data=None, text="", status=200):
|
||||
self._json = json_data
|
||||
self.text = text
|
||||
self.status_code = status
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise httpx.HTTPError(f"status {self.status_code}")
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
class FakeClient_news_service:
|
||||
def __init__(self, articles):
|
||||
self.articles = articles
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def get(self, url, timeout=None):
|
||||
if url == API_URL:
|
||||
return FakeResp_news_service(json_data={"articles": self.articles})
|
||||
return FakeResp_news_service(text='<img src="http://img.test/a.png">')
|
||||
|
||||
async def post(self, url, json=None, headers=None, timeout=None):
|
||||
prompt = json["messages"][0]["content"]
|
||||
if "FailArticle" in prompt:
|
||||
return FakeResp_news_service(status=500, text="err")
|
||||
if "EmptyArticle" in prompt:
|
||||
return FakeResp_news_service(json_data={"choices": [{"message": {"content": ""}}]})
|
||||
if "BadArticle" in prompt:
|
||||
return FakeResp_news_service(
|
||||
json_data={"choices": [{"message": {"content": "no number"}}]}
|
||||
)
|
||||
grade = "9" if "HighArticle" in prompt else "3"
|
||||
return FakeResp_news_service(json_data={"choices": [{"message": {"content": grade}}]})
|
||||
class FailingApiClient(FakeClient_news_service):
|
||||
async def get(self, url, timeout=None):
|
||||
if url == API_URL:
|
||||
raise httpx.HTTPError("api down")
|
||||
return FakeResp_news_service(text="")
|
||||
def _settings_stub(threshold="7"):
|
||||
def fake_get_setting(key, default=None):
|
||||
return {
|
||||
"news_api_url": API_URL,
|
||||
"news_ai_url": AI_URL,
|
||||
"news_ai_model": "test-model",
|
||||
"news_grade_threshold": threshold,
|
||||
"news_ai_key": "",
|
||||
}.get(key, default)
|
||||
|
||||
return fake_get_setting
|
||||
|
||||
|
||||
def test_extract_grade_parsing():
|
||||
assert _extract_grade("8") == 8
|
||||
assert _extract_grade("Grade: 9") == 9
|
||||
assert _extract_grade("Score is 7/10") == 7
|
||||
assert _extract_grade("0") is None
|
||||
assert _extract_grade("11") is None
|
||||
assert _extract_grade("not a number") is None
|
||||
|
||||
|
||||
def test_get_ai_key_env_precedence(monkeypatch):
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
monkeypatch.setenv("NEWS_AI_KEY", "primary")
|
||||
assert _get_ai_key() == "primary"
|
||||
|
||||
|
||||
def test_get_ai_key_setting_fallback(monkeypatch):
|
||||
monkeypatch.delenv("NEWS_AI_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
news_mod,
|
||||
"get_setting",
|
||||
lambda key, default=None: "from-setting" if key == "news_ai_key" else default,
|
||||
)
|
||||
assert _get_ai_key() == "from-setting"
|
||||
|
||||
|
||||
def test_get_ai_key_internal_gateway_fallback(local_db, monkeypatch):
|
||||
from devplacepy.database import internal_gateway_key
|
||||
|
||||
monkeypatch.delenv("NEWS_AI_KEY", raising=False)
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
assert _get_ai_key() == internal_gateway_key()
|
||||
|
||||
|
||||
def test_grade_article_empty_content_returns_none(local_db, monkeypatch):
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
grade = run_async(
|
||||
NewsService()._grade_article(
|
||||
{"title": "EmptyArticle", "description": "d", "content": "c"},
|
||||
AI_URL,
|
||||
"m",
|
||||
FakeClient_news_service([]),
|
||||
)
|
||||
)
|
||||
assert grade is None
|
||||
|
||||
|
||||
def test_grade_article_unparseable_returns_none(local_db, monkeypatch):
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
grade = run_async(
|
||||
NewsService()._grade_article(
|
||||
{"title": "BadArticle", "description": "d", "content": "c"},
|
||||
AI_URL,
|
||||
"m",
|
||||
FakeClient_news_service([]),
|
||||
)
|
||||
)
|
||||
assert grade is None
|
||||
|
||||
|
||||
def test_run_once_handles_api_failure(local_db, monkeypatch):
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
||||
monkeypatch.setattr(base_mod, "get_setting", _settings_stub())
|
||||
monkeypatch.setattr(
|
||||
news_mod.httpx, "AsyncClient", lambda *a, **k: FailingApiClient([])
|
||||
)
|
||||
run_async(NewsService().run_once())
|
||||
|
||||
|
||||
def test_run_once_updates_existing_news_row(local_db, monkeypatch):
|
||||
external_id = f"news-{generate_uid()}"
|
||||
existing_uid = generate_uid()
|
||||
get_table("news").insert(
|
||||
{
|
||||
"uid": existing_uid,
|
||||
"external_id": external_id,
|
||||
"slug": "",
|
||||
"title": "Old Title",
|
||||
"status": "draft",
|
||||
"grade": 0,
|
||||
"synced_at": "2020-01-01",
|
||||
}
|
||||
)
|
||||
articles = [
|
||||
{
|
||||
"guid": external_id,
|
||||
"title": "HighArticle",
|
||||
"description": "d",
|
||||
"content": "c",
|
||||
"link": "",
|
||||
"feed_name": "Feed",
|
||||
"author": "A",
|
||||
"published": "2026-01-01",
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7"))
|
||||
monkeypatch.setattr(base_mod, "get_setting", _settings_stub(threshold="7"))
|
||||
monkeypatch.setattr(
|
||||
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient_news_service(articles)
|
||||
)
|
||||
|
||||
run_async(NewsService().run_once())
|
||||
|
||||
row = get_table("news").find_one(uid=existing_uid)
|
||||
assert row["title"] == "HighArticle"
|
||||
assert row["status"] == "published"
|
||||
assert get_table("news").count(external_id=external_id) == 1
|
||||
|
||||
|
||||
def test_get_article_images_filters_and_dedupes():
|
||||
html = (
|
||||
'<img src="http://img.test/a.png">'
|
||||
"<img src='http://img.test/a.png'>"
|
||||
'<img src="http://img.test/b.svg">'
|
||||
'<img src="/relative.png">'
|
||||
)
|
||||
client = FakeClient_news_service([])
|
||||
|
||||
async def fake_get(url, timeout=None):
|
||||
return FakeResp_news_service(text=html)
|
||||
|
||||
client.get = fake_get
|
||||
images = run_async(_get_article_images("http://news.test/page", client))
|
||||
assert [img["url"] for img in images] == ["http://img.test/a.png"]
|
||||
|
||||
|
||||
def test_run_once_publishes_grades_and_is_idempotent(local_db, monkeypatch):
|
||||
g_high, g_low, g_fail = (f"news-{generate_uid()}" for _ in range(3))
|
||||
articles = [
|
||||
{
|
||||
"guid": g_high,
|
||||
"title": "HighArticle",
|
||||
"description": "d",
|
||||
"content": "c",
|
||||
"link": LINK_HIGH,
|
||||
"feed_name": "Feed",
|
||||
"author": "A",
|
||||
"published": "2026-01-01",
|
||||
},
|
||||
{
|
||||
"guid": g_low,
|
||||
"title": "LowArticle",
|
||||
"description": "d",
|
||||
"content": "c",
|
||||
"link": LINK_LOW,
|
||||
"feed_name": "Feed",
|
||||
"author": "A",
|
||||
"published": "2026-01-01",
|
||||
},
|
||||
{
|
||||
"guid": g_fail,
|
||||
"title": "FailArticle",
|
||||
"description": "d",
|
||||
"content": "c",
|
||||
"link": "",
|
||||
"feed_name": "Feed",
|
||||
"author": "A",
|
||||
"published": "2026-01-01",
|
||||
},
|
||||
{
|
||||
"guid": "",
|
||||
"title": "NoGuid",
|
||||
"description": "d",
|
||||
"content": "c",
|
||||
"link": "",
|
||||
"feed_name": "Feed",
|
||||
"author": "A",
|
||||
"published": "2026-01-01",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7"))
|
||||
monkeypatch.setattr(base_mod, "get_setting", _settings_stub(threshold="7"))
|
||||
monkeypatch.setattr(
|
||||
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient_news_service(articles)
|
||||
)
|
||||
|
||||
run_async(NewsService().run_once())
|
||||
|
||||
news = get_table("news")
|
||||
sync = get_table("news_sync")
|
||||
assert news.find_one(external_id=g_high)["status"] == "published"
|
||||
assert news.find_one(external_id=g_high)["grade"] == 9
|
||||
assert news.find_one(external_id=g_low)["status"] == "draft"
|
||||
assert news.find_one(external_id=g_low)["grade"] == 3
|
||||
fail_row = news.find_one(external_id=g_fail)
|
||||
assert fail_row["status"] == "draft"
|
||||
assert fail_row["grade"] == 0
|
||||
assert sync.find_one(external_id=g_fail)["status"] == "grading_failed"
|
||||
assert sync.find_one(external_id=g_high)["status"] == "graded"
|
||||
|
||||
run_async(NewsService().run_once())
|
||||
assert news.count(external_id=g_high) == 1
|
||||
assert news.count(external_id=g_fail) == 1
|
||||
@@ -0,0 +1,177 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from starlette.requests import Request
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, set_setting
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.services.openai_gateway import GatewayService
|
||||
from devplacepy.services.openai_gateway.analytics import build_user_usage
|
||||
from devplacepy.services.devii.config import (
|
||||
build_settings,
|
||||
FIELD_AI_URL,
|
||||
FIELD_AI_MODEL,
|
||||
FIELD_AI_KEY,
|
||||
FIELD_MAX_ITERATIONS,
|
||||
FIELD_PLAN_REQUIRED,
|
||||
FIELD_VERIFY_REQUIRED,
|
||||
)
|
||||
def _now_iso_ai_usage_profile():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
def _make_user_ai_usage_profile(role="Member"):
|
||||
username = f"aiu_{generate_uid()[:8]}"
|
||||
api_key = generate_uid()
|
||||
uid = generate_uid()
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": api_key,
|
||||
"role": role,
|
||||
"is_active": True,
|
||||
}
|
||||
)
|
||||
return uid, username, api_key
|
||||
def _make_request_ai_usage_profile(headers):
|
||||
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/openai/v1/chat/completions",
|
||||
"query_string": b"",
|
||||
"headers": raw,
|
||||
"state": {},
|
||||
}
|
||||
)
|
||||
def _seed_gateway(uid, n=3, cost=0.01, kind="user"):
|
||||
table = get_table("gateway_usage_ledger")
|
||||
for _ in range(n):
|
||||
table.insert(
|
||||
{
|
||||
"created_at": _now_iso_ai_usage_profile(),
|
||||
"owner_kind": kind,
|
||||
"owner_id": uid,
|
||||
"backend": "chat",
|
||||
"model": "molodetz",
|
||||
"success": 1,
|
||||
"status_code": 200,
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 300,
|
||||
"total_tokens": 1300,
|
||||
"upstream_latency_ms": 1500.0,
|
||||
"tokens_per_second": 40.0,
|
||||
"cost_usd": cost,
|
||||
}
|
||||
)
|
||||
def _devii_cfg():
|
||||
return {
|
||||
FIELD_AI_URL: "",
|
||||
FIELD_AI_MODEL: "",
|
||||
FIELD_AI_KEY: "INTERNAL-KEY",
|
||||
FIELD_MAX_ITERATIONS: "40",
|
||||
FIELD_PLAN_REQUIRED: "1",
|
||||
FIELD_VERIFY_REQUIRED: "1",
|
||||
}
|
||||
def _goto_profile(page, username):
|
||||
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def test_devii_user_session_uses_own_api_key(local_db):
|
||||
cfg = _devii_cfg()
|
||||
user_settings = build_settings(cfg, "http://x", "USER-KEY-123", "user")
|
||||
guest_settings = build_settings(cfg, "http://x", "", "guest")
|
||||
assert user_settings.ai_key == "USER-KEY-123"
|
||||
assert user_settings.platform_api_key == "USER-KEY-123"
|
||||
assert guest_settings.ai_key == "INTERNAL-KEY"
|
||||
|
||||
|
||||
def test_gateway_allows_users_by_default(local_db):
|
||||
field = next(
|
||||
f for f in GatewayService().config_fields if f.key == "gateway_allow_users"
|
||||
)
|
||||
assert field.default is True
|
||||
|
||||
|
||||
def test_gateway_attributes_calls_to_the_user(local_db):
|
||||
member_uid, _, member_key = _make_user_ai_usage_profile(role="Member")
|
||||
admin_uid, _, admin_key = _make_user_ai_usage_profile(role="Admin")
|
||||
svc = GatewayService()
|
||||
assert svc.resolve_owner(
|
||||
_make_request_ai_usage_profile({"Authorization": f"Bearer {member_key}"})
|
||||
) == ("user", member_uid)
|
||||
assert svc.resolve_owner(_make_request_ai_usage_profile({"X-API-KEY": admin_key})) == (
|
||||
"admin",
|
||||
admin_uid,
|
||||
)
|
||||
|
||||
|
||||
def test_build_user_usage_aggregates(local_db):
|
||||
uid, _, _ = _make_user_ai_usage_profile()
|
||||
_seed_gateway(uid, n=4, cost=0.02)
|
||||
out = build_user_usage(uid, hours=24)
|
||||
assert out["owner_id"] == uid
|
||||
assert out["requests"] == 4
|
||||
assert out["success"] == 4
|
||||
assert out["success_pct"] == 100.0
|
||||
assert round(out["cost"]["window_usd"], 4) == 0.08
|
||||
assert out["cost"]["projected_30d_usd"] == round(0.08 * 30, 2)
|
||||
assert out["tokens"]["total"] == 4 * 1300
|
||||
assert any(m["key"] == "molodetz" for m in out["by_model"])
|
||||
assert out["hourly"]
|
||||
|
||||
|
||||
def test_build_user_usage_isolates_users(local_db):
|
||||
mine, _, _ = _make_user_ai_usage_profile()
|
||||
other, _, _ = _make_user_ai_usage_profile()
|
||||
_seed_gateway(mine, n=2, cost=0.05)
|
||||
_seed_gateway(other, n=9, cost=0.05)
|
||||
out = build_user_usage(mine, hours=24)
|
||||
assert out["requests"] == 2
|
||||
|
||||
|
||||
def test_build_user_usage_empty_for_unknown(local_db):
|
||||
out = build_user_usage("no-such-uid-xyz", hours=24)
|
||||
assert out["requests"] == 0
|
||||
assert out["cost"]["projected_30d_usd"] == 0.0
|
||||
assert "projection" in out["notes"]
|
||||
|
||||
|
||||
def test_ai_quota_percentage_and_clamp(local_db):
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.devii import DeviiService
|
||||
from devplacepy.routers.profile import _ai_quota
|
||||
|
||||
if service_manager.get_service("devii") is None:
|
||||
service_manager.register(DeviiService())
|
||||
set_setting("devii_user_daily_usd", "2.0")
|
||||
try:
|
||||
half, _, _ = _make_user_ai_usage_profile()
|
||||
get_table("devii_usage_ledger").insert(
|
||||
{
|
||||
"owner_kind": "user",
|
||||
"owner_id": half,
|
||||
"created_at": _now_iso_ai_usage_profile(),
|
||||
"cost_usd": 1.0,
|
||||
}
|
||||
)
|
||||
quota = _ai_quota(half)
|
||||
assert quota is not None
|
||||
assert quota["used_pct"] == 50.0
|
||||
assert quota["turns"] == 1
|
||||
|
||||
over, _, _ = _make_user_ai_usage_profile()
|
||||
for _ in range(3):
|
||||
get_table("devii_usage_ledger").insert(
|
||||
{
|
||||
"owner_kind": "user",
|
||||
"owner_id": over,
|
||||
"created_at": _now_iso_ai_usage_profile(),
|
||||
"cost_usd": 1.0,
|
||||
}
|
||||
)
|
||||
capped = _ai_quota(over)
|
||||
assert capped["used_pct"] == 100.0
|
||||
finally:
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
@@ -0,0 +1,204 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import requests
|
||||
from starlette.requests import Request
|
||||
from tests.conftest import BASE_URL, run_async
|
||||
from devplacepy.database import get_table, set_setting
|
||||
from devplacepy.utils import generate_uid
|
||||
import devplacepy.services.openai_gateway.gateway as gwmod
|
||||
from devplacepy.services.openai_gateway import GatewayService
|
||||
class FakeResp_openai_gateway:
|
||||
def __init__(self, status=200, payload=None, ctype="application/json", content=b""):
|
||||
self.status_code = status
|
||||
self._payload = payload
|
||||
self.text = json.dumps(payload) if payload is not None else ""
|
||||
self.headers = {"content-type": ctype}
|
||||
self.content = content
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
class FakeRequest:
|
||||
def __init__(self, method, url, json_body):
|
||||
self.method = method
|
||||
self.url = url
|
||||
self.json_body = json_body
|
||||
self.extensions = {}
|
||||
class FakeClient_openai_gateway:
|
||||
def __init__(self, *a, **k):
|
||||
self.calls = []
|
||||
|
||||
def build_request(self, method, url, headers=None, json=None, content=None):
|
||||
return FakeRequest(method, url, json)
|
||||
|
||||
async def send(self, request):
|
||||
self.calls.append((request.url, request.json_body))
|
||||
body = request.json_body or {}
|
||||
return FakeResp_openai_gateway(
|
||||
payload={
|
||||
"id": "x",
|
||||
"model": body.get("model"),
|
||||
"choices": [{"message": {"content": "hi there"}}],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self, url, headers=None, json=None, timeout=None):
|
||||
self.calls.append((url, json))
|
||||
return FakeResp_openai_gateway(
|
||||
payload={
|
||||
"id": "x",
|
||||
"model": json.get("model"),
|
||||
"choices": [{"message": {"content": "hi there"}}],
|
||||
}
|
||||
)
|
||||
|
||||
async def request(self, method, url, headers=None, content=None):
|
||||
return FakeResp_openai_gateway(payload={"ok": True})
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
def _make_request_openai_gateway(headers=None, cookies=None):
|
||||
headers = headers or {}
|
||||
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
||||
if cookies:
|
||||
raw.append(
|
||||
(b"cookie", "; ".join(f"{k}={v}" for k, v in cookies.items()).encode())
|
||||
)
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/openai/v1/chat/completions",
|
||||
"query_string": b"",
|
||||
"headers": raw,
|
||||
"state": {},
|
||||
}
|
||||
)
|
||||
def _make_admin_openai_gateway(role="Admin"):
|
||||
username = f"gw_{generate_uid()[:8]}"
|
||||
api_key = generate_uid()
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"username": username,
|
||||
"email": f"{username}@t.dev",
|
||||
"api_key": api_key,
|
||||
"role": role,
|
||||
"is_active": True,
|
||||
}
|
||||
)
|
||||
return username, api_key
|
||||
def _config(page, **fields):
|
||||
page.request.post(f"{BASE_URL}/admin/services/openai/config", form=fields)
|
||||
|
||||
|
||||
def test_model_is_forced(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_force_model"] = True
|
||||
cfg["gateway_model"] = "deepseek-chat"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
)
|
||||
)
|
||||
assert rt._client.calls[-1][1]["model"] == "deepseek-chat"
|
||||
|
||||
|
||||
def test_vision_rewrites_image_to_text(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_vision_enabled"] = True
|
||||
cfg["gateway_vision_key"] = "" # no key -> placeholder text, still rewrites to str
|
||||
rt = svc.runtime()
|
||||
msgs = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what is this"},
|
||||
{"type": "image_url", "image_url": {"url": "http://x/y.png"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
run_async(rt.handle_chat({"messages": msgs}, cfg, ("guest", "test"), "test"))
|
||||
sent = rt._client.calls[-1][1]["messages"][0]["content"]
|
||||
assert isinstance(sent, str) and "vision" in sent.lower()
|
||||
|
||||
|
||||
def test_streaming_emits_sse(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
rt = svc.runtime()
|
||||
resp = run_async(
|
||||
rt.handle_chat(
|
||||
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
)
|
||||
)
|
||||
|
||||
async def drain():
|
||||
out = []
|
||||
async for chunk in resp.body_iterator:
|
||||
out.append(chunk if isinstance(chunk, str) else chunk.decode())
|
||||
return "".join(out)
|
||||
|
||||
body = run_async(drain())
|
||||
assert "chat.completion.chunk" in body
|
||||
assert "[DONE]" in body
|
||||
|
||||
|
||||
def test_authorize_static_access_key(local_db):
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "topsecret")
|
||||
try:
|
||||
svc = GatewayService()
|
||||
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": "topsecret"})) is True
|
||||
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": "wrong"})) is False
|
||||
finally:
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
|
||||
|
||||
def test_authorize_admin_and_user_toggles(local_db):
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_allow_admins", "1")
|
||||
set_setting("gateway_allow_users", "0")
|
||||
try:
|
||||
_, admin_key = _make_admin_openai_gateway(role="Admin")
|
||||
_, member_key = _make_admin_openai_gateway(role="Member")
|
||||
svc = GatewayService()
|
||||
|
||||
assert (
|
||||
svc.authorize(_make_request_openai_gateway(headers={"Authorization": f"Bearer {admin_key}"}))
|
||||
is True
|
||||
)
|
||||
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": member_key})) is False
|
||||
|
||||
set_setting("gateway_allow_users", "1")
|
||||
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": member_key})) is True
|
||||
finally:
|
||||
set_setting("gateway_allow_admins", "1")
|
||||
set_setting("gateway_allow_users", "1")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "")
|
||||
|
||||
|
||||
def test_authorize_require_auth_off_is_open(local_db):
|
||||
set_setting("gateway_require_auth", "0")
|
||||
try:
|
||||
svc = GatewayService()
|
||||
assert svc.authorize(_make_request_openai_gateway()) is True
|
||||
finally:
|
||||
set_setting("gateway_require_auth", "1")
|
||||
Reference in New Issue
Block a user