M2: cut the hosted product away from the local one
94 files, +1,395 -6,578. Three files are new; twenty-four are gone. The milestone is subtraction, and what is left is the single-user local storyteller the specification describes. Removed in full: campaign scripting and its QuickJS sandbox; multi-user accounts, guest sessions, login, registration and the shared demo key; the visitor-analytics tables, dashboard and page beacon; the access log of sign-ins, addresses and devices; per-IP and per-user rate limiting and quotas; Render deployment config; Postgres and psycopg; cloud inference providers, the API-key field and the key encryption that existed to store it; session-cookie signing. None of it was hidden behind a flag — the routes are gone and answer 404. Two things were kept that the brief allowed keeping. The `users` table and its foreign keys stay as an internal ownership detail, because rewriting them out means a migration across most of the schema to delete a column that costs nothing; nothing creates a second user and no request carries an identity. Five inert tables and four inert columns stay for the same reason, so an M1 campaign database opens unchanged. The one addition is app/endpoints.py, which decides where a story may be sent. Loopback, RFC1918, link-local, unique-local and CGNAT — an explicit allowlist of networks, not a guess at what `ipaddress` means by "private", which calls the documentation ranges private and IPv6 loopback reserved. Every address a hostname resolves to must be in it, so a split answer does not squeak through, and the rule runs both when the endpoint is saved and before every outbound request, because a name that resolved to the LAN this morning can resolve elsewhere this afternoon. Known cloud hosts are named in the refusal so the error says why rather than looking like broken DNS. TLS is never traded against it: M1's shared trust context is intact on all four clients and there is no way to skip verification. The hardcoded 120-second model timeout is now a setting. That was not theoretical — on this GPU-less four-core host a cold load of qwen2.5:3b-instruct took 648.9 seconds to produce the first turn, while turns 2 to 5 of the same campaign took 3.6 to 13.1. Connect stays short at 10s so a wrong address still fails fast; the read timeout defaults to 300s and is bounded at 3600, because "wait longer" must stay a number. Two defects found while testing and fixed here. An unknown /api path fell through the SPA catch-all and came back as HTML with status 200, so a client asking for JSON parsed a web page instead of learning the route was gone. And AIDND_CORS_ORIGINS accepted "*", which on an unauthenticated loopback API would hand every page on the Internet a write handle on the campaign database; it now refuses to start. Verified rather than assumed. Offline, on a network with no route out and no DNS: five turns, retry with both takes retained, restart with an identical transcript digest, a failed model call leaving the accepted AI-turn count untouched, and a capture with zero non-loopback unicast packets. Against a real second machine on the LAN over HTTPS with a private CA: four turns, restart, and a capture showing 289 packets to the approved host, 344 loopback, zero anywhere else, zero DNS queries. Cloud and public endpoints refused with their reasons; no API key settable; every removed route 404. 604 backend tests pass, down from 648 by the fifteen retired with the subsystems they tested and up by the twenty-nine added for the endpoint policy and the removed surface. The scripting tests were not deleted: eight files used a JavaScript counter as instrumentation for the state snapshot and rollback machinery, which M2 does not touch, so the counter moved to the world-state engine and those tests still assert what they always did. Frontend lint and build are clean; the image builds, and its wheel-building stage is gone with quickjs. No M3 work. Undo is still destructive and there is still no Redo.
This commit is contained in:
@@ -39,3 +39,44 @@ class ScriptedProvider:
|
||||
if isinstance(reply, Exception):
|
||||
raise reply
|
||||
yield ("text", reply)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deterministic per-turn state instrumentation
|
||||
# ---------------------------------------------------------------------------
|
||||
# Several tests need a value that changes by a fixed amount on every turn, so
|
||||
# that a rollback failure is arithmetic rather than a judgement call: if a take
|
||||
# stacks instead of replacing, the total is off by exactly one turn's worth.
|
||||
#
|
||||
# That instrumentation used to be a JavaScript `output` hook doing
|
||||
# `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and
|
||||
# the tests below are not about scripting — they are about the state snapshot,
|
||||
# rollback, and branch-isolation machinery in `attempts.py` and `tree.py`,
|
||||
# which is unchanged.
|
||||
#
|
||||
# The counter therefore moved to the world-state engine, which is a real
|
||||
# remaining product path: the model emits a ```state delta block, the referee
|
||||
# applies it, and the result lands in `adventure.world_state`. The fake
|
||||
# provider decides what the model "emits", so it is exactly as deterministic as
|
||||
# the script was, and it exercises production code rather than a test hook.
|
||||
|
||||
#: A schema with a plain unbounded counter. No `max_delta_per_turn` and no
|
||||
#: `cooldown`, so every +10 is applied in full, every turn.
|
||||
GOLD_SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
||||
}
|
||||
}
|
||||
|
||||
GOLD_PER_TURN = 10
|
||||
|
||||
|
||||
def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str:
|
||||
"""A model reply that narrates `text` and banks `amount` gold."""
|
||||
return f'{text}\n```state\n{{"player.gold": {amount}}}\n```'
|
||||
|
||||
|
||||
def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]:
|
||||
"""`count` numbered replies, each banking one turn's worth of gold."""
|
||||
return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""The access log: app/accesslog.py and GET /api/analytics/access.
|
||||
|
||||
This is the half of the analytics work that identifies people on purpose,
|
||||
so these tests pin the details that would quietly make it wrong. The
|
||||
address recorded must be the hardened one, not a header a client chose.
|
||||
Session rows must be thinned instead of written on every page load. And a
|
||||
row must outlive the account it describes, because guest cleanup deletes
|
||||
accounts on a schedule, and a log that deletes itself is not a log.
|
||||
|
||||
python -m pytest tests/test_accesslog.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import accesslog, auth, limits, models, security
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
|
||||
EDGE = "198.51.100.77" # what the trusted proxy appended
|
||||
SPOOF = "10.0.0.1" # what a client put in front of it
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state():
|
||||
accesslog._last_session.clear()
|
||||
yield
|
||||
accesslog._last_session.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
owner = models.User(is_guest=False, email="owner@example.com")
|
||||
member = models.User(
|
||||
is_guest=False, email="player@example.com",
|
||||
password_hash=security.hash_password("hunter2long"),
|
||||
)
|
||||
setup.add_all([owner, member])
|
||||
setup.commit()
|
||||
ids = {"owner": owner.id, "member": member.id}
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_login_allowed", lambda *a, **k: None)
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
|
||||
|
||||
# /auth/me resolves its own session, so the cookie flow below is the real
|
||||
# one. Every other endpoint goes through get_current_user, and `act_as`
|
||||
# decides who that is.
|
||||
acting = {"id": ids["owner"]}
|
||||
|
||||
def _current(db=Depends(get_db)):
|
||||
return db.get(models.User, acting["id"])
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current
|
||||
try:
|
||||
test_client = TestClient(app)
|
||||
test_client.ids = ids
|
||||
test_client.act_as = lambda user_id: acting.update(id=user_id)
|
||||
yield test_client
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def visit(client, ip=EDGE, ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64)"):
|
||||
return client.get(
|
||||
"/api/auth/me",
|
||||
headers={"x-forwarded-for": f"{SPOOF}, {ip}", "user-agent": ua},
|
||||
)
|
||||
|
||||
|
||||
def rows(kind=None):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(models.AccessEvent).order_by(models.AccessEvent.id)
|
||||
if kind:
|
||||
query = query.filter_by(kind=kind)
|
||||
return query.all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def read_log(client, **params):
|
||||
return client.get("/api/analytics/access", params=params)
|
||||
|
||||
|
||||
# ---------- Writing ----------
|
||||
|
||||
def test_a_new_session_is_logged(client):
|
||||
visit(client)
|
||||
logged = rows()
|
||||
assert len(logged) == 1
|
||||
entry = logged[0]
|
||||
assert entry.kind == accesslog.SESSION
|
||||
assert entry.is_guest and entry.who.startswith("Guest #")
|
||||
assert entry.device == "desktop"
|
||||
|
||||
|
||||
def test_the_address_is_the_hardened_one_not_the_clients(client):
|
||||
visit(client)
|
||||
# The client prepended its own value. Only the hop the edge appended counts.
|
||||
# Recording the leftmost value would make every row forgeable, which is
|
||||
# worse for a log than having no log at all.
|
||||
assert rows()[0].ip == EDGE
|
||||
|
||||
|
||||
def test_session_rows_are_thinned_to_one_per_day_per_address(client):
|
||||
for _ in range(4):
|
||||
visit(client)
|
||||
assert len(rows(accesslog.SESSION)) == 1
|
||||
|
||||
|
||||
def test_a_changed_address_writes_a_new_row(client):
|
||||
visit(client)
|
||||
visit(client, ip="203.0.113.9")
|
||||
logged = rows(accesslog.SESSION)
|
||||
assert [entry.ip for entry in logged] == [EDGE, "203.0.113.9"]
|
||||
# Same session throughout, so both rows name the same visitor.
|
||||
assert logged[0].who == logged[1].who
|
||||
|
||||
|
||||
def test_sign_in_and_failure_are_both_logged(client):
|
||||
client.post("/api/auth/login", json={"email": "player@example.com", "password": "wrong"},
|
||||
headers={"x-forwarded-for": EDGE})
|
||||
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
|
||||
headers={"x-forwarded-for": EDGE})
|
||||
kinds = [entry.kind for entry in rows()]
|
||||
assert accesslog.LOGIN_FAILED in kinds and accesslog.LOGIN in kinds
|
||||
|
||||
failure = rows(accesslog.LOGIN_FAILED)[0]
|
||||
# This records the address that was tried, not the account it belongs to.
|
||||
# A failed attempt against an address with no matching account is
|
||||
# exactly what this row exists to capture.
|
||||
assert failure.who == "player@example.com"
|
||||
assert failure.user_id is None
|
||||
assert rows(accesslog.LOGIN)[0].user_id == client.ids["member"]
|
||||
|
||||
|
||||
def test_registering_is_logged_against_the_upgraded_account(client):
|
||||
visit(client) # creates the guest whose session then registers
|
||||
client.act_as(rows()[0].user_id)
|
||||
client.post("/api/auth/register", json={"email": "new@example.com", "password": "hunter2long"})
|
||||
entry = rows(accesslog.REGISTER)[0]
|
||||
assert entry.who == "new@example.com" and not entry.is_guest
|
||||
|
||||
|
||||
def test_a_row_outlives_the_account_it_describes(client):
|
||||
visit(client)
|
||||
entry = rows()[0]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.delete(db.get(models.User, entry.user_id))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
# There is no foreign key, and `who` is a snapshot. Guest cleanup deletes
|
||||
# accounts on a schedule, and a log that vanishes along with them is not
|
||||
# a log.
|
||||
survivor = rows()[0]
|
||||
assert survivor.who == entry.who and survivor.ip == EDGE
|
||||
|
||||
|
||||
def test_a_long_user_agent_is_truncated(client):
|
||||
visit(client, ua="Mozilla/" + "x" * 500)
|
||||
assert len(rows()[0].user_agent) == accesslog.MAX_UA
|
||||
|
||||
|
||||
def test_a_logging_failure_does_not_break_the_request(client, monkeypatch):
|
||||
monkeypatch.setattr(accesslog, "_client_ip", lambda request: 1 / 0)
|
||||
# The log observes sign-in. A logging failure must not block the request.
|
||||
assert visit(client).status_code == 200
|
||||
|
||||
|
||||
# ---------- Reading ----------
|
||||
|
||||
def test_the_log_is_invisible_to_everyone_but_the_owner(client):
|
||||
visit(client)
|
||||
assert read_log(client).status_code == 200
|
||||
client.act_as(client.ids["member"])
|
||||
assert read_log(client).status_code == 404
|
||||
|
||||
|
||||
def test_the_log_reads_newest_first_and_pages_backwards(client):
|
||||
for index in range(5):
|
||||
visit(client, ip=f"203.0.113.{index}")
|
||||
first = read_log(client, limit=2).json()
|
||||
assert [event["ip"] for event in first["events"]] == ["203.0.113.4", "203.0.113.3"]
|
||||
assert first["has_more"]
|
||||
|
||||
older = read_log(client, limit=2, before_id=first["events"][-1]["id"]).json()
|
||||
assert [event["ip"] for event in older["events"]] == ["203.0.113.2", "203.0.113.1"]
|
||||
|
||||
|
||||
def test_the_log_filters_by_kind_and_searches(client):
|
||||
visit(client)
|
||||
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
|
||||
headers={"x-forwarded-for": "203.0.113.44"})
|
||||
|
||||
assert len(read_log(client, kind="login").json()["events"]) == 1
|
||||
by_email = read_log(client, q="player@example.com").json()["events"]
|
||||
assert len(by_email) == 1 and by_email[0]["kind"] == "login"
|
||||
by_ip = read_log(client, q="203.0.113.44").json()["events"]
|
||||
assert len(by_ip) == 1
|
||||
assert read_log(client, q="nobody@example.com").json()["events"] == []
|
||||
@@ -48,7 +48,6 @@ def client(monkeypatch):
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
"""Visit analytics: app/analytics.py and the two endpoints in front of it.
|
||||
|
||||
This file tests three things, and the rest is arithmetic. The counters must
|
||||
survive the buffer/UPSERT round trip: a flush adds to what is already
|
||||
stored instead of replacing it, or every number would show only the last
|
||||
minute. The funnel counts people rather than clicks, which is the only
|
||||
reason the visitor-day table exists. The gate holds: a stranger cannot read
|
||||
the dashboard, and cannot inflate what it reports beyond hitting the page.
|
||||
|
||||
python -m pytest tests/test_analytics.py -v
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import analytics, auth, limits, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_buffer():
|
||||
"""The buffer is process-wide, so a test that leaves counts in it would
|
||||
show up inside the next one's flush."""
|
||||
analytics._counts.clear()
|
||||
analytics._visits.clear()
|
||||
analytics._labels_seen.clear()
|
||||
yield
|
||||
analytics._counts.clear()
|
||||
analytics._visits.clear()
|
||||
analytics._labels_seen.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def counter(db, metric, label):
|
||||
row = (
|
||||
db.query(models.AnalyticsDaily)
|
||||
.filter_by(metric=metric, label=label)
|
||||
.one_or_none()
|
||||
)
|
||||
return row.hits if row else 0
|
||||
|
||||
|
||||
def make_user(db, email=None):
|
||||
user = models.User(is_guest=email is None, email=email)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
# ---------- The buffer and its flush ----------
|
||||
|
||||
def test_counts_accumulate_across_flushes(db):
|
||||
analytics.record(analytics.M_PAGE, "/")
|
||||
analytics.record(analytics.M_PAGE, "/")
|
||||
analytics.flush(db)
|
||||
analytics.record(analytics.M_PAGE, "/")
|
||||
analytics.flush(db)
|
||||
# The second flush has to find the existing row and add to it. Replacing it
|
||||
# would leave every counter showing only the newest minute of traffic.
|
||||
assert counter(db, analytics.M_PAGE, "/") == 3
|
||||
|
||||
|
||||
def test_flush_is_a_no_op_when_nothing_happened(db):
|
||||
analytics.flush(db)
|
||||
assert db.query(models.AnalyticsDaily).count() == 0
|
||||
|
||||
|
||||
def test_a_failed_flush_keeps_the_counts(db, monkeypatch):
|
||||
analytics.record(analytics.M_PAGE, "/")
|
||||
monkeypatch.setattr(analytics, "_write_counts", lambda *a: 1 / 0)
|
||||
analytics.flush(db) # must not raise
|
||||
monkeypatch.undo()
|
||||
analytics.flush(db)
|
||||
assert counter(db, analytics.M_PAGE, "/") == 1
|
||||
|
||||
|
||||
def test_label_cardinality_is_capped(db):
|
||||
for i in range(analytics.MAX_LABELS_PER_METRIC + 25):
|
||||
analytics.record(analytics.M_REFERRER, f"host{i}.example")
|
||||
analytics.flush(db)
|
||||
labels = db.query(models.AnalyticsDaily).filter_by(metric=analytics.M_REFERRER).count()
|
||||
# Everything past the cap is folded into one bucket, so a referrer flood
|
||||
# cannot create unlimited rows.
|
||||
assert labels == analytics.MAX_LABELS_PER_METRIC + 1
|
||||
assert counter(db, analytics.M_REFERRER, analytics.OTHER) == 25
|
||||
|
||||
|
||||
# ---------- Visitors ----------
|
||||
|
||||
def test_visitor_id_is_stable_and_keyed(db, monkeypatch):
|
||||
user = make_user(db)
|
||||
handle = analytics.visitor_id(user)
|
||||
assert handle == analytics.visitor_id(user) # a returning visitor
|
||||
assert handle != analytics.visitor_id(make_user(db)) # is still one visitor
|
||||
assert len(handle) == 32 and int(handle, 16) >= 0 # opaque hex, not an id
|
||||
# Keyed on the app secret, not a bare hash of the user id. Otherwise
|
||||
# anyone holding this table could rebuild the mapping by hashing
|
||||
# sequential ids.
|
||||
monkeypatch.setattr(analytics.security, "SECRET_KEY", b"a-different-secret")
|
||||
assert analytics.visitor_id(user) != handle
|
||||
|
||||
|
||||
def test_a_repeat_visitor_is_new_only_once(db):
|
||||
user = make_user(db)
|
||||
analytics.record_visit(user)
|
||||
analytics.flush(db)
|
||||
rows = db.query(models.AnalyticsVisitorDay).all()
|
||||
assert len(rows) == 1 and rows[0].is_new
|
||||
|
||||
# Same visitor, a later day: seen before, so not new. The row is not
|
||||
# merged into the first day's row either.
|
||||
tomorrow = (models.utcnow().date() + timedelta(days=1)).isoformat()
|
||||
analytics._visits[(tomorrow, analytics.visitor_id(user))] = set()
|
||||
analytics.flush(db)
|
||||
rows = db.query(models.AnalyticsVisitorDay).order_by(models.AnalyticsVisitorDay.day).all()
|
||||
assert [row.is_new for row in rows] == [True, False]
|
||||
|
||||
|
||||
def test_one_row_per_visitor_per_day_however_much_they_do(db):
|
||||
user = make_user(db)
|
||||
for _ in range(5):
|
||||
analytics.record_event(analytics.EV_ADVENTURE, user)
|
||||
analytics.flush(db)
|
||||
assert db.query(models.AnalyticsVisitorDay).count() == 1
|
||||
assert counter(db, analytics.M_EVENT, analytics.EV_ADVENTURE) == 5
|
||||
|
||||
|
||||
def test_funnel_flags_only_ever_turn_on(db):
|
||||
user = make_user(db)
|
||||
analytics.record_event(analytics.EV_TURN, user)
|
||||
analytics.flush(db)
|
||||
# A later visit that reaches no funnel step must not clear the earlier one.
|
||||
analytics.record_visit(user)
|
||||
analytics.flush(db)
|
||||
row = db.query(models.AnalyticsVisitorDay).one()
|
||||
assert row.played and not row.created
|
||||
|
||||
|
||||
def test_purge_drops_only_rows_past_the_horizon(db):
|
||||
old = (models.utcnow().date() - timedelta(days=analytics.RETENTION_DAYS + 1)).isoformat()
|
||||
db.add(models.AnalyticsVisitorDay(day=old, visitor="a" * 32))
|
||||
db.add(models.AnalyticsVisitorDay(day=analytics._today(), visitor="b" * 32))
|
||||
db.commit()
|
||||
assert analytics.purge_old_visitor_days(db) == 1
|
||||
assert [r.visitor for r in db.query(models.AnalyticsVisitorDay)] == ["b" * 32]
|
||||
|
||||
|
||||
# ---------- Normalizing what a browser claims ----------
|
||||
|
||||
@pytest.mark.parametrize("path, expected", [
|
||||
("/", "/"),
|
||||
("/adventures", "/adventures"),
|
||||
("/adventures/", "/adventures"),
|
||||
("/play/12?x=1", "/play/:id"),
|
||||
("/scenarios/9#top", "/scenarios/:id"),
|
||||
("/wp-admin", "(other)"),
|
||||
("/play/../../etc", "(other)"),
|
||||
("", "/"),
|
||||
])
|
||||
def test_route_normalization(path, expected):
|
||||
assert analytics.normalize_route(path) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("referrer, expected", [
|
||||
("", "(direct)"),
|
||||
("https://news.ycombinator.com/item?id=1", "news.ycombinator.com"),
|
||||
("https://www.google.com/", "google.com"),
|
||||
("https://ai-dnd.example/scenarios", ""), # our own host: not a referral
|
||||
("javascript:alert(1)", "(other)"),
|
||||
("https://" + "x" * 200 + ".com", "(other)"),
|
||||
])
|
||||
def test_referrer_normalization(referrer, expected):
|
||||
assert analytics.normalize_referrer(referrer, "ai-dnd.example") == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua, expected", [
|
||||
("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit", "mobile"),
|
||||
("Mozilla/5.0 (iPad; CPU OS 17_0) AppleWebKit", "tablet"),
|
||||
("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "desktop"),
|
||||
("Googlebot/2.1", "bot"),
|
||||
("", "(unknown)"),
|
||||
])
|
||||
def test_device_detection(ua, expected):
|
||||
assert analytics.device_of(ua) == expected
|
||||
|
||||
|
||||
def test_only_iso_looking_country_headers_are_trusted():
|
||||
assert analytics.country_of({"cf-ipcountry": "de"}) == "DE"
|
||||
assert analytics.country_of({"cf-ipcountry": "Norway"}) == analytics.UNKNOWN
|
||||
assert analytics.country_of({"cf-ipcountry": "XX"}) == analytics.UNKNOWN
|
||||
assert analytics.country_of({}) == analytics.UNKNOWN
|
||||
|
||||
|
||||
def test_error_labels_use_the_route_not_the_path():
|
||||
class Route:
|
||||
path = "/api/adventures/{adventure_id}"
|
||||
|
||||
assert analytics.api_route_label({"route": Route()}, 500) == "500 /api/adventures/{adventure_id}"
|
||||
# An unmatched path is entirely attacker-chosen, so it never becomes a label.
|
||||
assert analytics.api_route_label({}, 404) == "404 (unmatched)"
|
||||
|
||||
|
||||
# ---------- The summary ----------
|
||||
|
||||
def test_summary_counts_people_once_per_step(db):
|
||||
one, two = make_user(db), make_user(db)
|
||||
for _ in range(3):
|
||||
analytics.record_event(analytics.EV_SCENARIO_OPEN, one)
|
||||
analytics.record_event(analytics.EV_TURN, one)
|
||||
analytics.record_event(analytics.EV_SCENARIO_OPEN, two)
|
||||
|
||||
result = analytics.summary(db, days=7)
|
||||
steps = {row["step"]: row["count"] for row in result["funnel"]}
|
||||
assert steps["Visited"] == 2
|
||||
assert steps["Opened a scenario"] == 2
|
||||
assert steps["Played a turn"] == 1 # not 3, because one person made three turns
|
||||
assert steps["Signed up"] == 0
|
||||
# Raw event totals still count every occurrence.
|
||||
assert result["totals"]["turns"] == 3
|
||||
assert result["totals"]["visitors"] == 2
|
||||
|
||||
|
||||
def test_summary_series_covers_every_day_including_empty_ones(db):
|
||||
analytics.record(analytics.M_PAGE, "/")
|
||||
result = analytics.summary(db, days=7)
|
||||
assert len(result["series"]) == 7
|
||||
assert result["series"][-1]["day"] == models.utcnow().date().isoformat()
|
||||
assert result["series"][-1]["pageviews"] == 1
|
||||
assert result["series"][0]["pageviews"] == 0
|
||||
|
||||
|
||||
def test_summary_flushes_before_reading(db):
|
||||
analytics.record(analytics.M_EVENT, analytics.EV_TURN)
|
||||
# Never flushed by hand: the dashboard must not be up to a minute stale.
|
||||
assert analytics.summary(db, days=1)["totals"]["turns"] == 1
|
||||
|
||||
|
||||
def test_summary_reports_pages_referrers_and_errors(db):
|
||||
analytics.record(analytics.M_PAGE, "/play/:id", n=4)
|
||||
analytics.record(analytics.M_REFERRER, "news.ycombinator.com", n=2)
|
||||
analytics.record(analytics.M_ERROR, "500 /api/adventures/{adventure_id}")
|
||||
result = analytics.summary(db, days=30)
|
||||
assert result["pages"][0] == {"label": "/play/:id", "hits": 4}
|
||||
assert result["referrers"][0]["label"] == "news.ycombinator.com"
|
||||
assert result["totals"]["errors"] == 1
|
||||
|
||||
|
||||
# ---------- The endpoints ----------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
visitor = models.User(is_guest=True)
|
||||
owner = models.User(is_guest=False, email="owner@example.com")
|
||||
setup.add_all([visitor, owner])
|
||||
setup.commit()
|
||||
ids = {"visitor": visitor.id, "owner": owner.id}
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
# Multi-user is what makes the gate mean anything: local mode trusts
|
||||
# whoever is at the keyboard, because it is the operator's own machine.
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
|
||||
|
||||
current = {"id": ids["visitor"]}
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, current["id"])
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
monkeypatch.setattr(
|
||||
auth, "resolve_session_user", lambda request, db: db.get(models.User, current["id"])
|
||||
)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
client.ids, client.current = ids, current
|
||||
yield client
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def read_summary(client, days=30):
|
||||
return client.get(f"/api/analytics/summary?days={days}")
|
||||
|
||||
|
||||
def test_dashboard_is_invisible_to_everyone_but_the_owner(client):
|
||||
assert read_summary(client).status_code == 404
|
||||
client.current["id"] = client.ids["owner"]
|
||||
assert read_summary(client).status_code == 200
|
||||
|
||||
|
||||
def test_collect_records_a_pageview_and_the_visit(client):
|
||||
resp = client.post("/api/analytics/collect", json={"path": "/play/7", "first": True,
|
||||
"referrer": "https://news.ycombinator.com/"})
|
||||
assert resp.status_code == 204
|
||||
client.current["id"] = client.ids["owner"]
|
||||
body = read_summary(client).json()
|
||||
assert body["pages"][0] == {"label": "/play/:id", "hits": 1}
|
||||
assert body["referrers"][0]["label"] == "news.ycombinator.com"
|
||||
assert body["totals"]["visitors"] == 1
|
||||
|
||||
|
||||
def test_referrer_and_device_are_recorded_once_per_visit_not_per_view(client):
|
||||
for path in ("/", "/scenarios", "/adventures"):
|
||||
client.post("/api/analytics/collect", json={"path": path, "first": path == "/"})
|
||||
client.current["id"] = client.ids["owner"]
|
||||
body = read_summary(client).json()
|
||||
assert body["totals"]["pageviews"] == 3
|
||||
# Three views, one visit: the referral and the device are facts about the
|
||||
# visit, so counting them per view would multiply every one of them.
|
||||
assert sum(row["hits"] for row in body["devices"]) == 1
|
||||
|
||||
|
||||
def test_the_owners_own_visits_are_not_traffic(client):
|
||||
client.current["id"] = client.ids["owner"]
|
||||
client.post("/api/analytics/collect", json={"path": "/", "first": True})
|
||||
assert read_summary(client).json()["totals"]["pageviews"] == 0
|
||||
|
||||
|
||||
def test_a_client_cannot_invent_pages_or_events(client):
|
||||
client.post("/api/analytics/collect", json={"path": "/../../admin", "first": True})
|
||||
# There is no field for it, so a made-up event is not even expressible.
|
||||
client.post("/api/analytics/collect", json={"path": "/", "event": "signup"})
|
||||
client.current["id"] = client.ids["owner"]
|
||||
body = read_summary(client).json()
|
||||
assert {row["label"] for row in body["pages"]} == {"(other)", "/"}
|
||||
assert body["totals"]["signups"] == 0
|
||||
|
||||
|
||||
def test_api_errors_are_counted_by_route(client):
|
||||
client.get("/api/adventures/999999")
|
||||
client.current["id"] = client.ids["owner"]
|
||||
errors = read_summary(client).json()["errors"]
|
||||
assert errors and errors[0]["label"].startswith("404 /api/adventures/")
|
||||
|
||||
|
||||
# ---------- The dialect the tests never run on ----------
|
||||
|
||||
def test_the_upserts_compile_for_postgres():
|
||||
"""Prod runs on Neon, but these tests run on SQLite, and a failed flush
|
||||
is caught and logged instead of raised. A dialect mistake would
|
||||
therefore stay invisible until the dashboard quietly stayed empty. This
|
||||
test compiles both statements against Postgres without connecting to
|
||||
one.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
session = sessionmaker(bind=create_engine("postgresql+psycopg://u:p@localhost/db"))()
|
||||
compiled = []
|
||||
|
||||
def capture(statement, *args, **kwargs):
|
||||
compiled.append(str(statement.compile(dialect=postgresql.dialect())))
|
||||
|
||||
session.execute = capture
|
||||
session.scalars = lambda *a, **k: []
|
||||
|
||||
analytics._write_counts(session, {("2026-01-01", "pageview", "/"): 2})
|
||||
analytics._write_visits(session, {("2026-01-01", "f" * 32): {"played"}})
|
||||
|
||||
counts, visits = compiled
|
||||
assert "ON CONFLICT (day, metric, label) DO UPDATE" in counts
|
||||
assert "analytics_daily.hits + excluded.hits" in counts
|
||||
assert "ON CONFLICT (day, visitor) DO UPDATE" in visits
|
||||
assert "analytics_visitor_days.played OR excluded.played" in visits
|
||||
# is_new is settled by the first write of a visitor's first day and must
|
||||
# not be in the update clause at all.
|
||||
assert "is_new" not in visits.split("DO UPDATE")[1]
|
||||
@@ -19,17 +19,10 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -45,14 +38,11 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
script_state={}, world_state={"player": {"hp": 100}},
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
@@ -61,9 +51,6 @@ def client(monkeypatch):
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -30,7 +30,6 @@ from app import auth, limits, models, tree
|
||||
from app.context import history, lineage
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.scripting import ScriptPipeline
|
||||
from tools import dbmeter
|
||||
|
||||
|
||||
@@ -224,24 +223,11 @@ def test_an_already_loaded_collection_is_cut_down_to_the_path(forked):
|
||||
assert labels(history.tail(adventure, 3)) == ["B5", "C6", "C7"]
|
||||
|
||||
|
||||
def test_user_scripts_are_handed_the_path(forked):
|
||||
"""The same risk one layer up, in code visible to users:
|
||||
`pipeline._history()` is the documented scripting history API."""
|
||||
db, adventure, _ = forked
|
||||
list(adventure.actions) # the pipeline's caller has usually loaded these
|
||||
pipeline = ScriptPipeline(adventure, db)
|
||||
assert [h["text"] for h in pipeline._history()] == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
|
||||
]
|
||||
assert pipeline._info()["actionCount"] == 8
|
||||
|
||||
|
||||
# ------------------------------------------------------------ over the wire
|
||||
|
||||
@pytest.fixture()
|
||||
def client(forked, monkeypatch):
|
||||
db, adventure, ids = forked
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(session=Depends(get_db)):
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
|
||||
# advance shows up as a change the referee should have rejected.
|
||||
@@ -30,16 +30,13 @@ SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
|
||||
# The per-turn counter these tests measure rollbacks with. Unbounded and
|
||||
# uncapped on purpose, so every turn's +10 lands in full. See
|
||||
# `fakes.gold_reply`.
|
||||
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
||||
}
|
||||
}
|
||||
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -55,14 +52,11 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
script_state={}, world_state={"player": {"hp": 100, "mana": 50}},
|
||||
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
@@ -71,9 +65,6 @@ def client(monkeypatch):
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -121,7 +112,7 @@ def _state(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return adv.script_state, adv.world_state
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -322,11 +313,13 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
|
||||
|
||||
# -------------------------------------------------------------- the state
|
||||
|
||||
def test_switching_restores_the_script_and_world_state(client):
|
||||
def test_switching_restores_the_state_a_branch_left_behind(client):
|
||||
# Two stats move: hp differs per attempt, and gold counts turns. Between
|
||||
# them, a switch that restored the wrong snapshot is visible either way.
|
||||
ScriptedProvider.replies = [
|
||||
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
|
||||
"A beating.\n```state\n{\"player.hp\": -40}\n```",
|
||||
"Onward.",
|
||||
'A scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
|
||||
'A beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
|
||||
gold_reply("Onward."),
|
||||
]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
@@ -336,9 +329,9 @@ def test_switching_restores_the_script_and_world_state(client):
|
||||
|
||||
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
_fork(client, discarded)
|
||||
script_state, world_state = _state(client.adv_id)
|
||||
player, world_state = _state(client.adv_id)
|
||||
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
|
||||
assert script_state == {"gold": 10}, "one turn of gold, not three"
|
||||
assert player["gold"] == 10, "one turn of gold, not three"
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
||||
assert _state(client.adv_id) == on_parent
|
||||
|
||||
@@ -55,9 +55,6 @@ def client(monkeypatch):
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -33,19 +33,12 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
# Ten gold a turn, so the stored gold total tells how many turns the
|
||||
# story behind it played. This makes an after-snapshot visible from outside.
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
|
||||
OPENING = "You enter a cave."
|
||||
|
||||
@@ -63,14 +56,11 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
script_state={}, world_state={"player": {"hp": 100}},
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
@@ -78,9 +68,6 @@ def client(monkeypatch):
|
||||
ScriptedProvider.replies = ["A reply."]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -186,7 +173,7 @@ def _branch_rows(adv_id) -> list[models.Branch]:
|
||||
def _script_state(adv_id) -> dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.get(models.Adventure, adv_id).script_state
|
||||
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -272,7 +259,7 @@ def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
|
||||
# Play one more turn on the fork, so the two tips end up at
|
||||
# genuinely different totals. Turn for turn, both branches earn the
|
||||
# same gold, so a switch that restored nothing would still look right.
|
||||
ScriptedProvider.replies = ["Further still."]
|
||||
ScriptedProvider.replies = [gold_reply("Further still.")]
|
||||
_play(client, original, "press on")
|
||||
|
||||
per_branch = []
|
||||
@@ -398,7 +385,6 @@ def test_a_fork_with_no_depth_is_refused(client):
|
||||
|
||||
|
||||
def test_more_branches_than_the_cap_is_refused(client, monkeypatch):
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
payload = {
|
||||
"format": bundle.FORMAT, "title": "Too many",
|
||||
"branches": [{"parent": None, "forkDepth": None}]
|
||||
@@ -558,7 +544,6 @@ def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypat
|
||||
multiple of it. The body-size limit does not help here: the text is
|
||||
tiny, and the row count is the actual cost.
|
||||
"""
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 6)
|
||||
monkeypatch.setattr(limits, "_BUNDLE_LIST_CAPS",
|
||||
{**limits._BUNDLE_LIST_CAPS, "actions": 6})
|
||||
|
||||
+43
-165
@@ -1,8 +1,14 @@
|
||||
"""HTTP tests for the AI Chat scratchpad (power users only).
|
||||
"""HTTP tests for the AI Chat scratchpad.
|
||||
|
||||
Covers the access gate, the streamed reply, and the demo-key model pinning.
|
||||
This pinning must not let a public visitor reach paid models through this
|
||||
page.
|
||||
Most of this file used to be about the shared demo key: an access gate on a
|
||||
"power user" email allowlist, and a pinning rule that stopped a public visitor
|
||||
reaching paid models on a server-funded key. M2 removed the hosted deployment
|
||||
those defended, so the rules they tested no longer exist to be tested. See
|
||||
`planning/reports/M2-*` for the accounting.
|
||||
|
||||
What remains is what the page still does: stream a reply from the configured
|
||||
model, honour a system prompt and a per-request model override, and refuse a
|
||||
conversation too large to send.
|
||||
|
||||
python -m pytest tests/test_chat.py -v
|
||||
"""
|
||||
@@ -10,25 +16,27 @@ import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, models
|
||||
from app import auth, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import chat
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
"""Records what it was constructed with, then streams a fixed reply. Stands
|
||||
in for the real egress point, so asserting on last_key/last_model is
|
||||
asserting on exactly what would have gone over the wire."""
|
||||
"""Records what it was constructed with, then streams a fixed reply.
|
||||
|
||||
It stands in for the real egress point, so asserting on `last_endpoint` and
|
||||
`last_model` is asserting on exactly what would have gone over the wire.
|
||||
There is no `last_key` any more: the provider takes no API key, because
|
||||
Ollama does not use one.
|
||||
"""
|
||||
last_usage = None
|
||||
last_model = None
|
||||
last_key = None
|
||||
last_endpoint = None
|
||||
last_messages = None
|
||||
|
||||
def __init__(self, endpoint_url, api_key, model, api_mode="chat", reasoning_max_tokens=0):
|
||||
def __init__(self, endpoint_url, model, api_mode="chat", read_timeout=None):
|
||||
FakeProvider.last_model = model
|
||||
FakeProvider.last_key = api_key
|
||||
FakeProvider.last_endpoint = endpoint_url
|
||||
|
||||
async def chat(self, messages, *, temperature, max_tokens):
|
||||
@@ -41,68 +49,35 @@ class FakeProvider:
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="power@example.com")
|
||||
user = models.User(is_guest=False)
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
||||
setup.commit()
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(chat, "OpenAICompatibleProvider", FakeProvider)
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
# Multi-user mode is what makes the power-user gate meaningful, because
|
||||
# local mode trusts everyone. The allowlist is set per test.
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(auth, "POWER_USERS", {"power@example.com"})
|
||||
# These tests deliberately do not stub resolve_provider_config. The
|
||||
# point is to exercise the real BYOK-vs-demo decision, since that
|
||||
# decision is what keeps the shared key off paid models. Each test
|
||||
# picks a mode with _byok/_demo below.
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield TestClient(app)
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def _send(client, **extra):
|
||||
return client.post("/api/chat/stream", json={"messages": [{"role": "user", "content": "hi"}], **extra})
|
||||
def _send(client, **body):
|
||||
payload = {"messages": [{"role": "user", "content": "hi"}]}
|
||||
payload.update(body)
|
||||
return client.post("/api/chat/stream", json=payload)
|
||||
|
||||
|
||||
def _byok(monkeypatch):
|
||||
"""The user brought their own key: no demo key in play, any model allowed."""
|
||||
monkeypatch.setattr(auth, "demo_enabled", lambda: False)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = db.query(models.Settings).first()
|
||||
settings.api_key = "sk-my-own-key" # legacy-plaintext path: used as-is
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _demo(monkeypatch, whitelist=("free/allowed",)):
|
||||
"""The user has no key, so turns run on the server-funded demo key."""
|
||||
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
|
||||
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
|
||||
monkeypatch.setattr(auth, "DEMO_MODELS", list(whitelist))
|
||||
|
||||
|
||||
def test_non_power_user_gets_404(client, monkeypatch):
|
||||
monkeypatch.setattr(auth, "POWER_USERS", set())
|
||||
assert _send(client).status_code == 404
|
||||
assert client.get("/api/chat/config").status_code == 404
|
||||
|
||||
|
||||
def test_power_user_streams_a_reply(client, monkeypatch):
|
||||
_byok(monkeypatch)
|
||||
def test_the_page_streams_a_reply(client):
|
||||
resp = _send(client)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert '"type": "reasoning"' in resp.text
|
||||
@@ -111,136 +86,39 @@ def test_power_user_streams_a_reply(client, monkeypatch):
|
||||
assert FakeProvider.last_messages == [{"role": "user", "content": "hi"}]
|
||||
|
||||
|
||||
def test_system_prompt_and_model_override_are_honoured(client, monkeypatch):
|
||||
_byok(monkeypatch)
|
||||
def test_it_uses_the_configured_endpoint_and_model(client):
|
||||
_send(client)
|
||||
assert FakeProvider.last_model == "test-model"
|
||||
# The default from `models.Settings`, and the only kind of address the
|
||||
# endpoint policy allows without configuration.
|
||||
assert FakeProvider.last_endpoint == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
def test_system_prompt_and_model_override_are_honoured(client):
|
||||
resp = client.post("/api/chat/stream", json={
|
||||
"messages": [
|
||||
{"role": "system", "content": "Be terse."},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
"model": "some/other-model",
|
||||
"model": "some-other-model",
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
# BYOK: any model the user names is passed straight through, on their key.
|
||||
assert FakeProvider.last_model == "some/other-model"
|
||||
assert FakeProvider.last_key == "sk-my-own-key"
|
||||
assert FakeProvider.last_model == "some-other-model"
|
||||
assert FakeProvider.last_messages[0] == {"role": "system", "content": "Be terse."}
|
||||
|
||||
|
||||
def test_demo_key_pins_model_to_whitelist(client, monkeypatch):
|
||||
_demo(monkeypatch)
|
||||
resp = _send(client, model="expensive/paid-model")
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Refused visibly: the whitelisted model runs instead, with a note. The
|
||||
# paid slug must never reach the wire alongside the server-funded key.
|
||||
assert FakeProvider.last_model == "free/allowed"
|
||||
assert FakeProvider.last_key == "demo-key"
|
||||
assert '"type": "note"' in resp.text
|
||||
|
||||
# A whitelisted model is still selectable on the demo key.
|
||||
_demo(monkeypatch, ["free/allowed", "free/second"])
|
||||
resp = _send(client, model="free/second")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert FakeProvider.last_model == "free/second"
|
||||
|
||||
|
||||
def test_demo_key_ignores_an_off_whitelist_settings_model(client, monkeypatch):
|
||||
"""The override is not the only untrusted input. `Settings.model` is
|
||||
also user-set, and it must be pinned the same way when there is no
|
||||
BYOK key."""
|
||||
_demo(monkeypatch)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(models.Settings).first().model = "expensive/paid-model"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
resp = _send(client)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert FakeProvider.last_model == "free/allowed"
|
||||
|
||||
|
||||
def test_demo_key_endpoint_cannot_be_redirected(client, monkeypatch):
|
||||
"""A user-controlled `endpoint_url` would leak the key itself, which is
|
||||
worse than spending it. The demo branch pins the URL too."""
|
||||
_demo(monkeypatch)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(models.Settings).first().endpoint_url = "http://attacker.example/v1"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
assert _send(client).status_code == 200
|
||||
assert FakeProvider.last_endpoint == "http://demo"
|
||||
assert FakeProvider.last_key == "demo-key"
|
||||
|
||||
|
||||
def test_provider_config_refuses_server_funded_paid_model(monkeypatch):
|
||||
"""The structural backstop: a hand-built config (a future code path that
|
||||
forgets to go through resolve_provider_config) cannot run a
|
||||
server-funded turn on an off-whitelist model."""
|
||||
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
|
||||
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
|
||||
with pytest.raises(ValueError):
|
||||
auth.ProviderConfig("http://demo", "demo-key", "expensive/paid-model", True)
|
||||
auth.ProviderConfig("http://demo", "demo-key", "free/allowed", True) # whitelisted: fine
|
||||
# The user's own key with any model stays fine.
|
||||
auth.ProviderConfig("http://any", "sk-mine", "expensive/paid-model", False)
|
||||
|
||||
|
||||
def test_byok_user_may_reuse_the_demo_keys_value(client, monkeypatch):
|
||||
"""Regression: the demo key is just an OpenRouter key, so a user can paste
|
||||
that same value into their own Settings. That is still BYOK, because
|
||||
the user is paying, and it must not trip the guard. It used to raise
|
||||
on every resolution, which returned a 500 from `GET /auth/me` and
|
||||
broke the entire SPA (no nav, no chat)."""
|
||||
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth, "DEMO_API_KEY", "shared-key")
|
||||
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
|
||||
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
|
||||
def test_a_request_with_no_model_anywhere_is_refused(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = db.query(models.Settings).first()
|
||||
settings.api_key = "shared-key" # same value, but supplied by the user
|
||||
settings.model = "expensive/paid-model" # their spend, their choice
|
||||
settings.model = ""
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
assert client.get("/api/chat/config").status_code == 200
|
||||
resp = _send(client)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert FakeProvider.last_model == "expensive/paid-model"
|
||||
assert FakeProvider.last_key == "shared-key"
|
||||
assert _send(client).status_code == 400
|
||||
|
||||
|
||||
def test_resolve_provider_config_is_the_single_choke_point(monkeypatch):
|
||||
"""Turns, AI Chat, and the connection test all resolve through this one
|
||||
function, so pinning it here pins every caller. No DB or HTTP needed."""
|
||||
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
|
||||
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
|
||||
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
|
||||
|
||||
# No key of their own: both endpoint and model are pinned, regardless
|
||||
# of what they set.
|
||||
no_key = models.Settings(endpoint_url="http://mine/v1", api_key="", model="expensive/paid")
|
||||
assert auth.resolve_provider_config(no_key) == auth.ProviderConfig(
|
||||
"http://demo", "demo-key", "free/allowed", True)
|
||||
assert auth.resolve_provider_config(
|
||||
no_key, model_override="expensive/paid").model == "free/allowed"
|
||||
assert auth.resolve_provider_config(
|
||||
no_key, model_override="free/allowed").model == "free/allowed"
|
||||
|
||||
# Their own key: their endpoint, their key, their choice of model.
|
||||
byok = models.Settings(endpoint_url="http://mine/v1", api_key="sk-mine", model="expensive/paid")
|
||||
assert auth.resolve_provider_config(byok) == auth.ProviderConfig(
|
||||
"http://mine/v1", "sk-mine", "expensive/paid", False)
|
||||
|
||||
|
||||
def test_oversized_conversation_is_refused(client, monkeypatch):
|
||||
_byok(monkeypatch)
|
||||
def test_oversized_conversation_is_refused(client):
|
||||
huge = "x" * 90_000
|
||||
resp = client.post("/api/chat/stream", json={
|
||||
"messages": [{"role": "user", "content": huge} for _ in range(5)],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Deleting a turn puts the shared state back.
|
||||
|
||||
`script_state` and `world_state` belong to the adventure, not to the node
|
||||
`world_state` belongs to the adventure, not to the node
|
||||
that changed them. Undo, retry, a take and a branch switch all restore them;
|
||||
the delete endpoint did not. Deleting an AI turn removed the text and left
|
||||
everything the turn did to the numbers standing.
|
||||
@@ -23,7 +23,7 @@ from app import auth, limits, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
|
||||
# a refusal rather than as a number that is merely off.
|
||||
@@ -31,20 +31,19 @@ SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
|
||||
# The per-turn counter these tests measure rollbacks with. Unbounded and
|
||||
# uncapped on purpose, so every turn's +10 lands in full. See
|
||||
# `fakes.gold_reply`.
|
||||
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
||||
}
|
||||
}
|
||||
|
||||
# Ten gold a turn. A total that only ever climbs makes a missing rollback
|
||||
# obvious: it is off by exactly one turn's worth.
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
|
||||
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
|
||||
# The same turn, also banking the per-turn counter the rollback tests measure.
|
||||
DRAIN_AND_GOLD = 'Drained.\n```state\n{"player.mana": -10, "player.gold": 10}\n```'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -60,24 +59,18 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Tower", scenario_id=scenario.id,
|
||||
script_state={}, world_state={"player": {"hp": 100, "mana": 50}},
|
||||
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = [DRAIN]
|
||||
ScriptedProvider.replies = [DRAIN_AND_GOLD]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -116,7 +109,7 @@ def _state(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return adv.script_state, adv.world_state
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -168,17 +161,17 @@ def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
|
||||
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
|
||||
|
||||
|
||||
def test_deleting_the_ai_turn_rewinds_the_script_state(client):
|
||||
"""The same restore, on the other half of the shared state. Without it a
|
||||
replayed turn stacks its script run on top of the deleted one's."""
|
||||
def test_deleting_the_ai_turn_rewinds_the_counter(client):
|
||||
"""The same restore, seen through a value that only ever climbs. Without it
|
||||
a replayed turn stacks its effects on top of the deleted one's."""
|
||||
_play(client)
|
||||
assert _state(client.adv_id)[0] == {"gold": 10}
|
||||
assert _state(client.adv_id)[0]["gold"] == 10
|
||||
|
||||
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
||||
assert _state(client.adv_id)[0] == {}
|
||||
assert _state(client.adv_id)[0]["gold"] == 0
|
||||
|
||||
_continue(client)
|
||||
assert _state(client.adv_id)[0] == {"gold": 10}, "one turn of gold, not two"
|
||||
assert _state(client.adv_id)[0]["gold"] == 10, "one turn of gold, not two"
|
||||
|
||||
|
||||
# ------------------------------------------------- deleting further back
|
||||
@@ -190,7 +183,7 @@ def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
|
||||
_play(client)
|
||||
_play(client, "press on")
|
||||
before = _state(client.adv_id)
|
||||
assert before[0] == {"gold": 20}
|
||||
assert before[0]["gold"] == 20
|
||||
|
||||
first_ai = _ai_rows(client.adv_id)[0]
|
||||
assert _delete(client, first_ai.id).status_code == 204
|
||||
|
||||
@@ -99,7 +99,6 @@ def client(monkeypatch):
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -57,7 +57,6 @@ def client(monkeypatch):
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Which inference endpoints the product will and will not talk to.
|
||||
|
||||
The endpoint is where the whole campaign goes: prompts, narration, retrieved
|
||||
memories, embedding inputs. `app/endpoints.py` is the rule that keeps that on
|
||||
the user's own infrastructure, and these tests are what stops the rule quietly
|
||||
widening.
|
||||
|
||||
Nothing here touches the network. Hostnames are resolved through a stub, so the
|
||||
tests are about the *policy*, run identically offline, and cannot be made to
|
||||
pass or fail by whatever DNS the machine happens to have.
|
||||
|
||||
python -m pytest tests/test_endpoint_policy.py -v
|
||||
"""
|
||||
import ipaddress
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, endpoints, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.providers import OpenAICompatibleProvider, ProviderError
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def resolves(monkeypatch):
|
||||
"""Points every hostname at addresses the test names."""
|
||||
table: dict[str, list[str]] = {}
|
||||
|
||||
def fake_getaddrinfo(host, port, *a, **k):
|
||||
# An address literal resolves to itself, as the real resolver does.
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
if host not in table:
|
||||
raise socket.gaierror(-2, "Name or service not known")
|
||||
answers = table[host]
|
||||
else:
|
||||
answers = [host]
|
||||
return [
|
||||
(
|
||||
socket.AF_INET6 if ":" in ip else socket.AF_INET,
|
||||
socket.SOCK_STREAM, 6, "", (ip, port or 0),
|
||||
)
|
||||
for ip in answers
|
||||
]
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
return table
|
||||
|
||||
|
||||
# --- allowed: the user's own machine, and the user's own network ----------
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://127.0.0.1:11434/v1",
|
||||
"http://[::1]:11434/v1",
|
||||
"http://192.168.1.50:11434/v1", # RFC1918
|
||||
"http://10.0.0.7:11434/v1", # RFC1918
|
||||
"http://172.16.4.4:11434/v1", # RFC1918
|
||||
"https://192.168.1.50:8443/v1", # TLS on the LAN
|
||||
"http://100.100.5.6:11434/v1", # CGNAT, which is what a mesh VPN hands out
|
||||
"http://[fd00::1]:11434/v1", # IPv6 unique-local
|
||||
])
|
||||
def test_local_and_lan_addresses_are_allowed(url, resolves):
|
||||
assert endpoints.rejection_reason(url) is None
|
||||
|
||||
|
||||
def test_a_hostname_resolving_to_the_lan_is_allowed(resolves):
|
||||
resolves["ollama.home.arpa"] = ["192.168.1.50"]
|
||||
assert endpoints.rejection_reason("https://ollama.home.arpa:8443/v1") is None
|
||||
|
||||
|
||||
# --- refused: anywhere else ----------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://8.8.8.8:11434/v1",
|
||||
"http://1.1.1.1:11434/v1",
|
||||
"http://[2001:4860:4860::8888]:11434/v1",
|
||||
])
|
||||
def test_public_addresses_are_refused(url, resolves):
|
||||
reason = endpoints.rejection_reason(url)
|
||||
assert reason is not None
|
||||
assert "public Internet address" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://198.51.100.9/v1", # TEST-NET-2, a documentation range
|
||||
"http://0.0.0.0:11434/v1", # "this host", not an address to dial
|
||||
])
|
||||
def test_addresses_that_are_neither_local_nor_public_are_refused(url, resolves):
|
||||
"""Deny by default. `ipaddress` calls both of these "private", which is why
|
||||
the policy names the networks it allows instead of asking that question."""
|
||||
assert endpoints.rejection_reason(url) is not None
|
||||
|
||||
|
||||
def test_a_hostname_resolving_to_the_public_internet_is_refused(resolves):
|
||||
resolves["ollama.example.com"] = ["93.184.216.34"]
|
||||
reason = endpoints.rejection_reason("https://ollama.example.com/v1")
|
||||
assert reason is not None and "public Internet address" in reason
|
||||
|
||||
|
||||
def test_a_split_horizon_answer_is_refused(resolves):
|
||||
"""One public address among the answers is enough. A name that resolves to
|
||||
both a LAN address and a public one must not be usable: which one the
|
||||
request actually reaches is not ours to decide."""
|
||||
resolves["sneaky.example"] = ["192.168.1.50", "93.184.216.34"]
|
||||
assert endpoints.rejection_reason("http://sneaky.example:11434/v1") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"openrouter.ai", "api.openai.com", "api.anthropic.com", "api.groq.com",
|
||||
])
|
||||
def test_known_cloud_providers_are_named_in_the_refusal(host, resolves):
|
||||
"""They would be refused by address anyway — every one resolves publicly.
|
||||
Naming them turns 'your DNS might be broken' into 'this build has no cloud
|
||||
provider support'."""
|
||||
resolves[host] = ["192.168.1.50"] # even if DNS said otherwise
|
||||
reason = endpoints.rejection_reason(f"https://{host}/v1")
|
||||
assert reason is not None
|
||||
assert "cloud inference service" in reason
|
||||
|
||||
|
||||
def test_a_subdomain_of_a_cloud_provider_is_refused(resolves):
|
||||
resolves["eu.api.openai.com"] = ["192.168.1.50"]
|
||||
assert endpoints.rejection_reason("https://eu.api.openai.com/v1") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"ftp://127.0.0.1/v1",
|
||||
"file:///etc/passwd",
|
||||
"127.0.0.1:11434", # no scheme
|
||||
"",
|
||||
"http://",
|
||||
])
|
||||
def test_things_that_are_not_usable_urls_are_refused(url, resolves):
|
||||
assert endpoints.rejection_reason(url) is not None
|
||||
|
||||
|
||||
def test_an_unresolvable_host_is_refused_with_advice(resolves):
|
||||
reason = endpoints.rejection_reason("http://nope.invalid:11434/v1")
|
||||
assert reason is not None and "could not be resolved" in reason
|
||||
|
||||
|
||||
# --- the rule is applied, not merely available ---------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False)
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
||||
setup.commit()
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def test_saving_a_public_endpoint_is_refused(client, resolves):
|
||||
resolves["openrouter.ai"] = ["93.184.216.34"]
|
||||
r = client.put("/api/settings", json={"endpoint_url": "https://openrouter.ai/api/v1"})
|
||||
assert r.status_code == 400, r.text
|
||||
assert "can't be used" in r.json()["detail"]
|
||||
# And it was not written.
|
||||
assert client.get("/api/settings").json()["endpoint_url"] != "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def test_saving_a_lan_endpoint_is_allowed(client, resolves):
|
||||
r = client.put("/api/settings", json={"endpoint_url": "http://192.168.1.50:11434/v1"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get("/api/settings").json()["endpoint_url"] == "http://192.168.1.50:11434/v1"
|
||||
|
||||
|
||||
def test_the_connection_test_reports_a_refused_endpoint_as_such(client, resolves):
|
||||
"""A row edited by hand, or a name that has started resolving elsewhere,
|
||||
must not simply look 'unreachable'."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(models.Settings).first().endpoint_url = "https://openrouter.ai/api/v1"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
resolves["openrouter.ai"] = ["93.184.216.34"]
|
||||
body = client.post("/api/settings/test").json()
|
||||
assert body["ok"] is False
|
||||
assert body["kind"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_the_provider_refuses_before_it_sends_anything(resolves):
|
||||
"""The check that actually matters. Whatever is stored, no request leaves
|
||||
for an address outside the policy — so a database edited behind the app's
|
||||
back cannot turn into an exfiltration path."""
|
||||
resolves["openrouter.ai"] = ["93.184.216.34"]
|
||||
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "m")
|
||||
with pytest.raises(ProviderError) as exc:
|
||||
async for _ in provider.generate(
|
||||
_parts(), temperature=0.8, max_tokens=10
|
||||
):
|
||||
pass
|
||||
assert "can't be used" in str(exc.value)
|
||||
|
||||
|
||||
def _parts():
|
||||
from app.providers.base import PromptParts
|
||||
return PromptParts(system="s", story="t")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Guest retention policy: app/cleanup.py.
|
||||
|
||||
These tests cover the two things that matter. Idle guests and their whole
|
||||
data graph must actually be deleted, and nothing else must ever be
|
||||
deleted.
|
||||
|
||||
python -m pytest tests/test_guest_cleanup.py -v
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app import cleanup, models
|
||||
from app.database import Base
|
||||
from app.migrations import bootstrap
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}", connect_args={"check_same_thread": False})
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _fk(dbapi_connection, _record):
|
||||
# The whole policy relies on ON DELETE CASCADE. SQLite ignores every
|
||||
# one of them unless this is set, the same as database.py does.
|
||||
cur = dbapi_connection.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.close()
|
||||
|
||||
bootstrap(engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
NOW = models.utcnow().replace(tzinfo=None)
|
||||
|
||||
|
||||
def make_user(db, *, days_idle=None, days_old=0, guest=True, email=None):
|
||||
"""A user last seen `days_idle` ago (None = never seen, only created)."""
|
||||
user = models.User(
|
||||
is_guest=guest,
|
||||
email=email,
|
||||
password_hash=None if email is None else "x",
|
||||
created_at=NOW - timedelta(days=days_old),
|
||||
last_seen_at=None if days_idle is None else NOW - timedelta(days=days_idle),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def sweep(db):
|
||||
return cleanup.delete_stale_guests(db, now=NOW)
|
||||
|
||||
|
||||
def alive(db, user_id):
|
||||
# A count, not db.get: the sweep deletes with synchronize_session=False, so
|
||||
# the session's identity map still holds the object and db.get would answer
|
||||
# from memory without ever asking the database.
|
||||
return db.query(models.User).filter(models.User.id == user_id).count() == 1
|
||||
|
||||
|
||||
# ---------- what goes ----------
|
||||
|
||||
def test_deletes_guest_idle_past_the_window(db):
|
||||
user = make_user(db, days_idle=6)
|
||||
assert sweep(db) == 1
|
||||
assert not alive(db, user.id)
|
||||
|
||||
|
||||
def test_keeps_guest_inside_the_window(db):
|
||||
user = make_user(db, days_idle=4)
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
|
||||
|
||||
def test_boundary_is_not_yet_stale(db):
|
||||
# Exactly 5 days survives. The comparison is strict.
|
||||
user = make_user(db, days_idle=cleanup.RETENTION_DAYS)
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
|
||||
|
||||
def test_never_seen_guest_falls_back_to_created_at(db):
|
||||
"""last_seen_at is NULL until a guest's second request (auth._touch runs
|
||||
hourly), so a coalesce-less query would delete brand-new visitors."""
|
||||
fresh = make_user(db, days_idle=None, days_old=0)
|
||||
stale = make_user(db, days_idle=None, days_old=9)
|
||||
assert sweep(db) == 1
|
||||
assert alive(db, fresh.id)
|
||||
assert not alive(db, stale.id)
|
||||
|
||||
|
||||
def test_recent_visit_beats_an_old_created_at(db):
|
||||
# A long-standing guest who came back yesterday stays.
|
||||
user = make_user(db, days_idle=1, days_old=90)
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
|
||||
|
||||
# ---------- what must never go ----------
|
||||
|
||||
def test_spares_registered_users(db):
|
||||
"""Registering upgrades the guest row in place. An idle account here is
|
||||
a real user with real data, which is what signing up is meant to
|
||||
protect."""
|
||||
user = make_user(db, days_idle=400, guest=False, email="a@b.com")
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
|
||||
|
||||
def test_spares_the_local_mode_user(db):
|
||||
# email NULL but is_guest False: local mode's implicit owner of everything.
|
||||
user = make_user(db, days_idle=400, guest=False)
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
|
||||
|
||||
def test_spares_a_guest_flagged_row_that_has_an_email(db):
|
||||
# This row should not exist, but both clauses are checked so it cannot be collected.
|
||||
user = make_user(db, days_idle=400, guest=True, email="odd@b.com")
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
|
||||
|
||||
def test_leaves_seeded_public_scenarios_alone(db):
|
||||
"""Seeded demo content has user_id NULL, so it is outside the filter."""
|
||||
seeded = models.Scenario(user_id=None, is_public=True, title="Demo")
|
||||
db.add(seeded)
|
||||
make_user(db, days_idle=30)
|
||||
db.commit()
|
||||
assert sweep(db) == 1
|
||||
assert db.query(models.Scenario).filter(models.Scenario.id == seeded.id).count() == 1
|
||||
|
||||
|
||||
def test_disabled_when_retention_is_zero(db, monkeypatch):
|
||||
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
|
||||
user = make_user(db, days_idle=999)
|
||||
assert sweep(db) == 0
|
||||
assert alive(db, user.id)
|
||||
|
||||
|
||||
def test_enabled_requires_multi_user(monkeypatch):
|
||||
from app import auth
|
||||
monkeypatch.setattr(auth, "MULTI_USER", False)
|
||||
assert cleanup.enabled() is False
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 5)
|
||||
assert cleanup.enabled() is True
|
||||
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
|
||||
assert cleanup.enabled() is False
|
||||
|
||||
|
||||
# ---------- the cascade ----------
|
||||
|
||||
def test_deletes_the_whole_data_graph(db):
|
||||
"""One DELETE must remove the adventure, its actions and memories, the
|
||||
story cards, and the settings row. Nothing is loaded into Python, so if
|
||||
the FK cascade does not reach a table, its rows are silently orphaned,
|
||||
or the statement fails, instead of being removed."""
|
||||
user = make_user(db, days_idle=30)
|
||||
scenario = models.Scenario(user_id=user.id, title="S")
|
||||
db.add(scenario)
|
||||
db.commit()
|
||||
adventure = models.Adventure(user_id=user.id, scenario_id=scenario.id, title="A")
|
||||
db.add(adventure)
|
||||
db.commit()
|
||||
db.add_all([
|
||||
models.Action(adventure_id=adventure.id, type="ai", text="t"),
|
||||
models.Memory(adventure_id=adventure.id, text="m", source_start=0, source_end=0),
|
||||
models.StoryCard(adventure_id=adventure.id, name="c"),
|
||||
models.Settings(user_id=user.id),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
assert sweep(db) == 1
|
||||
|
||||
for model in (models.Scenario, models.Adventure, models.Action,
|
||||
models.Memory, models.StoryCard, models.Settings):
|
||||
assert db.query(model).count() == 0, f"{model.__name__} rows survived"
|
||||
|
||||
|
||||
def test_one_users_cleanup_does_not_touch_another(db):
|
||||
keeper = make_user(db, days_idle=1)
|
||||
keep_adv = models.Adventure(user_id=keeper.id, title="mine")
|
||||
goner = make_user(db, days_idle=30)
|
||||
db.add_all([keep_adv, models.Adventure(user_id=goner.id, title="theirs")])
|
||||
db.commit()
|
||||
|
||||
assert sweep(db) == 1
|
||||
remaining = db.query(models.Adventure).all()
|
||||
assert [a.title for a in remaining] == ["mine"]
|
||||
|
||||
|
||||
def test_sweep_swallows_errors(monkeypatch):
|
||||
"""A broken cleanup must not take the app down (same rule as seeding)."""
|
||||
monkeypatch.setattr(cleanup.auth, "MULTI_USER", True)
|
||||
monkeypatch.setattr(cleanup, "delete_stale_guests",
|
||||
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
assert cleanup.sweep() == 0
|
||||
@@ -0,0 +1,181 @@
|
||||
"""What M2 removed stays removed, and what it must not break stays working.
|
||||
|
||||
A subtractive milestone needs tests that fail if the surface grows back. These
|
||||
are cheap, blunt, and deliberately not clever: they assert against the running
|
||||
app's route table, the shipped configuration files, and the settings API.
|
||||
|
||||
python -m pytest tests/test_local_only_surface.py -v
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.providers.openai_compatible import (
|
||||
CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, OpenAICompatibleProvider,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _paths() -> set[str]:
|
||||
"""Every path the app serves, including those inside included routers."""
|
||||
found = set()
|
||||
|
||||
def walk(routes):
|
||||
for r in routes:
|
||||
path = getattr(r, "path", None)
|
||||
if path:
|
||||
found.add(path)
|
||||
walk(getattr(r, "routes", []) or [])
|
||||
|
||||
walk(app.routes)
|
||||
return found
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False)
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
||||
setup.commit()
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# --- the removed surfaces ------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["/api/auth", "/api/analytics", "/api/scripts"])
|
||||
def test_no_route_serves_a_removed_subsystem(prefix):
|
||||
"""Accounts, the visitor dashboard, and campaign scripting are gone as
|
||||
routes, not merely hidden behind a flag."""
|
||||
assert not [p for p in _paths() if p.startswith(prefix)], prefix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/api/auth/me", "/api/auth/login", "/api/auth/register", "/api/auth/logout",
|
||||
"/api/analytics/summary", "/api/analytics/collect", "/api/analytics/access",
|
||||
"/api/scripts", "/api/adventures/1/scripts", "/api/adventures/1/script-state",
|
||||
])
|
||||
def test_a_removed_endpoint_answers_404(client, path):
|
||||
assert client.get(path).status_code == 404, path
|
||||
|
||||
|
||||
def test_the_application_has_no_scripting_engine():
|
||||
with pytest.raises(ImportError):
|
||||
__import__("app.scripting")
|
||||
|
||||
|
||||
def test_no_module_imports_quickjs():
|
||||
"""The dependency is gone from requirements; this catches an import that
|
||||
would put it back."""
|
||||
for py in (REPO / "backend" / "app").rglob("*.py"):
|
||||
assert "import quickjs" not in py.read_text(), py
|
||||
|
||||
|
||||
def test_requirements_carry_no_hosted_dependencies():
|
||||
text = (REPO / "backend" / "requirements.txt").read_text()
|
||||
for gone in ("quickjs", "psycopg", "cryptography"):
|
||||
assert gone not in text, gone
|
||||
|
||||
|
||||
def test_no_render_deployment_config():
|
||||
assert not (REPO / "render.yaml").exists()
|
||||
|
||||
|
||||
# --- no cloud provider, no key ------------------------------------------
|
||||
|
||||
def test_settings_expose_no_api_key_field(client):
|
||||
body = client.get("/api/settings").json()
|
||||
assert "api_key" not in body
|
||||
assert "has_api_key" not in body
|
||||
|
||||
|
||||
def test_an_api_key_cannot_be_set_through_the_api(client):
|
||||
"""Pydantic ignores unknown fields, so this asserts the value does not
|
||||
land rather than that the request is refused."""
|
||||
client.put("/api/settings", json={"api_key": "sk-should-not-stick"})
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.query(models.Settings).first().api_key == ""
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_the_provider_sends_no_authorization_header():
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
|
||||
assert "Authorization" not in provider._headers()
|
||||
|
||||
|
||||
# --- the model timeout ---------------------------------------------------
|
||||
|
||||
def test_the_default_timeout_is_generous_but_finite():
|
||||
"""M1 measured a cold model load exceeding the inherited hardcoded 120s on
|
||||
a CPU-only host. It must be longer than that, and it must be a number."""
|
||||
assert DEFAULT_READ_TIMEOUT > 120
|
||||
assert DEFAULT_READ_TIMEOUT <= 3600
|
||||
|
||||
|
||||
def test_connect_stays_short_while_reading_stays_patient():
|
||||
"""A wrong address should fail in seconds; a loading model should not."""
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
|
||||
timeout = provider._timeout()
|
||||
assert timeout.connect == CONNECT_TIMEOUT <= 30
|
||||
assert timeout.read == DEFAULT_READ_TIMEOUT
|
||||
|
||||
|
||||
def test_the_timeout_is_configurable(client):
|
||||
r = client.put("/api/settings", json={"model_timeout_seconds": 900})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get("/api/settings").json()["model_timeout_seconds"] == 900
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 29, 3601, -1])
|
||||
def test_an_unusable_timeout_is_refused(client, value):
|
||||
"""Not zero, not negative, and not "wait forever" spelled as a big number."""
|
||||
assert client.put(
|
||||
"/api/settings", json={"model_timeout_seconds": value}
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_the_provider_honours_the_configured_timeout():
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m", read_timeout=45)
|
||||
assert provider._timeout().read == 45
|
||||
|
||||
|
||||
# --- the storyteller stays on loopback -----------------------------------
|
||||
|
||||
def test_the_native_start_scripts_bind_loopback():
|
||||
for script in ("start.sh", "start.ps1"):
|
||||
text = (REPO / script).read_text(errors="ignore")
|
||||
assert "--host 127.0.0.1" in text, script
|
||||
assert "--host 0.0.0.0" not in text, script
|
||||
|
||||
|
||||
def test_compose_publishes_to_loopback_only():
|
||||
"""The container listens on 0.0.0.0 because a published port cannot reach
|
||||
anything else. What must stay loopback is the *host* side of the mapping."""
|
||||
text = (REPO / "docker-compose.yml").read_text()
|
||||
published = re.findall(r'^\s*-\s*"([^"]+)"', text, re.M)
|
||||
assert published, "no published ports found — has the file moved?"
|
||||
for mapping in published:
|
||||
assert mapping.startswith("127.0.0.1:"), mapping
|
||||
@@ -128,7 +128,7 @@ def fill_bank(db, adventure, *, count=2):
|
||||
def options(**overrides):
|
||||
args = dict(write=False, adventure=None, email=None, limit=None,
|
||||
include_forgotten=False, embed=False, endpoint=None,
|
||||
model=None, api_key=None)
|
||||
model=None)
|
||||
args.update(overrides)
|
||||
return argparse.Namespace(**args)
|
||||
|
||||
@@ -248,23 +248,6 @@ def test_the_rewrite_prompt_is_the_one_the_app_sends(db):
|
||||
|
||||
# --------------------------------------------------------------------- the tool
|
||||
|
||||
def test_the_database_line_carries_no_password():
|
||||
"""The report names the database it is about to rewrite. That line ends up
|
||||
in a console, a screenshot or a pasted bug report."""
|
||||
shown = rewrite_memories.safe_dsn(
|
||||
"postgresql://parth:hunter2@ep-cool-frost.us-east-1.aws.neon.tech/aidnd"
|
||||
"?sslmode=require")
|
||||
assert "hunter2" not in shown
|
||||
assert "sslmode" not in shown # a password can be passed there too
|
||||
assert shown == ("postgresql://parth@ep-cool-frost.us-east-1.aws.neon.tech"
|
||||
"/aidnd")
|
||||
|
||||
|
||||
def test_an_unparseable_database_url_shows_nothing_at_all():
|
||||
assert rewrite_memories.safe_dsn("not-a-url") == "(configured)"
|
||||
|
||||
|
||||
|
||||
def test_without_write_nothing_changes(db, monkeypatch):
|
||||
adventure = make_adventure(db)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
@@ -316,9 +299,12 @@ def test_a_hand_written_memory_is_left_alone(db, monkeypatch):
|
||||
assert typed.text == "Kaelen owes a debt."
|
||||
|
||||
|
||||
def test_an_owner_with_no_api_key_is_skipped(db, monkeypatch):
|
||||
"""Summarization spends the user's own key and never the shared demo key."""
|
||||
adventure = make_adventure(db, key="")
|
||||
def test_an_adventure_with_no_model_configured_is_skipped(db, monkeypatch):
|
||||
"""Nothing to summarize with, so the memory is reported rather than
|
||||
silently left as it was. This used to be about the owner's API key; after
|
||||
M2 the thing that can be missing is the model name."""
|
||||
adventure = make_adventure(db)
|
||||
_blank_model(db, adventure)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
stub = StubSummarizer()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
@@ -328,10 +314,10 @@ def test_an_owner_with_no_api_key_is_skipped(db, monkeypatch):
|
||||
assert first.text == "You entered the crypt 1."
|
||||
|
||||
|
||||
def test_an_api_key_on_the_command_line_covers_that_owner(db, monkeypatch):
|
||||
"""The way to summarize for an owner who has no key of their own, and the
|
||||
way to point a run at the local Claude shim instead of a paid endpoint."""
|
||||
adventure = make_adventure(db, key="")
|
||||
def test_a_model_on_the_command_line_covers_that_adventure(db, monkeypatch):
|
||||
"""How to run the rewrite against a model other than the configured one."""
|
||||
adventure = make_adventure(db)
|
||||
_blank_model(db, adventure)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
built: list[tuple] = []
|
||||
|
||||
@@ -340,11 +326,22 @@ def test_an_api_key_on_the_command_line_covers_that_owner(db, monkeypatch):
|
||||
return StubSummarizer()
|
||||
|
||||
monkeypatch.setattr("app.providers.OpenAICompatibleProvider", build)
|
||||
assert run_tool(options(write=True, api_key="sk-cli", model="sonnet",
|
||||
assert run_tool(options(write=True, model="other-model",
|
||||
endpoint="http://127.0.0.1:8787/v1")) == 0
|
||||
db.expire_all()
|
||||
assert first.text == "Kaelen entered the crypt 1."
|
||||
assert built[0][:3] == ("http://127.0.0.1:8787/v1", "sk-cli", "sonnet")
|
||||
assert built[0][:2] == ("http://127.0.0.1:8787/v1", "other-model")
|
||||
|
||||
|
||||
def _blank_model(db, adventure) -> None:
|
||||
settings = (
|
||||
db.query(models.Settings)
|
||||
.filter(models.Settings.user_id == adventure.user_id)
|
||||
.first()
|
||||
)
|
||||
settings.model = ""
|
||||
settings.summary_model = ""
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_limit_stops_early(db, monkeypatch):
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Tests for the SSRF guard on the user-supplied BYOK endpoint_url.
|
||||
|
||||
python -m pytest tests/test_netguard.py -v
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import auth, netguard
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hosted(monkeypatch):
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
|
||||
|
||||
def _resolves_to(monkeypatch, ip: str):
|
||||
"""Pin `getaddrinfo` so the test exercises the address decision, not real DNS."""
|
||||
monkeypatch.setattr(
|
||||
netguard.socket, "getaddrinfo",
|
||||
lambda *a, **k: [(2, 1, 6, "", (ip, 443))],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", [
|
||||
"127.0.0.1", # loopback
|
||||
"169.254.169.254", # cloud metadata (link-local)
|
||||
"10.0.0.5", # RFC1918
|
||||
"192.168.1.1", # RFC1918
|
||||
"172.16.0.9", # RFC1918
|
||||
"0.0.0.0", # unspecified
|
||||
"100.64.0.1", # carrier-grade NAT
|
||||
"::1", # IPv6 loopback
|
||||
"fd00::1", # IPv6 unique-local
|
||||
])
|
||||
def test_blocks_non_public_addresses(hosted, monkeypatch, ip):
|
||||
_resolves_to(monkeypatch, ip)
|
||||
assert netguard.endpoint_block_reason("https://evil.example.com/v1") is not None
|
||||
|
||||
|
||||
def test_allows_public_address(hosted, monkeypatch):
|
||||
_resolves_to(monkeypatch, "104.18.0.1") # a public IP
|
||||
assert netguard.endpoint_block_reason("https://openrouter.ai/api/v1") is None
|
||||
|
||||
|
||||
def test_rejects_non_http_scheme(hosted):
|
||||
assert netguard.endpoint_block_reason("file:///etc/passwd") is not None
|
||||
assert netguard.endpoint_block_reason("gopher://x/") is not None
|
||||
|
||||
|
||||
def test_unresolvable_host_is_blocked(hosted, monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise netguard.socket.gaierror("no such host")
|
||||
monkeypatch.setattr(netguard.socket, "getaddrinfo", boom)
|
||||
assert netguard.endpoint_block_reason("https://nope.invalid/v1") is not None
|
||||
|
||||
|
||||
def test_noop_in_local_mode(monkeypatch):
|
||||
monkeypatch.setattr(auth, "MULTI_USER", False)
|
||||
# Local installs must reach localhost (Ollama). The guard never blocks local mode.
|
||||
assert netguard.endpoint_block_reason("http://localhost:11434/v1") is None
|
||||
assert netguard.endpoint_block_reason("http://127.0.0.1:11434/v1") is None
|
||||
@@ -275,7 +275,6 @@ def client(monkeypatch):
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -17,10 +17,10 @@ Three things must hold, and each is easy to undo by accident:
|
||||
* Moving a section out of the system block does not drop it from the
|
||||
token budget. It is still in the prompt.
|
||||
|
||||
This file also covers two request-level concerns: preferring one
|
||||
OpenRouter upstream, since each upstream holds its own cache, and reading
|
||||
back the usage the endpoint reports, so the hit rate is measurable rather
|
||||
than assumed.
|
||||
This file also covers one request-level concern: reading back the usage
|
||||
the endpoint reports, so the hit rate is measurable rather than assumed.
|
||||
It covered OpenRouter upstream pinning too, until M2 removed cloud
|
||||
provider support.
|
||||
|
||||
python -m pytest tests/test_prompt_caching.py -v
|
||||
"""
|
||||
@@ -40,50 +40,11 @@ SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------- preferring one upstream
|
||||
|
||||
def _routed(endpoint, model):
|
||||
provider = OpenAICompatibleProvider(endpoint, "k", model, "chat", 0)
|
||||
body = {"max_tokens": 100}
|
||||
provider._apply_provider_routing(body)
|
||||
return body
|
||||
|
||||
|
||||
def test_openrouter_deepseek_pins_the_upstream():
|
||||
"""Each upstream has its own cache, so routing has to be deterministic."""
|
||||
body = _routed("https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash-0731")
|
||||
assert body["provider"] == {"order": ["deepseek"]}
|
||||
|
||||
|
||||
def test_fallbacks_stay_on():
|
||||
"""A preference, not a restriction: if deepseek is down the turn still runs
|
||||
somewhere else and merely misses the cache."""
|
||||
body = _routed("https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash-0731")
|
||||
assert "allow_fallbacks" not in body["provider"]
|
||||
|
||||
|
||||
def test_non_openrouter_endpoints_get_no_provider_field():
|
||||
"""Ollama and other providers reject fields they do not know. This is
|
||||
the same problem the `reasoning` param works around."""
|
||||
body = _routed("http://localhost:11434/v1", "deepseek/deepseek-v4-flash-0731")
|
||||
assert "provider" not in body
|
||||
|
||||
|
||||
def test_unknown_vendors_are_left_alone():
|
||||
"""The vendor half of a slug is not reliably a provider slug: Google's
|
||||
models are served by "google-ai-studio", and there is no "google". A guess
|
||||
would be a routing preference naming an upstream that does not exist."""
|
||||
body = _routed("https://openrouter.ai/api/v1", "google/gemma-4-26b-a4b-it:free")
|
||||
assert "provider" not in body
|
||||
|
||||
|
||||
# ------------------------------------------------------ reading usage back
|
||||
|
||||
def test_usage_is_recorded_from_a_final_chunk():
|
||||
"""In a stream, the usage block arrives in a final chunk that carries
|
||||
no choices, which is why it is read separately from the text
|
||||
extraction."""
|
||||
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "k", "m")
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
|
||||
assert provider.last_usage is None
|
||||
provider._record_usage({"choices": [{"delta": {"content": "hi"}}]})
|
||||
assert provider.last_usage is None
|
||||
@@ -95,7 +56,7 @@ def test_usage_is_recorded_from_a_final_chunk():
|
||||
|
||||
|
||||
def test_a_later_chunk_without_usage_does_not_erase_it():
|
||||
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "k", "m")
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
|
||||
provider._record_usage({"usage": {"prompt_tokens": 5}})
|
||||
provider._record_usage({"choices": [{"delta": {"content": "x"}}]})
|
||||
provider._record_usage({"usage": {}})
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Regression tests for the X-Forwarded-For rate-limit bypass and the
|
||||
per-account login throttle added to close it.
|
||||
|
||||
Background: uvicorn's `--forwarded-allow-ips "*"` trusted the leftmost
|
||||
`X-Forwarded-For` entry. The client controls that entry, so rotating the
|
||||
header issued a fresh rate-limit bucket on every request. `client_ip` now
|
||||
reads the hop the trusted edge appends, which is the rightmost one. Login
|
||||
also has an email-keyed throttle that no IP trick can weaken.
|
||||
|
||||
python -m pytest tests/test_ratelimit_hardening.py -v
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import auth, limits
|
||||
|
||||
|
||||
class _Req:
|
||||
"""Minimal stand-in for starlette's Request: a header lookup and a peer."""
|
||||
|
||||
def __init__(self, xff: str | None, peer: str | None = "10.0.0.1"):
|
||||
self.headers = {} if xff is None else {"x-forwarded-for": xff}
|
||||
self.client = None if peer is None else type("C", (), {"host": peer})()
|
||||
|
||||
|
||||
# ---------- client_ip: the spoof-resistant hop ----------
|
||||
|
||||
def test_client_ip_takes_appended_rightmost_hop(monkeypatch):
|
||||
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
|
||||
# An attacker prepends a fake IP. The edge appends the real one on the right.
|
||||
req = _Req("203.0.113.9, 198.51.100.77")
|
||||
assert limits.client_ip(req) == "198.51.100.77"
|
||||
|
||||
|
||||
def test_client_ip_ignores_spoofed_leftmost(monkeypatch):
|
||||
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
|
||||
# The keyed IP stays the real hop regardless of what the client adds on
|
||||
# the left, so rotating that value no longer creates a new bucket.
|
||||
a = limits.client_ip(_Req("1.1.1.1, 198.51.100.77"))
|
||||
b = limits.client_ip(_Req("2.2.2.2, 198.51.100.77"))
|
||||
c = limits.client_ip(_Req("evil, junk, 198.51.100.77"))
|
||||
assert a == b == c == "198.51.100.77"
|
||||
|
||||
|
||||
def test_client_ip_honours_extra_trusted_hops(monkeypatch):
|
||||
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 2)
|
||||
# Two trusted hops: real client is second from the right.
|
||||
req = _Req("9.9.9.9, 203.0.113.5, 198.51.100.77")
|
||||
assert limits.client_ip(req) == "203.0.113.5"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_to_socket_peer():
|
||||
assert limits.client_ip(_Req(None, peer="172.16.0.4")) == "172.16.0.4"
|
||||
assert limits.client_ip(_Req(None, peer=None)) == "unknown"
|
||||
|
||||
|
||||
# ---------- per-account login throttle ----------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _multi_user(monkeypatch):
|
||||
monkeypatch.setattr(auth, "MULTI_USER", True)
|
||||
# Isolate the module-level failure map for each test.
|
||||
from collections import defaultdict, deque
|
||||
monkeypatch.setattr(limits, "_login_fails", defaultdict(deque))
|
||||
|
||||
|
||||
def test_login_throttle_blocks_after_limit():
|
||||
email = "victim@example.com"
|
||||
# Each attempt up to the limit is allowed and recorded as a failure.
|
||||
for _ in range(limits.LOGIN_FAIL_LIMIT):
|
||||
limits.check_login_allowed(email) # does not raise
|
||||
limits.note_login_failure(email)
|
||||
# One more failure exceeds the limit.
|
||||
with pytest.raises(limits.HTTPException) as exc:
|
||||
limits.check_login_allowed(email)
|
||||
assert exc.value.status_code == 429
|
||||
|
||||
|
||||
def test_login_throttle_is_per_account():
|
||||
for _ in range(limits.LOGIN_FAIL_LIMIT):
|
||||
limits.note_login_failure("a@example.com")
|
||||
with pytest.raises(limits.HTTPException):
|
||||
limits.check_login_allowed("a@example.com")
|
||||
# A different account is unaffected because the throttle keys on email, not IP.
|
||||
limits.check_login_allowed("b@example.com") # must not raise
|
||||
|
||||
|
||||
def test_successful_login_clears_the_streak():
|
||||
email = "typo@example.com"
|
||||
for _ in range(limits.LOGIN_FAIL_LIMIT):
|
||||
limits.note_login_failure(email)
|
||||
limits.note_login_success(email)
|
||||
limits.check_login_allowed(email) # failure streak cleared, must not raise
|
||||
|
||||
|
||||
def test_throttle_is_noop_in_local_mode(monkeypatch):
|
||||
monkeypatch.setattr(auth, "MULTI_USER", False)
|
||||
for _ in range(limits.LOGIN_FAIL_LIMIT * 3):
|
||||
limits.note_login_failure("solo@example.com")
|
||||
limits.check_login_allowed("solo@example.com") # never throttled locally
|
||||
@@ -1,45 +0,0 @@
|
||||
"""What the provider puts in the `reasoning` request field for each budget
|
||||
setting: a positive budget asks for thinking, 0 stays silent, -1 turns it off.
|
||||
|
||||
python -m pytest tests/test_reasoning_param.py -v
|
||||
"""
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
|
||||
def _body(reasoning_max_tokens, api_mode="chat", max_tokens=1000):
|
||||
provider = OpenAICompatibleProvider(
|
||||
"https://openrouter.ai/api/v1", "k", "deepseek/deepseek-v4-flash-0731",
|
||||
api_mode, reasoning_max_tokens,
|
||||
)
|
||||
body = {"max_tokens": max_tokens}
|
||||
provider._apply_reasoning_budget(body)
|
||||
return body
|
||||
|
||||
|
||||
def test_zero_sends_nothing():
|
||||
"""Ollama and other providers reject unknown fields. Sending 0 must not add a `reasoning` field."""
|
||||
assert "reasoning" not in _body(0)
|
||||
|
||||
|
||||
def test_positive_budget_adds_thinking_tokens():
|
||||
body = _body(500)
|
||||
assert body["reasoning"] == {"max_tokens": 500}
|
||||
# the story output keeps its own full budget on top of the thinking budget
|
||||
assert body["max_tokens"] == 1500
|
||||
|
||||
|
||||
def test_negative_turns_reasoning_off():
|
||||
body = _body(-1)
|
||||
assert body["reasoning"] == {"effort": "none"}
|
||||
# "off" must not inflate the output budget
|
||||
assert body["max_tokens"] == 1000
|
||||
|
||||
|
||||
def test_off_is_not_merely_excluded():
|
||||
"""`exclude: true` still generates and bills for reasoning tokens. The off setting must omit the field entirely instead of relying on `exclude`."""
|
||||
assert _body(-1)["reasoning"].get("exclude") is None
|
||||
|
||||
|
||||
def test_completion_mode_never_sends_reasoning():
|
||||
for budget in (-1, 0, 500):
|
||||
assert "reasoning" not in _body(budget, api_mode="completion")
|
||||
@@ -14,18 +14,11 @@ from app.main import app
|
||||
from app.providers import ProviderError
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
# Each turn spends 10 gold, so a double-applied or un-rolled-back attempt shows.
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -41,14 +34,11 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
script_state={}, world_state={"player": {"hp": 100}},
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
@@ -57,9 +47,6 @@ def client(monkeypatch):
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -80,7 +67,7 @@ def _adv(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return adv.script_state, adv.world_state
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -177,15 +164,15 @@ def test_three_attempts_all_kept_in_order(client):
|
||||
|
||||
def test_switching_back_restores_that_attempt_state(client):
|
||||
ScriptedProvider.replies = [
|
||||
"You take a scratch.\n```state\n{\"player.hp\": -5}\n```",
|
||||
"You take a beating.\n```state\n{\"player.hp\": -40}\n```",
|
||||
'You take a scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
|
||||
'You take a beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
|
||||
]
|
||||
_play(client)
|
||||
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
|
||||
_retry(client)
|
||||
script_state, world_state = _adv(client.adv_id)
|
||||
player, world_state = _adv(client.adv_id)
|
||||
assert world_state["player"]["hp"] == 60
|
||||
assert script_state == {"gold": 10} # rolled back, not stacked to 20
|
||||
assert player["gold"] == 10 # rolled back, not stacked to 20
|
||||
|
||||
last = _actions(client)[-1]
|
||||
r = client.post(
|
||||
@@ -194,9 +181,9 @@ def test_switching_back_restores_that_attempt_state(client):
|
||||
assert r.json()["text"].startswith("You take a scratch")
|
||||
assert r.json()["take_index"] == 0
|
||||
# The stats follow the narration back.
|
||||
script_state, world_state = _adv(client.adv_id)
|
||||
player, world_state = _adv(client.adv_id)
|
||||
assert world_state["player"]["hp"] == 95
|
||||
assert script_state == {"gold": 10}
|
||||
assert player["gold"] == 10
|
||||
|
||||
# And forward again.
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||
@@ -251,26 +238,26 @@ def test_switching_to_a_missing_index_is_rejected(client):
|
||||
def test_failed_retry_leaves_the_previous_attempt_in_charge(client):
|
||||
"""A provider error mid-retry must undo the rollback, or the stats on
|
||||
screen would silently disagree with the text still shown."""
|
||||
ScriptedProvider.replies = ["Attempt one.", ProviderError("upstream is down")]
|
||||
ScriptedProvider.replies = [gold_reply("Attempt one."), ProviderError("upstream is down")]
|
||||
_play(client)
|
||||
assert _adv(client.adv_id)[0] == {"gold": 10}
|
||||
assert _adv(client.adv_id)[0]["gold"] == 10
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
|
||||
actions = _actions(client)
|
||||
assert actions[-1]["text"] == "Attempt one." # text never lost
|
||||
assert _adv(client.adv_id)[0] == {"gold": 10} # and the state still matches it
|
||||
assert _adv(client.adv_id)[0]["gold"] == 10 # and the state still matches it
|
||||
|
||||
|
||||
def test_undo_removes_the_action_and_its_history(client):
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
# Undo returns the newest window now, not the whole story.
|
||||
assert [a["type"] for a in r.json()["actions"]] == ["start"]
|
||||
assert _adv(client.adv_id)[0] == {}
|
||||
assert _adv(client.adv_id)[0]["gold"] == 0
|
||||
|
||||
|
||||
def test_editing_the_text_updates_the_live_variant(client):
|
||||
|
||||
@@ -125,7 +125,6 @@ def client(monkeypatch):
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -34,7 +34,6 @@ def client(monkeypatch):
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""Tests for undo and retry rolling back the shared `script_state`
|
||||
"""Tests for undo and retry rolling back the shared `world_state`
|
||||
(plan/11-state-revert-and-retry-fix.md).
|
||||
|
||||
The state being rolled back was the scripting engine's `script_state` until
|
||||
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
|
||||
`roll_back_before`, and the per-node outcome snapshot — is unchanged; only the
|
||||
column it moves has. `world_state`/`world_state_after` is now the shared state
|
||||
an adventure carries, so that is what these tests exercise.
|
||||
|
||||
Phase 14 SP4 reversed the snapshots. An action used to carry the state as
|
||||
it stood before it ran, and rolling back read the snapshot off the action
|
||||
being removed. Now it carries the state it left behind, and rolling back
|
||||
@@ -36,11 +42,11 @@ def db():
|
||||
adventures.turns._active_turns.clear()
|
||||
|
||||
|
||||
def _make_adventure(db, script_state):
|
||||
def _make_adventure(db, world_state):
|
||||
user = models.User(is_guest=False)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
adv = models.Adventure(user_id=user.id, title="T", script_state=script_state)
|
||||
adv = models.Adventure(user_id=user.id, title="T", world_state=world_state)
|
||||
db.add(adv)
|
||||
db.flush()
|
||||
return user, adv
|
||||
@@ -49,7 +55,7 @@ def _make_adventure(db, script_state):
|
||||
def _add(db, adv, index, type_, text="x", state_after=None):
|
||||
a = models.Action(
|
||||
adventure_id=adv.id, type=type_, text=text,
|
||||
state_after=state_after,
|
||||
world_state_after=state_after,
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
@@ -72,7 +78,7 @@ def _forget_snapshots(db, adv):
|
||||
# ---------------------------------------------------------------- undo
|
||||
|
||||
def test_undo_reverts_state_to_before_the_turn(db):
|
||||
# A turn moved script_state from {gold:0} to {gold:10}. The node in
|
||||
# A turn moved world_state from {gold:0} to {gold:10}. The node in
|
||||
# front of the turn records where it started. The current state is
|
||||
# the mutated one.
|
||||
user, adv = _make_adventure(db, {"gold": 10})
|
||||
@@ -83,7 +89,7 @@ def test_undo_reverts_state_to_before_the_turn(db):
|
||||
|
||||
adventures.undo_turn(adv.id, db=db, adventure=adv)
|
||||
|
||||
assert adv.script_state == {"gold": 0}
|
||||
assert adv.world_state == {"gold": 0}
|
||||
assert [a.type for a in adv.actions] == ["start"]
|
||||
|
||||
|
||||
@@ -97,7 +103,7 @@ def test_undo_of_bare_continue_uses_the_node_in_front(db):
|
||||
|
||||
adventures.undo_turn(adv.id, db=db, adventure=adv)
|
||||
|
||||
assert adv.script_state == {"gold": 0}
|
||||
assert adv.world_state == {"gold": 0}
|
||||
assert [a.type for a in adv.actions] == ["start"]
|
||||
|
||||
|
||||
@@ -112,7 +118,7 @@ def test_undo_leaves_state_untouched_when_snapshot_missing(db):
|
||||
|
||||
adventures.undo_turn(adv.id, db=db, adventure=adv)
|
||||
|
||||
assert adv.script_state == {"gold": 10}
|
||||
assert adv.world_state == {"gold": 10}
|
||||
|
||||
|
||||
def test_undo_raises_when_nothing_to_undo(db):
|
||||
@@ -188,24 +194,24 @@ def test_snapshot_outcome_is_an_independent_deep_copy(db):
|
||||
_, adv = _make_adventure(db, {"nested": {"n": 1}})
|
||||
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
||||
attempts.snapshot_outcome(adv, node)
|
||||
adv.script_state["nested"]["n"] = 99
|
||||
assert node.state_after == {"nested": {"n": 1}} # unaffected by later mutation
|
||||
adv.world_state["nested"]["n"] = 99
|
||||
assert node.world_state_after == {"nested": {"n": 1}} # unaffected by later mutation
|
||||
|
||||
|
||||
def test_snapshot_outcome_handles_non_dict(db):
|
||||
_, adv = _make_adventure(db, {})
|
||||
adv.script_state = None
|
||||
adv.world_state = None
|
||||
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
||||
attempts.snapshot_outcome(adv, node)
|
||||
assert node.state_after == {}
|
||||
assert node.world_state_after == {}
|
||||
|
||||
|
||||
def test_restore_state_ignores_a_node_with_no_outcome(db):
|
||||
_, adv = _make_adventure(db, {"gold": 7})
|
||||
attempts.restore_state(adv, models.Action(adventure_id=adv.id, type="ai"))
|
||||
assert adv.script_state == {"gold": 7}
|
||||
assert adv.world_state == {"gold": 7}
|
||||
attempts.restore_state(adv, None)
|
||||
assert adv.script_state == {"gold": 7}
|
||||
assert adv.world_state == {"gold": 7}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- retry
|
||||
@@ -220,8 +226,6 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
|
||||
_add(db, adv, 2, "ai", state_after={"gold": 20})
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(adventures.limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(adventures.turns, "check_demo_cap", lambda *a, **k: None)
|
||||
|
||||
async def _noop(*a, **k):
|
||||
if False:
|
||||
@@ -230,12 +234,12 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
|
||||
|
||||
adventures.retry_action(adv.id, request=None, db=db, user=user, adventure=adv)
|
||||
|
||||
assert adv.script_state == {"gold": 10}
|
||||
assert adv.world_state == {"gold": 10}
|
||||
# Nothing is written until a replacement actually arrives: the attempt on
|
||||
# screen is left exactly as it was, and stays the live one.
|
||||
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
|
||||
last = adv.actions[-1]
|
||||
assert last.live is True
|
||||
assert len(attempts.group(db, last)) == 1 # no sibling was filed
|
||||
assert last.state_after == {"gold": 20} # its own outcome, untouched
|
||||
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
|
||||
adventures.turns._active_turns.discard(adv.id)
|
||||
|
||||
@@ -26,20 +26,13 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
# A world-state schema, so the RPG layer is exercised rather than skipped.
|
||||
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
# Ten gold a turn. A turn that double-applies its hooks, or one that fails to
|
||||
# roll back on retry, shows up here as a wrong total rather than as nothing.
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
|
||||
OPENING = "You enter a cave."
|
||||
|
||||
@@ -59,7 +52,7 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
script_state={}, world_state={"player": {"hp": 100}},
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
@@ -70,20 +63,14 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0):
|
||||
type="ai" if i % 2 else "do",
|
||||
text=f"Seeded turn {i}.",
|
||||
))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = ["A reply."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["A reply."]]
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -145,7 +132,7 @@ def _state(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return adv.script_state, adv.world_state
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -172,7 +159,7 @@ def test_short_adventure_returns_everything(client):
|
||||
# --------------------------------------------------------------- the turn
|
||||
|
||||
def test_a_turn_appends_the_player_action_then_the_ai_action(client):
|
||||
ScriptedProvider.replies = ["The dark presses in."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["The dark presses in."]]
|
||||
_play(client, "light a torch")
|
||||
actions = _actions(client)
|
||||
assert [a["type"] for a in actions] == ["start", "do", "ai"]
|
||||
@@ -181,7 +168,7 @@ def test_a_turn_appends_the_player_action_then_the_ai_action(client):
|
||||
|
||||
|
||||
def test_say_and_story_and_continue_all_work(client):
|
||||
ScriptedProvider.replies = ["One.", "Two.", "Three."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two.", "Three."]]
|
||||
_play(client, "hello", type="say")
|
||||
_play(client, "The wind rises.", type="story")
|
||||
_play(client, "", type="continue")
|
||||
@@ -190,7 +177,7 @@ def test_say_and_story_and_continue_all_work(client):
|
||||
|
||||
|
||||
def test_the_story_so_far_is_replayed_into_the_prompt(client):
|
||||
ScriptedProvider.replies = ["First.", "Second."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["First.", "Second."]]
|
||||
_play(client, "go north")
|
||||
_play(client, "go south")
|
||||
story = ScriptedProvider.prompts[-1][1]
|
||||
@@ -201,7 +188,7 @@ def test_the_story_so_far_is_replayed_into_the_prompt(client):
|
||||
|
||||
def test_scripts_run_once_per_turn(client):
|
||||
"""The gold script adds ten a turn. Two turns is twenty — not forty."""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
|
||||
_play(client)
|
||||
_play(client)
|
||||
script_state, _ = _state(client.adv_id)
|
||||
@@ -211,7 +198,7 @@ def test_scripts_run_once_per_turn(client):
|
||||
# ------------------------------------------------------------------ retry
|
||||
|
||||
def test_retry_replaces_the_text_and_keeps_the_attempt(client):
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
|
||||
_play(client)
|
||||
assert _texts(client)[-1] == "Attempt one."
|
||||
|
||||
@@ -233,7 +220,7 @@ def test_retry_replaces_the_text_and_keeps_the_attempt(client):
|
||||
def test_retry_does_not_stack_script_effects(client):
|
||||
"""The discarded attempt's ten gold is rolled back, so one turn plus one
|
||||
retry is still ten, not twenty."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
|
||||
_play(client)
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
script_state, _ = _state(client.adv_id)
|
||||
@@ -241,7 +228,7 @@ def test_retry_does_not_stack_script_effects(client):
|
||||
|
||||
|
||||
def test_switching_back_to_an_earlier_attempt_restores_it(client):
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
|
||||
_play(client)
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
action_id = _actions(client)[-1]["id"]
|
||||
@@ -259,7 +246,7 @@ def test_switching_back_to_an_earlier_attempt_restores_it(client):
|
||||
def test_only_the_newest_turn_can_be_switched(client):
|
||||
"""An older turn's alternatives stay readable but not selectable. The
|
||||
story after it continues from what is live."""
|
||||
ScriptedProvider.replies = ["One.", "Again.", "Two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Again.", "Two."]]
|
||||
_play(client)
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
older_id = _actions(client)[-1]["id"]
|
||||
@@ -274,7 +261,7 @@ def test_only_the_newest_turn_can_be_switched(client):
|
||||
# ------------------------------------------------------------------- undo
|
||||
|
||||
def test_undo_removes_the_whole_turn_and_rolls_state_back(client):
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
|
||||
_play(client, "go north")
|
||||
_play(client, "go south")
|
||||
assert len(_actions(client)) == 5
|
||||
@@ -365,7 +352,7 @@ def test_paging_past_the_start_reports_the_end(long_client):
|
||||
# ------------------------------------------------------- editing and deleting
|
||||
|
||||
def test_editing_an_action_sticks(client):
|
||||
ScriptedProvider.replies = ["Original."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["Original."]]
|
||||
_play(client)
|
||||
action_id = _actions(client)[-1]["id"]
|
||||
r = client.patch(f"/api/adventures/{client.adv_id}/actions/{action_id}",
|
||||
@@ -380,7 +367,7 @@ def test_editing_an_action_sticks(client):
|
||||
def test_editing_a_retried_action_survives_a_reload(client):
|
||||
"""The edit has to reach the live attempt too, or paging away and back
|
||||
reverts it."""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
|
||||
_play(client)
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
action_id = _actions(client)[-1]["id"]
|
||||
@@ -390,7 +377,7 @@ def test_editing_a_retried_action_survives_a_reload(client):
|
||||
|
||||
|
||||
def test_deleting_an_action_removes_it(client):
|
||||
ScriptedProvider.replies = ["One."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One."]]
|
||||
_play(client)
|
||||
action_id = _actions(client)[-1]["id"]
|
||||
r = client.delete(f"/api/adventures/{client.adv_id}/actions/{action_id}")
|
||||
@@ -431,7 +418,7 @@ def test_export_carries_the_whole_story(client):
|
||||
same fact: a bundle that stores coordinates has no use for a repeating
|
||||
group. Everything else here still passes unmodified.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
|
||||
_play(client, "go north")
|
||||
_play(client, "go south")
|
||||
|
||||
@@ -446,7 +433,7 @@ def test_export_carries_the_whole_story(client):
|
||||
|
||||
|
||||
def test_export_round_trips_through_import(client):
|
||||
ScriptedProvider.replies = ["One."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One."]]
|
||||
_play(client, "go north")
|
||||
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
|
||||
|
||||
@@ -461,7 +448,7 @@ def test_export_round_trips_through_import(client):
|
||||
|
||||
|
||||
def test_export_keeps_retry_attempts(client):
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
|
||||
_play(client)
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
|
||||
@@ -474,7 +461,7 @@ def test_export_keeps_retry_attempts(client):
|
||||
# -------------------------------------------------------------- world state
|
||||
|
||||
def test_world_state_is_readable_and_survives_a_turn(client):
|
||||
ScriptedProvider.replies = ["Nothing changes."]
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["Nothing changes."]]
|
||||
_play(client)
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@@ -57,9 +57,6 @@ def client(monkeypatch):
|
||||
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -58,12 +58,6 @@ def client(monkeypatch):
|
||||
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"resolve_provider_config",
|
||||
lambda s: auth.ProviderConfig("http://fake", "k", "test-model", False),
|
||||
)
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -24,20 +24,14 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
# Ten gold a turn, every turn. A number that only ever increases makes a
|
||||
# rollback failure obvious: if a take stacks instead of replacing, the gold
|
||||
# total is off by exactly one turn's worth.
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
# Ten gold a turn, every turn, banked through the world-state engine. A number
|
||||
# that only ever increases makes a rollback failure obvious: if a take stacks
|
||||
# instead of replacing, the gold total is off by exactly one turn's worth. See
|
||||
# `fakes.gold_reply` for why this is not a script any more.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -53,24 +47,18 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Vault", scenario_id=scenario.id,
|
||||
script_state={}, world_state={"player": {"hp": 100}},
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
|
||||
ScriptedProvider.replies = gold_replies("Take", 39)
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -107,7 +95,7 @@ def _take(client, action_id, text=""):
|
||||
def _gold(adv_id) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (db.get(models.Adventure, adv_id).script_state or {}).get("gold", 0)
|
||||
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {}).get("gold", 0)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -397,7 +397,6 @@ def client(monkeypatch):
|
||||
user_id, scenario_id = user.id, scenario.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""End-to-end HTTP tests for undo and retry state revert. These tests drive
|
||||
real turns through the actual routes and scripting engine, with only the LLM
|
||||
provider mocked.
|
||||
real turns through the actual routes and the world-state engine, with only the
|
||||
LLM provider mocked.
|
||||
|
||||
A script's output hook adds 10 gold each turn. These tests confirm that the
|
||||
adventure's stored gold total stays correct across play, undo, and retry.
|
||||
The model banks 10 gold each turn through its ```state delta block. These tests
|
||||
confirm that the adventure's stored gold total stays correct across play, undo,
|
||||
and retry. It was a JavaScript output hook that added the gold until M2 removed
|
||||
campaign scripting; the state machinery under test is the same either way.
|
||||
|
||||
python -m pytest tests/test_turn_flow_integration.py -v
|
||||
"""
|
||||
@@ -16,15 +18,8 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
GOLD_SCRIPT = """
|
||||
const modifier = (text) => {
|
||||
state.gold = (state.gold || 0) + 10;
|
||||
return { text };
|
||||
};
|
||||
modifier(text);
|
||||
"""
|
||||
|
||||
|
||||
AI_REPLY = "The torch flickers as you press onward."
|
||||
@@ -38,24 +33,21 @@ def client(monkeypatch):
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
adv = models.Adventure(user_id=user.id, title="Cave", script_state={})
|
||||
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA)
|
||||
setup.add(scenario)
|
||||
setup.flush()
|
||||
adv = models.Adventure(user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
world_state={"player": {"hp": 100, "gold": 0}})
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
|
||||
setup.add(models.AdventureScript(
|
||||
adventure_id=adv.id, position=0, enabled=True, name="Gold",
|
||||
output_js=GOLD_SCRIPT,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
# Force a real, non-demo turn that uses the fake provider.
|
||||
ScriptedProvider.replies = [AI_REPLY]
|
||||
ScriptedProvider.replies = [gold_reply(AI_REPLY)]
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
@@ -73,10 +65,13 @@ def client(monkeypatch):
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def _state(adv_id):
|
||||
def _gold(adv_id) -> int:
|
||||
"""The banked total. Reading one stat rather than the whole section keeps
|
||||
these assertions about the rollback, not about the schema's shape."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.get(models.Adventure, adv_id).script_state
|
||||
player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
|
||||
return player.get("gold", 0)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -88,38 +83,38 @@ def _play(client, type_="do", text="look around"):
|
||||
|
||||
|
||||
def test_play_then_undo_reverts_gold(client):
|
||||
assert _state(client.adv_id) == {}
|
||||
assert _gold(client.adv_id) == 0
|
||||
_play(client)
|
||||
assert _state(client.adv_id) == {"gold": 10}
|
||||
assert _gold(client.adv_id) == 10
|
||||
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _state(client.adv_id) == {} # gold reverted to zero
|
||||
assert _gold(client.adv_id) == 0 # gold reverted to zero
|
||||
|
||||
|
||||
def test_two_turns_then_undo_reverts_only_last(client):
|
||||
_play(client)
|
||||
_play(client)
|
||||
assert _state(client.adv_id) == {"gold": 20}
|
||||
assert _gold(client.adv_id) == 20
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert _state(client.adv_id) == {"gold": 10} # back to after turn 1, not 0
|
||||
assert _gold(client.adv_id) == 10 # back to after turn 1, not 0
|
||||
|
||||
|
||||
def test_retry_does_not_double_apply_gold(client):
|
||||
_play(client)
|
||||
assert _state(client.adv_id) == {"gold": 10}
|
||||
assert _gold(client.adv_id) == 10
|
||||
|
||||
# Before the fix, this produced 20 because the output hook ran twice.
|
||||
# Before the fix, this produced 20 because the turn's effects ran twice.
|
||||
# Now it stays 10.
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _state(client.adv_id) == {"gold": 10}
|
||||
assert _gold(client.adv_id) == 10
|
||||
|
||||
|
||||
def test_retry_then_undo_still_clean(client):
|
||||
_play(client)
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert _state(client.adv_id) == {"gold": 10}
|
||||
assert _gold(client.adv_id) == 10
|
||||
client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert _state(client.adv_id) == {}
|
||||
assert _gold(client.adv_id) == 0
|
||||
|
||||
@@ -63,9 +63,6 @@ def client(monkeypatch):
|
||||
|
||||
ScriptedProvider.replies = [AI_REPLY]
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
|
||||
Reference in New Issue
Block a user