forked from retoor/devplacepy
Add personal notes, DeepSearch history, backup offload, and gateway auth throttling
Also streamline the top navigation: drop the Tools dropdown, make Quizzes and Battles icon-only entries, and remove the Workspace, Containers and Editor entry points from the project detail page.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from devplacepy.docs_api import field, endpoint
|
||||
|
||||
|
||||
def test_field_defaults_nullable_false():
|
||||
spec = field("username", "path", "string", True, "alice", "A username.")
|
||||
assert spec["nullable"] is False
|
||||
|
||||
|
||||
def test_field_nullable_true_is_threaded_into_spec():
|
||||
spec = field(
|
||||
"avatar_seed",
|
||||
"response",
|
||||
"string",
|
||||
description="Null falls back to the username.",
|
||||
nullable=True,
|
||||
)
|
||||
assert spec["nullable"] is True
|
||||
assert spec["location"] == "response"
|
||||
|
||||
|
||||
def test_endpoint_carries_response_field_specs_in_params():
|
||||
ep = endpoint(
|
||||
id="example",
|
||||
method="GET",
|
||||
path="/example",
|
||||
title="Example",
|
||||
summary="An example endpoint.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "response", "string", nullable=False),
|
||||
field("bio", "response", "string", nullable=True),
|
||||
],
|
||||
)
|
||||
by_name = {p["name"]: p for p in ep["params"]}
|
||||
assert by_name["uid"]["nullable"] is False
|
||||
assert by_name["bio"]["nullable"] is True
|
||||
@@ -518,6 +518,30 @@ def test_sweep_orphan_project_file_blobs_keeps_blob_referenced_only_by_soft_dele
|
||||
assert (tmp_path / directory / stored_name).exists()
|
||||
|
||||
|
||||
def test_export_to_dir_summarizes_missing_blobs(local_db, tmp_path, monkeypatch, caplog):
|
||||
import logging
|
||||
|
||||
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
|
||||
uid = _insert_binary_node("export-missing-proj", "ab/cd", "gone.bin")
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
pf._table().update(
|
||||
{
|
||||
"uid": uid,
|
||||
"path": "gone.bin",
|
||||
"name": "gone.bin",
|
||||
"parent_path": "",
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="devplacepy.project_files"):
|
||||
written = pf.export_to_dir("export-missing-proj", "", dest)
|
||||
assert written == 0
|
||||
messages = [record.getMessage() for record in caplog.records]
|
||||
assert any("Skipped 1 missing blob" in message for message in messages)
|
||||
assert not any("Blob file missing" in message for message in messages)
|
||||
|
||||
|
||||
def test_sweep_orphan_project_file_blobs_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
|
||||
directory = "ab/cd"
|
||||
|
||||
+69
-1
@@ -2,7 +2,12 @@
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from devplacepy.rendering import render_content, render_title
|
||||
from devplacepy.rendering import (
|
||||
markdown_structure_signature,
|
||||
render_content,
|
||||
render_title,
|
||||
safe_truncate,
|
||||
)
|
||||
|
||||
|
||||
def test_emoji_shortcodes():
|
||||
@@ -12,6 +17,37 @@ def test_emoji_shortcodes():
|
||||
assert ":rocket:" not in out
|
||||
|
||||
|
||||
def test_markdown_structure_signature_counts_elements():
|
||||
text = (
|
||||
"# Title\n\n"
|
||||
"- one\n- two\n\n"
|
||||
"1. first\n2. second\n\n"
|
||||
"```python\nprint(1)\n```\n\n"
|
||||
"[link](https://example.com)\n"
|
||||
)
|
||||
assert markdown_structure_signature(text) == {
|
||||
"code_fences": 1,
|
||||
"list_items": 4,
|
||||
"headers": 1,
|
||||
"links": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_markdown_structure_signature_blank_text():
|
||||
assert markdown_structure_signature("") == {
|
||||
"code_fences": 0,
|
||||
"list_items": 0,
|
||||
"headers": 0,
|
||||
"links": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_markdown_structure_signature_ignores_word_changes():
|
||||
original = markdown_structure_signature("- alpha\n- beta\n- gamma\n")
|
||||
reworded = markdown_structure_signature("- Alpha\n- beta\n- gamma\n")
|
||||
assert original == reworded
|
||||
|
||||
|
||||
def test_markdown_bold_and_italic():
|
||||
out = str(render_content("this is **bold** and _em_"))
|
||||
assert "<strong>bold</strong>" in out
|
||||
@@ -47,6 +83,38 @@ def test_plain_url_autolinked():
|
||||
assert '<a href="https://example.com/page" target="_blank" rel="noopener noreferrer">' in out
|
||||
|
||||
|
||||
def test_safe_truncate_short_text_unchanged():
|
||||
text = "short content"
|
||||
assert safe_truncate(text, 300) == text
|
||||
|
||||
|
||||
def test_safe_truncate_backs_off_to_word_boundary():
|
||||
text = "a" * 45 + " " + "b" * 45
|
||||
truncated = safe_truncate(text, 50)
|
||||
assert truncated == "a" * 45
|
||||
|
||||
|
||||
def test_safe_truncate_does_not_split_a_url_straddling_the_cutoff():
|
||||
prefix = "word " * 58
|
||||
url = "https://example.com/gallery/a-very-long-descriptive-photo-name.jpg"
|
||||
text = prefix + url + " more words after the link that get cut off entirely"
|
||||
truncated = safe_truncate(text, 300)
|
||||
assert url not in truncated
|
||||
assert "example.com" not in truncated
|
||||
out = str(render_content(truncated + "..."))
|
||||
assert "<img" not in out
|
||||
assert "<a href=" not in out
|
||||
|
||||
|
||||
def test_safe_truncate_keeps_a_url_that_fits_before_the_cutoff():
|
||||
url = "https://example.com/image.png"
|
||||
text = url + " " + ("word " * 60)
|
||||
truncated = safe_truncate(text, 300)
|
||||
assert url in truncated
|
||||
out = str(render_content(truncated))
|
||||
assert f'<img src="{url}"' in out
|
||||
|
||||
|
||||
def test_mention_links_to_profile():
|
||||
out = str(render_content("hello @alice_test there"))
|
||||
assert '<a href="/profile/alice_test" class="mention-link">@alice_test</a>' in out
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services import ai_modifier, correction
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content: str) -> None:
|
||||
self._content = content
|
||||
self.headers: dict = {}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"choices": [{"message": {"content": self._content}}]}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
self.calls.append({"url": url, "json": json, "headers": headers})
|
||||
return _FakeResponse("modified text")
|
||||
|
||||
|
||||
def test_modify_text_requests_the_preamble_bypass(local_db, monkeypatch):
|
||||
fake = _FakeClient()
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
ai_modifier.modify_text("user-api-key", "", "@ai fix this")
|
||||
sent = fake.calls[-1]
|
||||
assert sent["json"]["bypass_preamble"] is True
|
||||
@@ -0,0 +1,58 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from tests.conftest import run_async
|
||||
from devplacepy.services.backup import offload, store
|
||||
|
||||
|
||||
def _make_backup(target):
|
||||
job_uid = f"job-{store.generate_uid()}"
|
||||
uid = store.create_backup(target=target, created_by="test", job_uid=job_uid)
|
||||
path = Path(tempfile.gettempdir()) / f"backup-offload-test-{uid}.tar.gz"
|
||||
path.write_bytes(b"x" * 10)
|
||||
store.finalize_backup(
|
||||
uid,
|
||||
filename=path.name,
|
||||
local_path=str(path),
|
||||
stats={
|
||||
"bytes_out": 10,
|
||||
"bytes_in": 10,
|
||||
"file_count": 1,
|
||||
"dir_count": 0,
|
||||
"sha256": "abc",
|
||||
},
|
||||
)
|
||||
return uid, path
|
||||
|
||||
|
||||
def test_upload_pending_stops_after_auth_error(local_db, monkeypatch):
|
||||
first_uid, first_path = _make_backup("database")
|
||||
second_uid, second_path = _make_backup("uploads")
|
||||
calls = []
|
||||
|
||||
async def fake_rclone(*args, timeout=1800.0):
|
||||
calls.append(args)
|
||||
return 1, "", "401 Unauthorized from Apache"
|
||||
|
||||
monkeypatch.setattr(offload, "_run_rclone", fake_rclone)
|
||||
logs = []
|
||||
try:
|
||||
uploaded = run_async(offload.upload_pending(log=logs.append))
|
||||
assert uploaded == 0
|
||||
assert len(calls) == 1
|
||||
assert any("halted" in line.lower() for line in logs)
|
||||
finally:
|
||||
first_path.unlink(missing_ok=True)
|
||||
second_path.unlink(missing_ok=True)
|
||||
store.delete_backup(first_uid)
|
||||
store.delete_backup(second_uid)
|
||||
|
||||
|
||||
def test_is_auth_error_detects_rclone_html_401():
|
||||
assert offload._is_auth_error(
|
||||
'read metadata failed: <title>401 Unauthorized</title>: 401 Unauthorized'
|
||||
)
|
||||
assert offload._is_auth_error("didn't find section in config file")
|
||||
assert not offload._is_auth_error("connection timed out")
|
||||
@@ -0,0 +1,72 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.backup import service, store
|
||||
|
||||
|
||||
def _stats(used_percent):
|
||||
return {
|
||||
"disk": {
|
||||
"total_bytes": 100,
|
||||
"used_bytes": used_percent,
|
||||
"free_bytes": 100 - used_percent,
|
||||
"total_human": "100 B",
|
||||
"used_human": f"{used_percent} B",
|
||||
"free_human": f"{100 - used_percent} B",
|
||||
"used_percent": used_percent,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _disk(used_percent):
|
||||
return _stats(used_percent)["disk"]
|
||||
|
||||
|
||||
def test_check_disk_usage_warns_once_and_recovers_once(local_db, monkeypatch):
|
||||
svc = service.BackupService()
|
||||
assert svc.disk_warn_percent_field.default == service.DEFAULT_DISK_WARN_PERCENT
|
||||
logs = []
|
||||
monkeypatch.setattr(svc, "log", lambda message: logs.append(message))
|
||||
|
||||
monkeypatch.setattr(store, "disk_usage", lambda: _disk(50))
|
||||
svc._check_disk_usage()
|
||||
assert logs == []
|
||||
assert svc._disk_warned is False
|
||||
|
||||
monkeypatch.setattr(store, "disk_usage", lambda: _disk(95))
|
||||
svc._check_disk_usage()
|
||||
assert len(logs) == 1
|
||||
assert "critical" in logs[0].lower()
|
||||
assert svc._disk_warned is True
|
||||
|
||||
svc._check_disk_usage()
|
||||
assert len(logs) == 1
|
||||
|
||||
monkeypatch.setattr(store, "disk_usage", lambda: _disk(40))
|
||||
svc._check_disk_usage()
|
||||
assert len(logs) == 2
|
||||
assert "under threshold" in logs[1].lower()
|
||||
assert svc._disk_warned is False
|
||||
|
||||
|
||||
def test_collect_metrics_includes_disk_usage_stat(local_db, monkeypatch):
|
||||
svc = service.BackupService()
|
||||
monkeypatch.setattr(store, "disk_usage", lambda: _disk(62))
|
||||
|
||||
metrics = svc.collect_metrics()
|
||||
|
||||
labels = [stat["label"] for stat in metrics["stats"]]
|
||||
assert "Disk usage" in labels
|
||||
disk_stat = next(stat for stat in metrics["stats"] if stat["label"] == "Disk usage")
|
||||
assert "62%" in disk_stat["value"]
|
||||
|
||||
|
||||
def test_collect_metrics_never_walks_storage_tree(local_db, monkeypatch):
|
||||
svc = service.BackupService()
|
||||
monkeypatch.setattr(store, "disk_usage", lambda: _disk(10))
|
||||
|
||||
def boom():
|
||||
raise AssertionError("compute_storage_stats must not run on the service tick")
|
||||
|
||||
monkeypatch.setattr(store, "compute_storage_stats", boom)
|
||||
svc.collect_metrics()
|
||||
svc._check_disk_usage()
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -65,3 +66,90 @@ def test_rotate_schedule_never_removes_a_backup_without_a_confirmed_remote_copy(
|
||||
finally:
|
||||
store.delete_backup(older_uid)
|
||||
store.delete_backup(newer_uid)
|
||||
|
||||
|
||||
def _point_storage_at(monkeypatch, root: Path):
|
||||
uploads = root / "uploads"
|
||||
attachments = uploads / "attachments"
|
||||
project_files = uploads / "project_files"
|
||||
keys = root / "keys"
|
||||
zips = root / "zips"
|
||||
deepsearch = root / "deepsearch"
|
||||
workspaces = root / "container_workspaces"
|
||||
backups = root / "backups"
|
||||
for path in (
|
||||
attachments,
|
||||
project_files,
|
||||
keys,
|
||||
zips,
|
||||
deepsearch,
|
||||
workspaces,
|
||||
backups,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
db_file = root / "devplace.db"
|
||||
db_file.write_bytes(b"db")
|
||||
tasks = root / "devii_tasks.db"
|
||||
tasks.write_bytes(b"t")
|
||||
lessons = root / "devii_lessons.db"
|
||||
lessons.write_bytes(b"l")
|
||||
(project_files / "a.bin").write_bytes(b"x" * 10)
|
||||
(attachments / "b.bin").write_bytes(b"y" * 5)
|
||||
monkeypatch.setattr(store.config, "DATA_DIR", root)
|
||||
monkeypatch.setattr(store.config, "UPLOADS_DIR", uploads)
|
||||
monkeypatch.setattr(store.config, "ATTACHMENTS_DIR", attachments)
|
||||
monkeypatch.setattr(store.config, "PROJECT_FILES_DIR", project_files)
|
||||
monkeypatch.setattr(store.config, "KEYS_DIR", keys)
|
||||
monkeypatch.setattr(store.config, "ZIPS_DIR", zips)
|
||||
monkeypatch.setattr(store.config, "DEEPSEARCH_DIR", deepsearch)
|
||||
monkeypatch.setattr(store.config, "CONTAINER_WORKSPACES_DIR", workspaces)
|
||||
monkeypatch.setattr(store.config, "BACKUPS_DIR", backups)
|
||||
monkeypatch.setattr(store.config, "DEVII_TASKS_DB", tasks)
|
||||
monkeypatch.setattr(store.config, "DEVII_LESSONS_DB", lessons)
|
||||
monkeypatch.setattr(store.config, "DATABASE_URL", f"sqlite:///{db_file}")
|
||||
store.clear_storage_stats_cache()
|
||||
return {
|
||||
"uploads": uploads,
|
||||
"attachments": attachments,
|
||||
"project_files": project_files,
|
||||
"db_file": db_file,
|
||||
}
|
||||
|
||||
|
||||
def test_disk_usage_does_not_walk(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(store.config, "DATA_DIR", tmp_path)
|
||||
store.clear_storage_stats_cache()
|
||||
|
||||
def boom(*_args, **_kwargs):
|
||||
raise AssertionError("disk_usage must not walk the data tree")
|
||||
|
||||
monkeypatch.setattr(store.os, "walk", boom)
|
||||
disk = store.disk_usage()
|
||||
assert disk["total_bytes"] > 0
|
||||
assert "used_percent" in disk
|
||||
|
||||
|
||||
def test_compute_storage_stats_walks_data_dir_once(local_db, monkeypatch, tmp_path):
|
||||
root = tmp_path / "data"
|
||||
_point_storage_at(monkeypatch, root)
|
||||
walks = []
|
||||
real_walk = os.walk
|
||||
|
||||
def counting_walk(path, *args, **kwargs):
|
||||
walks.append(os.path.realpath(path))
|
||||
return real_walk(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(store.os, "walk", counting_walk)
|
||||
stats = store.compute_storage_stats()
|
||||
assert walks == [os.path.realpath(root)]
|
||||
by_key = {row["key"]: row for row in stats["paths"]}
|
||||
assert by_key["project_files"]["file_count"] == 1
|
||||
assert by_key["project_files"]["size_bytes"] == 10
|
||||
assert by_key["attachments"]["file_count"] == 1
|
||||
assert by_key["uploads"]["file_count"] >= 2
|
||||
assert by_key["database"]["file_count"] == 1
|
||||
assert stats["data_dir"]["file_count"] >= 3
|
||||
walks.clear()
|
||||
again = store.compute_storage_stats()
|
||||
assert walks == []
|
||||
assert again["data_dir"]["file_count"] == stats["data_dir"]["file_count"]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.bot.browser import NATIVE_FILL_TYPES, uses_native_fill
|
||||
|
||||
|
||||
def test_uses_native_fill_for_date_widgets():
|
||||
assert uses_native_fill("date")
|
||||
assert uses_native_fill("DATE")
|
||||
assert uses_native_fill("datetime-local")
|
||||
assert uses_native_fill("month")
|
||||
assert uses_native_fill("time")
|
||||
assert uses_native_fill("week")
|
||||
|
||||
|
||||
def test_uses_native_fill_skips_text_fields():
|
||||
assert not uses_native_fill("text")
|
||||
assert not uses_native_fill("email")
|
||||
assert not uses_native_fill("password")
|
||||
assert not uses_native_fill("")
|
||||
|
||||
|
||||
def test_native_fill_types_are_the_date_family():
|
||||
assert NATIVE_FILL_TYPES == {
|
||||
"date",
|
||||
"datetime-local",
|
||||
"month",
|
||||
"time",
|
||||
"week",
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import set_setting
|
||||
from devplacepy.services import correction
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content: str) -> None:
|
||||
self._content = content
|
||||
self.headers: dict = {}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"choices": [{"message": {"content": self._content}}]}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
self.calls.append({"url": url, "json": json, "headers": headers})
|
||||
return _FakeResponse("corrected text")
|
||||
|
||||
|
||||
def test_gateway_complete_sets_bypass_preamble_and_internal_header(local_db, monkeypatch):
|
||||
set_setting("gateway_internal_key", "test-internal-secret")
|
||||
fake = _FakeClient()
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
try:
|
||||
content, _usage = correction.gateway_complete(
|
||||
"user-api-key",
|
||||
"system prompt",
|
||||
"hello world",
|
||||
5.0,
|
||||
bypass_preamble=True,
|
||||
)
|
||||
finally:
|
||||
set_setting("gateway_internal_key", "")
|
||||
assert content == "corrected text"
|
||||
sent = fake.calls[-1]
|
||||
assert sent["json"]["bypass_preamble"] is True
|
||||
assert sent["headers"]["Authorization"] == "Bearer user-api-key"
|
||||
assert sent["headers"]["X-Gateway-Internal-Key"] == "test-internal-secret"
|
||||
|
||||
|
||||
def test_gateway_complete_without_bypass_preamble_omits_it(local_db, monkeypatch):
|
||||
fake = _FakeClient()
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
correction.gateway_complete("user-api-key", "system prompt", "hello world", 5.0)
|
||||
sent = fake.calls[-1]
|
||||
assert "bypass_preamble" not in sent["json"]
|
||||
assert "X-Gateway-Internal-Key" not in sent["headers"]
|
||||
|
||||
|
||||
def test_correct_text_requests_the_preamble_bypass(local_db, monkeypatch):
|
||||
fake = _FakeClient()
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
correction.correct_text("user-api-key", "", "hello world")
|
||||
sent = fake.calls[-1]
|
||||
assert sent["json"]["bypass_preamble"] is True
|
||||
|
||||
|
||||
class _ScriptedClient:
|
||||
def __init__(self, content: str) -> None:
|
||||
self._content = content
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
self.calls.append({"url": url, "json": json, "headers": headers})
|
||||
return _FakeResponse(self._content)
|
||||
|
||||
|
||||
def test_structure_diverges_false_on_reworded_list_item():
|
||||
original = "- alpha\n- beta\n- gamma\n"
|
||||
corrected = "- Alpha\n- beta\n- gamma\n"
|
||||
assert correction.structure_diverges(original, corrected) is False
|
||||
|
||||
|
||||
def test_structure_diverges_true_on_flattened_list():
|
||||
original = "- alpha\n- beta\n- gamma\n"
|
||||
corrected = "alpha, beta and gamma."
|
||||
assert correction.structure_diverges(original, corrected) is True
|
||||
|
||||
|
||||
def test_gateway_complete_structure_check_disabled_by_default(local_db, monkeypatch):
|
||||
original = "- alpha\n- beta\n"
|
||||
corrected = "alpha and beta."
|
||||
fake = _ScriptedClient(corrected)
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
content, _usage = correction.gateway_complete(
|
||||
"user-api-key", "system prompt", original, 5.0
|
||||
)
|
||||
assert content == corrected
|
||||
|
||||
|
||||
def test_gateway_complete_structure_check_passes_typo_fix(local_db, monkeypatch):
|
||||
original = "- alpha\n- beta\n- gamma\n"
|
||||
corrected = "- Alpha\n- beta\n- gamma\n"
|
||||
fake = _ScriptedClient(corrected)
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
content, _usage = correction.gateway_complete(
|
||||
"user-api-key", "system prompt", original, 5.0, check_structure=True
|
||||
)
|
||||
assert content == corrected.strip()
|
||||
|
||||
|
||||
def test_gateway_complete_structure_check_rejects_flattened_list(local_db, monkeypatch):
|
||||
original = "- alpha\n- beta\n- gamma\n"
|
||||
corrected = "alpha, beta and gamma."
|
||||
fake = _ScriptedClient(corrected)
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
content, _usage = correction.gateway_complete(
|
||||
"user-api-key", "system prompt", original, 5.0, check_structure=True
|
||||
)
|
||||
assert content == original
|
||||
|
||||
|
||||
def test_gateway_complete_structure_check_rejects_dropped_code_fence(local_db, monkeypatch):
|
||||
original = "Here is the fix:\n\n```python\nprint(1)\n```\n"
|
||||
corrected = "Here is the fix:\n\nprint(1)\n"
|
||||
fake = _ScriptedClient(corrected)
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
content, _usage = correction.gateway_complete(
|
||||
"user-api-key", "system prompt", original, 5.0, check_structure=True
|
||||
)
|
||||
assert content == original
|
||||
|
||||
|
||||
def test_gateway_complete_structure_check_rejects_removed_link(local_db, monkeypatch):
|
||||
original = "Check out [DevPlace](https://example.com) for details."
|
||||
corrected = "Check out DevPlace for details."
|
||||
fake = _ScriptedClient(corrected)
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
content, _usage = correction.gateway_complete(
|
||||
"user-api-key", "system prompt", original, 5.0, check_structure=True
|
||||
)
|
||||
assert content == original
|
||||
|
||||
|
||||
def test_correct_text_rejects_corrupted_structure(local_db, monkeypatch):
|
||||
original = "# Setup\n\n- step one\n- step two\n"
|
||||
corrected = "Setup: step one, step two."
|
||||
fake = _ScriptedClient(corrected)
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
content, _usage = correction.correct_text("user-api-key", "", original)
|
||||
assert content == original
|
||||
|
||||
|
||||
def test_correct_text_keeps_grammar_only_correction(local_db, monkeypatch):
|
||||
original = "# Setup\n\n- step one\n- step two\n"
|
||||
corrected = "# Setup\n\n- Step one\n- Step two\n"
|
||||
fake = _ScriptedClient(corrected)
|
||||
monkeypatch.setattr(correction, "_client", lambda: fake)
|
||||
content, _usage = correction.correct_text("user-api-key", "", original)
|
||||
assert content == corrected.strip()
|
||||
@@ -190,3 +190,87 @@ def test_cli_reset_quota_all(local_db):
|
||||
_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
|
||||
|
||||
|
||||
def _quota_warnings(uid):
|
||||
from devplacepy.services.devii.service import QUOTA_WARNING_NOTIFICATION_TYPE
|
||||
|
||||
return list(
|
||||
get_table("notifications").find(
|
||||
user_uid=uid, type=QUOTA_WARNING_NOTIFICATION_TYPE
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_quota_warning_fires_once_past_80_percent(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
try:
|
||||
uid, _ = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 0.85)
|
||||
svc.maybe_warn_quota_threshold("user", uid, False)
|
||||
assert len(_quota_warnings(uid)) == 1
|
||||
finally:
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
|
||||
|
||||
def test_quota_warning_not_refired_within_same_window(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
try:
|
||||
uid, _ = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 0.85)
|
||||
svc.maybe_warn_quota_threshold("user", uid, False)
|
||||
_seed_ledger("user", uid, 0.05)
|
||||
svc.maybe_warn_quota_threshold("user", uid, False)
|
||||
assert len(_quota_warnings(uid)) == 1
|
||||
finally:
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
|
||||
|
||||
def test_quota_warning_skips_below_threshold(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
try:
|
||||
uid, _ = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 0.5)
|
||||
svc.maybe_warn_quota_threshold("user", uid, False)
|
||||
assert _quota_warnings(uid) == []
|
||||
finally:
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
|
||||
|
||||
def test_quota_warning_skips_admin(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_admin_daily_usd", "1.0")
|
||||
try:
|
||||
uid, _ = _make_user_devii_quota(role="Admin")
|
||||
_seed_ledger("user", uid, 0.95)
|
||||
svc.maybe_warn_quota_threshold("user", uid, True)
|
||||
assert _quota_warnings(uid) == []
|
||||
finally:
|
||||
set_setting("devii_admin_daily_usd", "0.0")
|
||||
|
||||
|
||||
def test_quota_warning_skips_unlimited_cap(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_user_daily_usd", "0")
|
||||
try:
|
||||
uid, _ = _make_user_devii_quota()
|
||||
_seed_ledger("user", uid, 50.0)
|
||||
svc.maybe_warn_quota_threshold("user", uid, False)
|
||||
assert _quota_warnings(uid) == []
|
||||
finally:
|
||||
set_setting("devii_user_daily_usd", "1.0")
|
||||
|
||||
|
||||
def test_quota_warning_skips_guests(local_db):
|
||||
svc = _ensure_devii()
|
||||
set_setting("devii_guest_daily_usd", "0.10")
|
||||
try:
|
||||
guest_id = f"guest-{generate_uid()[:8]}"
|
||||
_seed_ledger("guest", guest_id, 0.09)
|
||||
svc.maybe_warn_quota_threshold("guest", guest_id, False)
|
||||
assert get_table("notifications").find_one(user_uid=guest_id) is None
|
||||
finally:
|
||||
set_setting("devii_guest_daily_usd", "0.05")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.seo_meta_text import plain_seo_defaults
|
||||
from devplacepy.services.jobs import seo_meta_service
|
||||
|
||||
|
||||
def test_generate_requests_the_preamble_bypass(local_db, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_gateway_complete(
|
||||
api_key, system, text, timeout, model=None, bypass_preamble=False
|
||||
):
|
||||
seen["bypass_preamble"] = bypass_preamble
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
"seo_title": "A generated title",
|
||||
"seo_description": "A generated description that is reasonably long.",
|
||||
"seo_keywords": "one, two, three, four, five",
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(seo_meta_service, "gateway_complete", fake_gateway_complete)
|
||||
svc = seo_meta_service.SeoMetaService()
|
||||
defaults = plain_seo_defaults("Title", "Body text")
|
||||
totals = seo_meta_service.new_usage_totals()
|
||||
svc._generate(
|
||||
"post",
|
||||
"nonexistent-uid",
|
||||
{"user_uid": ""},
|
||||
"Title",
|
||||
"Body text",
|
||||
defaults,
|
||||
totals,
|
||||
"molodetz",
|
||||
)
|
||||
assert seen["bypass_preamble"] is True
|
||||
@@ -0,0 +1,105 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import uuid_utils
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.messaging import active_conversation
|
||||
|
||||
|
||||
def _make_user():
|
||||
uid = str(uuid_utils.uuid7())
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"actconv_{uid[:8]}",
|
||||
"email": f"{uid[:8]}@actconv.test",
|
||||
"terms_version": "1",
|
||||
}
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def test_touch_writes_marker_then_throttles(local_db):
|
||||
viewer = _make_user()
|
||||
other = _make_user()
|
||||
active_conversation._last_write.pop(f"{viewer}:{other}", None)
|
||||
|
||||
active_conversation.touch_active_conversation(viewer, other)
|
||||
row = get_table("users").find_one(uid=viewer)
|
||||
assert row["active_conversation_uid"] == other
|
||||
first_stamp = row["active_conversation_at"]
|
||||
assert first_stamp
|
||||
|
||||
active_conversation.touch_active_conversation(viewer, other)
|
||||
assert get_table("users").find_one(uid=viewer)["active_conversation_at"] == first_stamp
|
||||
|
||||
active_conversation._last_write[f"{viewer}:{other}"] = (
|
||||
active_conversation.time.monotonic()
|
||||
- active_conversation.WRITE_THROTTLE_SECONDS
|
||||
- 1
|
||||
)
|
||||
active_conversation.touch_active_conversation(viewer, other)
|
||||
second_stamp = get_table("users").find_one(uid=viewer)["active_conversation_at"]
|
||||
assert second_stamp >= first_stamp
|
||||
|
||||
|
||||
def test_touch_is_a_noop_without_both_uids(local_db):
|
||||
viewer = _make_user()
|
||||
active_conversation.touch_active_conversation("", "someone")
|
||||
active_conversation.touch_active_conversation(viewer, "")
|
||||
row = get_table("users").find_one(uid=viewer)
|
||||
assert not row.get("active_conversation_uid")
|
||||
|
||||
|
||||
def test_is_actively_viewing_true_when_fresh(local_db):
|
||||
viewer = _make_user()
|
||||
other = _make_user()
|
||||
get_table("users").update(
|
||||
{
|
||||
"uid": viewer,
|
||||
"active_conversation_uid": other,
|
||||
"active_conversation_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
assert active_conversation.is_actively_viewing(viewer, other) is True
|
||||
|
||||
|
||||
def test_is_actively_viewing_false_when_stale(local_db):
|
||||
viewer = _make_user()
|
||||
other = _make_user()
|
||||
stale = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=active_conversation.FRESH_SECONDS + 5
|
||||
)
|
||||
get_table("users").update(
|
||||
{
|
||||
"uid": viewer,
|
||||
"active_conversation_uid": other,
|
||||
"active_conversation_at": stale.isoformat(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
assert active_conversation.is_actively_viewing(viewer, other) is False
|
||||
|
||||
|
||||
def test_is_actively_viewing_false_for_a_different_conversation(local_db):
|
||||
viewer = _make_user()
|
||||
other = _make_user()
|
||||
someone_else = _make_user()
|
||||
get_table("users").update(
|
||||
{
|
||||
"uid": viewer,
|
||||
"active_conversation_uid": someone_else,
|
||||
"active_conversation_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
assert active_conversation.is_actively_viewing(viewer, other) is False
|
||||
|
||||
|
||||
def test_is_actively_viewing_false_when_missing_or_unknown(local_db):
|
||||
assert active_conversation.is_actively_viewing("", "other") is False
|
||||
assert active_conversation.is_actively_viewing("viewer", "") is False
|
||||
assert active_conversation.is_actively_viewing(str(uuid_utils.uuid7()), "other") is False
|
||||
@@ -4,7 +4,24 @@ from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.services.messaging.persist import stamp_content_revision
|
||||
from devplacepy.services.messaging.persist import persist_message, stamp_content_revision
|
||||
|
||||
|
||||
def _make_user(prefix):
|
||||
uid = generate_uid()
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"{prefix}_{uid[:8]}",
|
||||
"email": f"{uid[:8]}@{prefix}.test",
|
||||
"terms_version": "1",
|
||||
}
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def _sender(uid):
|
||||
return {"uid": uid, "username": "sender", "role": "Member"}
|
||||
|
||||
|
||||
def test_stamp_content_revision_sets_updated_at(local_db):
|
||||
@@ -26,3 +43,84 @@ def test_stamp_content_revision_sets_updated_at(local_db):
|
||||
stored = get_table("messages").find_one(uid=uid)
|
||||
assert stored["updated_at"] == row["updated_at"]
|
||||
assert stored["content"] == "hello"
|
||||
|
||||
|
||||
def test_persist_message_dedupes_same_sender_and_client_id(local_db):
|
||||
sender_uid = _make_user("dedupe_sender")
|
||||
receiver_uid = _make_user("dedupe_receiver")
|
||||
sender = _sender(sender_uid)
|
||||
client_id = f"cid-{generate_uid()}"
|
||||
|
||||
first = persist_message(sender, receiver_uid, "hello once", client_id=client_id)
|
||||
assert first is not None
|
||||
|
||||
second = persist_message(sender, receiver_uid, "hello once", client_id=client_id)
|
||||
assert second is not None
|
||||
assert second["uid"] == first["uid"]
|
||||
|
||||
rows = list(
|
||||
get_table("messages").find(sender_uid=sender_uid, receiver_uid=receiver_uid)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_persist_message_does_not_dedupe_without_client_id(local_db):
|
||||
sender_uid = _make_user("nodedupe_sender")
|
||||
receiver_uid = _make_user("nodedupe_receiver")
|
||||
sender = _sender(sender_uid)
|
||||
|
||||
first = persist_message(sender, receiver_uid, "no client id here")
|
||||
second = persist_message(sender, receiver_uid, "no client id here")
|
||||
assert first["uid"] != second["uid"]
|
||||
|
||||
rows = list(
|
||||
get_table("messages").find(sender_uid=sender_uid, receiver_uid=receiver_uid)
|
||||
)
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
def test_persist_message_does_not_dedupe_a_different_client_id(local_db):
|
||||
sender_uid = _make_user("diffid_sender")
|
||||
receiver_uid = _make_user("diffid_receiver")
|
||||
sender = _sender(sender_uid)
|
||||
|
||||
first = persist_message(sender, receiver_uid, "a", client_id="cid-a")
|
||||
second = persist_message(sender, receiver_uid, "b", client_id="cid-b")
|
||||
assert first["uid"] != second["uid"]
|
||||
|
||||
|
||||
def test_persist_message_suppresses_notification_when_receiver_is_actively_viewing(local_db):
|
||||
sender_uid = _make_user("active_sender")
|
||||
receiver_uid = _make_user("active_receiver")
|
||||
sender = _sender(sender_uid)
|
||||
|
||||
get_table("users").update(
|
||||
{
|
||||
"uid": receiver_uid,
|
||||
"active_conversation_uid": sender_uid,
|
||||
"active_conversation_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
|
||||
result = persist_message(sender, receiver_uid, "are you watching?", client_id="cid-watch")
|
||||
assert result is not None
|
||||
|
||||
notifications = list(
|
||||
get_table("notifications").find(user_uid=receiver_uid, type="message")
|
||||
)
|
||||
assert notifications == []
|
||||
|
||||
|
||||
def test_persist_message_notifies_when_receiver_is_not_actively_viewing(local_db):
|
||||
sender_uid = _make_user("inactive_sender")
|
||||
receiver_uid = _make_user("inactive_receiver")
|
||||
sender = _sender(sender_uid)
|
||||
|
||||
result = persist_message(sender, receiver_uid, "hello there", client_id="cid-plain")
|
||||
assert result is not None
|
||||
|
||||
notifications = list(
|
||||
get_table("notifications").find(user_uid=receiver_uid, type="message")
|
||||
)
|
||||
assert len(notifications) == 1
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.openai_gateway import auth_throttle
|
||||
|
||||
|
||||
def test_is_throttled_false_below_threshold():
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
for _ in range(9):
|
||||
auth_throttle.record_failure("1.1.1.1", 60)
|
||||
assert auth_throttle.is_throttled("1.1.1.1", 10, 60) is False
|
||||
finally:
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_is_throttled_true_at_threshold():
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
for _ in range(10):
|
||||
auth_throttle.record_failure("2.2.2.2", 60)
|
||||
assert auth_throttle.is_throttled("2.2.2.2", 10, 60) is True
|
||||
finally:
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_is_throttled_scoped_per_ip():
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
for _ in range(10):
|
||||
auth_throttle.record_failure("3.3.3.3", 60)
|
||||
assert auth_throttle.is_throttled("3.3.3.3", 10, 60) is True
|
||||
assert auth_throttle.is_throttled("4.4.4.4", 10, 60) is False
|
||||
finally:
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_record_failure_returns_running_count():
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
assert auth_throttle.record_failure("5.5.5.5", 60) == 1
|
||||
assert auth_throttle.record_failure("5.5.5.5", 60) == 2
|
||||
assert auth_throttle.record_failure("5.5.5.5", 60) == 3
|
||||
finally:
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_throttle_resets_after_window_passes(monkeypatch):
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(auth_throttle.time, "time", lambda: now[0])
|
||||
for _ in range(10):
|
||||
auth_throttle.record_failure("6.6.6.6", 60)
|
||||
assert auth_throttle.is_throttled("6.6.6.6", 10, 60) is True
|
||||
now[0] += 61
|
||||
assert auth_throttle.is_throttled("6.6.6.6", 10, 60) is False
|
||||
finally:
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_reset_clears_one_ip():
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
for _ in range(10):
|
||||
auth_throttle.record_failure("7.7.7.7", 60)
|
||||
auth_throttle.reset("7.7.7.7")
|
||||
assert auth_throttle.is_throttled("7.7.7.7", 10, 60) is False
|
||||
finally:
|
||||
auth_throttle.clear()
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
from devplacepy.services.openai_gateway import auth_throttle
|
||||
class FakeResp_openai_gateway:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -257,6 +258,7 @@ def test_authorize_static_access_key(local_db):
|
||||
finally:
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_authorize_admin_and_user_toggles(local_db):
|
||||
@@ -282,6 +284,7 @@ def test_authorize_admin_and_user_toggles(local_db):
|
||||
set_setting("gateway_allow_users", "1")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "")
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_authorize_require_auth_off_is_open(local_db):
|
||||
@@ -293,6 +296,124 @@ def test_authorize_require_auth_off_is_open(local_db):
|
||||
set_setting("gateway_require_auth", "1")
|
||||
|
||||
|
||||
def test_authorize_throttles_repeated_failed_auth_from_one_ip(local_db):
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_auth_throttle_max_failures", "3")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
svc = GatewayService()
|
||||
ip = "203.0.113.10"
|
||||
for _ in range(3):
|
||||
assert (
|
||||
svc.authorize(_make_request_openai_gateway(headers={"X-Real-IP": ip}))
|
||||
is False
|
||||
)
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
svc.authorize(_make_request_openai_gateway(headers={"X-Real-IP": ip}))
|
||||
assert excinfo.value.status_code == 429
|
||||
assert "Retry-After" in excinfo.value.headers
|
||||
finally:
|
||||
set_setting("gateway_auth_throttle_max_failures", "10")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_authorize_throttle_does_not_affect_other_ips(local_db):
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_auth_throttle_max_failures", "3")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
svc = GatewayService()
|
||||
throttled_ip = "203.0.113.20"
|
||||
other_ip = "203.0.113.21"
|
||||
for _ in range(3):
|
||||
svc.authorize(_make_request_openai_gateway(headers={"X-Real-IP": throttled_ip}))
|
||||
assert auth_throttle.is_throttled(throttled_ip, 3, 60) is True
|
||||
assert (
|
||||
svc.authorize(_make_request_openai_gateway(headers={"X-Real-IP": other_ip}))
|
||||
is False
|
||||
)
|
||||
assert auth_throttle.is_throttled(other_ip, 3, 60) is False
|
||||
finally:
|
||||
set_setting("gateway_auth_throttle_max_failures", "10")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_authorize_valid_credentials_succeed_from_throttled_ip(local_db):
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_allow_users", "1")
|
||||
set_setting("gateway_auth_throttle_max_failures", "3")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
svc = GatewayService()
|
||||
ip = "203.0.113.30"
|
||||
_, member_key = _make_admin_openai_gateway(role="Member")
|
||||
for _ in range(3):
|
||||
svc.authorize(_make_request_openai_gateway(headers={"X-Real-IP": ip}))
|
||||
assert auth_throttle.is_throttled(ip, 3, 60) is True
|
||||
assert (
|
||||
svc.authorize(
|
||||
_make_request_openai_gateway(
|
||||
headers={"X-Real-IP": ip, "X-API-KEY": member_key}
|
||||
)
|
||||
)
|
||||
is True
|
||||
)
|
||||
set_setting("gateway_access_key", "topsecret")
|
||||
assert (
|
||||
svc.authorize(
|
||||
_make_request_openai_gateway(
|
||||
headers={"X-Real-IP": ip, "X-API-KEY": "topsecret"}
|
||||
)
|
||||
)
|
||||
is True
|
||||
)
|
||||
finally:
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_auth_throttle_max_failures", "10")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
def test_authorize_throttle_resets_after_window(local_db, monkeypatch):
|
||||
set_setting("gateway_require_auth", "1")
|
||||
set_setting("gateway_access_key", "")
|
||||
set_setting("gateway_auth_throttle_max_failures", "3")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
auth_throttle.clear()
|
||||
try:
|
||||
now = [2000.0]
|
||||
monkeypatch.setattr(auth_throttle.time, "time", lambda: now[0])
|
||||
svc = GatewayService()
|
||||
ip = "203.0.113.40"
|
||||
for _ in range(3):
|
||||
svc.authorize(_make_request_openai_gateway(headers={"X-Real-IP": ip}))
|
||||
assert auth_throttle.is_throttled(ip, 3, 60) is True
|
||||
now[0] += 61
|
||||
assert (
|
||||
svc.authorize(_make_request_openai_gateway(headers={"X-Real-IP": ip}))
|
||||
is False
|
||||
)
|
||||
finally:
|
||||
set_setting("gateway_auth_throttle_max_failures", "10")
|
||||
set_setting("gateway_auth_throttle_window_seconds", "60")
|
||||
set_setting("gateway_require_auth", "1")
|
||||
auth_throttle.clear()
|
||||
|
||||
|
||||
class FakeEmbedClient_openai_gateway:
|
||||
def __init__(self, *a, **k):
|
||||
self.calls = []
|
||||
@@ -381,6 +502,36 @@ def test_embeddings_disabled_returns_503(local_db, monkeypatch):
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_embeddings_missing_key_returns_clean_error_not_unauthenticated_call(
|
||||
local_db, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||||
set_setting("gateway_api_key", "")
|
||||
set_setting("gateway_vision_key", "")
|
||||
set_setting("gateway_embed_key", "")
|
||||
try:
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
assert cfg["gateway_embed_key"] == ""
|
||||
cfg["gateway_embed_enabled"] = True
|
||||
rt = svc.runtime()
|
||||
resp = run_async(
|
||||
rt.handle_embeddings(
|
||||
{"input": "hello"}, cfg, ("guest", "no_key_probe"), "test", "default"
|
||||
)
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
payload = json.loads(bytes(resp.body).decode())
|
||||
assert payload["error"]["type"] == "embeddings_not_configured"
|
||||
assert rt._client is None
|
||||
finally:
|
||||
set_setting("gateway_api_key", "")
|
||||
set_setting("gateway_vision_key", "")
|
||||
set_setting("gateway_embed_key", "")
|
||||
|
||||
|
||||
class FakeImageClient_openai_gateway:
|
||||
def __init__(self, *a, **k):
|
||||
self.calls = []
|
||||
@@ -1942,3 +2093,121 @@ def test_hostile_upstream_model_header_is_ignored_end_to_end(local_db, monkeypat
|
||||
)
|
||||
)
|
||||
assert resp.headers["X-Gateway-Model"] == "deepseek-chat"
|
||||
|
||||
|
||||
def test_bypass_preamble_skips_the_operator_preamble_when_allowed(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_system_preamble"] = "OPERATOR-PREAMBLE-TEXT"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{
|
||||
"messages": [
|
||||
{"role": "system", "content": "Reply with strict JSON only."},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
"bypass_preamble": True,
|
||||
},
|
||||
cfg,
|
||||
("internal", "devii"),
|
||||
"test",
|
||||
"default",
|
||||
bypass_allowed=True,
|
||||
)
|
||||
)
|
||||
sent = rt._client.calls[-1][1]
|
||||
system_message = sent["messages"][0]
|
||||
assert system_message["role"] == "system"
|
||||
assert "OPERATOR-PREAMBLE-TEXT" not in system_message["content"]
|
||||
assert "Current date" in system_message["content"]
|
||||
assert "Reply with strict JSON only." in system_message["content"]
|
||||
assert "bypass_preamble" not in sent
|
||||
|
||||
|
||||
def test_bypass_preamble_ignored_without_internal_credentials(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_system_preamble"] = "OPERATOR-PREAMBLE-TEXT"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{"messages": [{"role": "user", "content": "hi"}], "bypass_preamble": True},
|
||||
cfg,
|
||||
("user", "someuser"),
|
||||
"test",
|
||||
"default",
|
||||
bypass_allowed=False,
|
||||
)
|
||||
)
|
||||
sent = rt._client.calls[-1][1]
|
||||
system_message = sent["messages"][0]
|
||||
assert "OPERATOR-PREAMBLE-TEXT" in system_message["content"]
|
||||
assert "bypass_preamble" not in sent
|
||||
|
||||
|
||||
def test_bypass_preamble_keeps_the_clients_own_system_content(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_system_preamble"] = "OPERATOR-PREAMBLE-TEXT"
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{
|
||||
"messages": [
|
||||
{"role": "system", "content": "Grade strictly. Reply JSON only."},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
"bypass_preamble": True,
|
||||
},
|
||||
cfg,
|
||||
("internal", "devii"),
|
||||
"test",
|
||||
"default",
|
||||
bypass_allowed=True,
|
||||
)
|
||||
)
|
||||
sent = rt._client.calls[-1][1]
|
||||
system_message = sent["messages"][0]
|
||||
assert "OPERATOR-PREAMBLE-TEXT" not in system_message["content"]
|
||||
assert "Grade strictly. Reply JSON only." in system_message["content"]
|
||||
|
||||
|
||||
def test_internal_bypass_allowed_requires_the_exact_internal_key(local_db):
|
||||
set_setting("gateway_internal_key", "test-internal-secret")
|
||||
try:
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
assert (
|
||||
svc.internal_bypass_allowed(
|
||||
_make_request_openai_gateway(
|
||||
headers={"X-Gateway-Internal-Key": "test-internal-secret"}
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
svc.internal_bypass_allowed(
|
||||
_make_request_openai_gateway(
|
||||
headers={"X-Gateway-Internal-Key": "wrong-secret"}
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
svc.internal_bypass_allowed(
|
||||
_make_request_openai_gateway(
|
||||
headers={"Authorization": "Bearer test-internal-secret"}
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert svc.internal_bypass_allowed(_make_request_openai_gateway(), cfg) is False
|
||||
finally:
|
||||
set_setting("gateway_internal_key", "")
|
||||
|
||||
@@ -267,6 +267,32 @@ def test_embed_route_and_kind_isolation(local_db):
|
||||
_cleanup(["utemb"], ["ut-embed"])
|
||||
|
||||
|
||||
def test_embed_overlay_blank_provider_key_preserves_top_level_key(local_db):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(
|
||||
name="utemblank",
|
||||
base_url="https://emb.example/v1/chat/completions",
|
||||
api_key="",
|
||||
)
|
||||
)
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
source_model="ut-embed-blank",
|
||||
provider="utemblank",
|
||||
target_model="vendor/embed",
|
||||
kind="embed",
|
||||
)
|
||||
)
|
||||
try:
|
||||
base_cfg = {"gateway_embed_key": "top-level-fallback-key"}
|
||||
overlay = r.embed_overlay("ut-embed-blank", base_cfg)
|
||||
assert "gateway_embed_key" not in overlay
|
||||
merged = {**base_cfg, **overlay}
|
||||
assert merged["gateway_embed_key"] == "top-level-fallback-key"
|
||||
finally:
|
||||
_cleanup(["utemblank"], ["ut-embed-blank"])
|
||||
|
||||
|
||||
def test_inactive_route_ignored(local_db):
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
|
||||
@@ -134,8 +134,9 @@ def test_grade_free_text_uses_a_valid_verdict(monkeypatch):
|
||||
def test_grade_free_text_passes_the_answering_key_through(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def capture(api_key, system, text, timeout, model=None):
|
||||
def capture(api_key, system, text, timeout, model=None, bypass_preamble=False):
|
||||
seen["api_key"] = api_key
|
||||
seen["bypass_preamble"] = bypass_preamble
|
||||
return json.dumps({"score": 1.0}), None
|
||||
|
||||
monkeypatch.setattr(grading, "gateway_complete", capture)
|
||||
@@ -143,6 +144,18 @@ def test_grade_free_text_passes_the_answering_key_through(monkeypatch):
|
||||
assert seen["api_key"] == "member-key"
|
||||
|
||||
|
||||
def test_grade_free_text_bypasses_the_operator_preamble(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def capture(api_key, system, text, timeout, model=None, bypass_preamble=False):
|
||||
seen["bypass_preamble"] = bypass_preamble
|
||||
return json.dumps({"score": 1.0}), None
|
||||
|
||||
monkeypatch.setattr(grading, "gateway_complete", capture)
|
||||
grading.grade_free_text("member-key", QUESTION, "answer")
|
||||
assert seen["bypass_preamble"] is True
|
||||
|
||||
|
||||
def test_the_fallback_score_is_the_deterministic_overlap():
|
||||
expected = scoring.token_overlap_score(QUESTION["expected_answer"], "one bucket index")
|
||||
result = grading.grade_free_text("", QUESTION, "one bucket index")
|
||||
|
||||
Reference in New Issue
Block a user