Files
bullpen/tests/test_mneme_server.py

659 lines
26 KiB
Python

"""Executable contract for mneme v1.1 — src/mneme/server.py (+ store.py v1.1 notes).
This suite IS the spec. It must FAIL until server.py exists and store.py is
upgraded to v1.1. Do NOT weaken a test to make it pass.
Pinned server contract (stdlib only, mirror bullseye/server.py house style):
python3 -m mneme.server --port P --db PATH --drain-interval SECS
- binds 127.0.0.1 only (like bullseye), ThreadingHTTPServer.
- --db default /var/lib/mneme/mneme.db (tests always pass a tmp path).
- --drain-interval float seconds, default 60. A background thread
drains the write_queue every interval. It must wait on a
threading.Event (interruptible), NOT a busy-poll / bare sleep spin:
SIGTERM must (a) run ONE final drain and (b) exit promptly even when
the interval is huge (test_sigterm_final_drain enforces both).
- drains never overlap (serialize with a lock or single thread).
Auth (bullseye _post_token/_authorized pattern):
- token from env MNEME_TOKEN, else first non-empty non-comment line of
~/.config/mneme/token. None configured => writes DISABLED fail-closed.
- POST /save: no token configured -> 503; wrong/absent Bearer -> 401
(+ WWW-Authenticate: Bearer); good Bearer -> 200.
- comparison via hmac.compare_digest (constant time).
- GET endpoints require no auth (reads stay open).
Endpoints (JSON in/out, charset utf-8):
POST /save {"ns": str, "text": str} -> 200 {"id": int}.
Queue-append ONLY (Store.save); must NOT drain/index
synchronously. Content-Type must be application/json
(else 415); malformed JSON -> 400; declared
Content-Length > 1 MiB -> 413 before reading body.
GET /query?q=&k= -> 200 JSON array of {"ns","id","text","score"},
BM25 best-first. Hostile FTS5 metacharacters in q,
or garbage k, must never yield a 5xx.
GET /namespaces -> 200 JSON object {ns: {"count","last"}}.
GET /recent?n= -> 200 JSON array (newest first).
GET /keywords?ns= -> 200 JSON array of strings.
GET /similar?to=<id> -> 200 JSON array (related drained rows).
GET /status -> 200 {"degraded": bool, "queue_depth": int,
"queue_age": float|null}.
GET /healthz -> 200, cheap liveness: opens NO DB connection,
performs NO DB write (db/-wal bytes must not change).
Pinned store.py v1.1 notes (source-level, deliberate):
- drain() inserts with plain INSERT (never INSERT OR REPLACE): if two
drains ever overlap, the second must ERROR, not silently re-write rows.
- memories_fts declares id and ns as UNINDEXED: namespace/id tokens must
not be matchable (behavioural twin: test_query_does_not_match_ns_tokens).
- every per-batch connection is explicitly closed ("with sqlite3.connect"
commits but does NOT close).
Run: python3 -m pytest tests/test_mneme_server.py -q
Grinder-sandbox notes (bwrap --unshare-all): the fresh netns starts with 'lo'
DOWN — a session fixture raises it via SIOCSIFFLAGS ioctl (userns grants
CAP_NET_ADMIN, no `ip` binary needed); if that fails, every HTTP test fails
immediately instead of eating its startup timeout. The suite watchdog aborts
at 110 s so it always beats the grinder's own <=150 s kill.
"""
import faulthandler
import hashlib
import http.client
import json
import os
import re
import signal
import socket
import sqlite3
import subprocess
import sys
import threading
import time
import urllib.parse
from pathlib import Path
import pytest
# abspath, NOT resolve(): a sandboxed working copy may reach here via symlink,
# and resolve() would escape it back to the original checkout.
ROOT = Path(os.path.abspath(__file__)).parents[1]
SRC = ROOT / "src"
STORE_PY = SRC / "mneme" / "store.py"
SERVER_PY = SRC / "mneme" / "server.py"
TOKEN = "sesame-t0ken-42"
LONG = "3600" # drain interval that never fires during a test
SHORT = "0.2" # drain interval for background-drain tests
HTTP_TIMEOUT = 5.0 # socket timeout on every HTTP call
STARTUP_DEADLINE = 6.0 # server must accept a connection within this
STOP_GRACE = 5.0 # SIGTERM grace before the group gets SIGKILL
SUITE_BUDGET = 110.0 # hard wall-clock cap: must beat the grinder's
# own <=150s kill, or the grinder SIGKILLs pytest
# mid-flight and reports a crash instead of failures
# Every live server's process-group id, so the watchdog can nuke them all.
_live_pgids = set()
_live_lock = threading.Lock()
def _killpg(pgid, sig):
try:
os.killpg(pgid, sig)
except (ProcessLookupError, PermissionError):
pass
# ----------------------------------------------------------------------
# loopback bring-up: the grinder sandbox (bwrap --unshare-all) starts a
# fresh netns whose 'lo' is DOWN, so every 127.0.0.1 connect fails until
# it is raised. Inside the user namespace we hold CAP_NET_ADMIN, so a
# plain SIOCSIFFLAGS ioctl works — no `ip` binary needed.
# ----------------------------------------------------------------------
IFF_UP = 0x1
SIOCGIFFLAGS = 0x8913
SIOCSIFFLAGS = 0x8914
# Non-None => loopback is unusable; Server() fails each HTTP test fast
# with this message instead of burning STARTUP_DEADLINE per test.
_LO_ERROR = None
def _ensure_loopback_up():
"""Bring 'lo' UP if it is down (no-op when already up). Returns None on
success, else a string describing why loopback is unusable."""
import fcntl
import struct
pad = b"\0" * 22 # struct ifreq is 40 bytes; flags live at offset 16
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
except OSError as e:
return "cannot open AF_INET socket: %s" % e
try:
req = struct.pack("16sH", b"lo", 0) + pad
try:
flags = struct.unpack_from("16sH", fcntl.ioctl(s, SIOCGIFFLAGS, req))[1]
except OSError as e:
return "SIOCGIFFLAGS(lo) failed: %s" % e
if not flags & IFF_UP:
try:
fcntl.ioctl(s, SIOCSIFFLAGS,
struct.pack("16sH", b"lo", flags | IFF_UP) + pad)
except OSError as e:
return "SIOCSIFFLAGS could not bring 'lo' UP: %s" % e
flags = struct.unpack_from("16sH", fcntl.ioctl(s, SIOCGIFFLAGS, req))[1]
if not flags & IFF_UP:
return "IFF_UP did not stick on 'lo'"
finally:
s.close()
# end-to-end proof, cheap (~ms): bind + accept-connect over loopback
try:
with socket.socket() as srv:
srv.bind(("127.0.0.1", 0))
srv.listen(1)
with socket.create_connection(srv.getsockname(), timeout=2.0):
pass
except OSError as e:
return "loopback self-check (bind+connect 127.0.0.1) failed: %s" % e
return None
@pytest.fixture(scope="session", autouse=True)
def _loopback_up():
global _LO_ERROR
_LO_ERROR = _ensure_loopback_up()
yield
@pytest.fixture(scope="session", autouse=True)
def _suite_watchdog():
"""Last-resort guarantee the grinder never hangs: if the suite exceeds
SUITE_BUDGET, dump all thread stacks, SIGKILL every server process group,
and hard-exit. Individual waits below are all bounded; this only fires if
something truly unforeseen wedges."""
cancel = threading.Event()
def guard():
if not cancel.wait(SUITE_BUDGET):
msg = ("\n*** test_mneme_server watchdog: suite exceeded %.0fs — "
"killing servers, aborting (exit 70) ***\n" % SUITE_BUDGET)
# pytest's fd-capture swallows stderr when we os._exit, so also
# leave a postmortem in the working dir the runner preserves.
streams = [sys.stderr]
try:
streams.append(open("test_mneme_server.watchdog.log", "w"))
except OSError:
pass
for f in streams:
try:
f.write(msg)
faulthandler.dump_traceback(file=f)
f.flush()
except Exception:
pass
with _live_lock:
pgids = list(_live_pgids)
for pg in pgids:
_killpg(pg, signal.SIGKILL)
os._exit(70)
t = threading.Thread(target=guard, daemon=True)
t.start()
yield
cancel.set()
def _free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Server:
"""Launch `python3 -m mneme.server` black-box; context manager kills it."""
def __init__(self, tmp, *, token=TOKEN, token_file=None, interval=LONG):
if _LO_ERROR:
pytest.fail("loopback unusable, HTTP tests cannot run: %s"
% _LO_ERROR, pytrace=False)
self.tmp = Path(tmp)
self.db = str(self.tmp / "mneme.db")
self.port = _free_port()
self.home = self.tmp / "home"
self.home.mkdir(exist_ok=True)
if token_file is not None:
cfg = self.home / ".config" / "mneme"
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "token").write_text(token_file)
env = os.environ.copy()
env.pop("MNEME_TOKEN", None)
env["HOME"] = str(self.home) # so a real ~/.config token never leaks in
env["PYTHONPATH"] = str(SRC) + os.pathsep + env.get("PYTHONPATH", "")
if token is not None:
env["MNEME_TOKEN"] = token
self.stderr_path = self.tmp / "server.stderr"
self._stderr_f = open(self.stderr_path, "wb")
# start_new_session: the server becomes its own process group leader,
# so teardown can killpg() it and any children it forks.
self.proc = subprocess.Popen(
[sys.executable, "-m", "mneme.server",
"--port", str(self.port), "--db", self.db,
"--drain-interval", interval],
env=env, stdout=subprocess.DEVNULL, stderr=self._stderr_f,
start_new_session=True,
)
self.pgid = self.proc.pid
with _live_lock:
_live_pgids.add(self.pgid)
try:
self._wait_up()
except BaseException:
self.stop()
raise
def _wait_up(self, deadline=STARTUP_DEADLINE):
"""Bounded startup wait: raises RuntimeError (after killing the
process group) if the server neither accepts nor exits in time."""
t0 = time.monotonic()
while time.monotonic() - t0 < deadline:
if self.proc.poll() is not None:
break
try:
with socket.create_connection(("127.0.0.1", self.port), 0.2):
return
except OSError:
time.sleep(0.05)
rc = self.proc.poll()
self.stop()
raise RuntimeError(
"mneme server did not come up within %.0fs: rc=%s stderr=%r"
% (deadline, rc, self.stderr())
)
def stderr(self):
if not self._stderr_f.closed:
self._stderr_f.flush()
return self.stderr_path.read_bytes().decode("utf-8", "replace")
def stop(self, sig=signal.SIGTERM, timeout=STOP_GRACE):
"""Signal the whole process group and wait, SIGKILL fallback; always
bounded, always reaps, idempotent. Returns the exit code (None only
if even SIGKILL failed to reap, which the caller asserts against)."""
try:
if self.proc.poll() is None:
_killpg(self.pgid, sig)
try:
self.proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
_killpg(self.pgid, signal.SIGKILL)
try:
self.proc.wait(timeout=STOP_GRACE)
except subprocess.TimeoutExpired:
pass
# nuke any surviving group members (forked workers etc.)
_killpg(self.pgid, signal.SIGKILL)
finally:
with _live_lock:
_live_pgids.discard(self.pgid)
if not self._stderr_f.closed:
self._stderr_f.close()
return self.proc.returncode
def __enter__(self):
return self
def __exit__(self, *exc):
self.stop()
# ---- HTTP helpers ------------------------------------------------
def req(self, method, path, body=None, headers=None):
con = http.client.HTTPConnection("127.0.0.1", self.port,
timeout=HTTP_TIMEOUT)
try:
con.request(method, path, body=body, headers=headers or {})
r = con.getresponse()
return r.status, r.read(), dict(r.getheaders())
finally:
con.close()
def get_json(self, path):
status, body, _ = self.req("GET", path)
assert status == 200, "GET %s -> %s (%r)" % (path, status, body[:200])
return json.loads(body)
def save(self, ns, text, token=TOKEN, ctype="application/json", raw=None):
body = raw if raw is not None else json.dumps({"ns": ns, "text": text}).encode()
headers = {"Content-Type": ctype}
if token is not None:
headers["Authorization"] = "Bearer " + token
return self.req("POST", "/save", body=body, headers=headers)
def query(self, q, k=None):
path = "/query?q=" + urllib.parse.quote(q, safe="")
if k is not None:
path += "&k=%s" % k
return self.get_json(path)
def wait_queryable(self, q, deadline=8.0):
"""Bounded wait for the background drain — never calls drain()."""
t0 = time.monotonic()
while time.monotonic() - t0 < deadline:
hits = self.query(q)
if hits:
return hits
time.sleep(0.1)
raise AssertionError("row never became queryable for q=%r" % q)
def _db_rows(db, sql):
con = sqlite3.connect(db)
try:
return con.execute(sql).fetchall()
finally:
con.close()
# ======================================================================
# (1) save is fast, queue-append only, not synchronously indexed
# ======================================================================
def test_save_is_queue_append_only_and_fast(tmp_path):
"""POST /save returns an int id quickly; the row is NOT immediately
queryable and NOT in memories — it sits in write_queue (status shows it)."""
with Server(tmp_path, interval=LONG) as s:
t0 = time.monotonic()
status, body, _ = s.save("/test/alpha", "the quick brown fox")
elapsed = time.monotonic() - t0
assert status == 200, body
assert elapsed < 1.0, "save took %.2fs — must be queue-append only" % elapsed
rid = json.loads(body)["id"]
assert isinstance(rid, int)
assert s.query("fox") == [] # not indexed yet
assert _db_rows(s.db, "SELECT COUNT(*) FROM memories")[0][0] == 0
st = s.get_json("/status")
assert st["queue_depth"] >= 1
# ======================================================================
# (2) automatic background drain — no manual drain() anywhere
# ======================================================================
def test_background_drain_makes_save_queryable(tmp_path):
"""With --drain-interval 0.2 a saved row becomes queryable by itself;
/status queue_depth returns to 0. This test never triggers a drain."""
with Server(tmp_path, interval=SHORT) as s:
status, body, _ = s.save("/test/beta", "aurora borealis localized")
assert status == 200, body
hits = s.wait_queryable("aurora")
assert hits[0]["text"] == "aurora borealis localized"
t0 = time.monotonic()
while time.monotonic() - t0 < 5:
if s.get_json("/status")["queue_depth"] == 0:
break
time.sleep(0.1)
assert s.get_json("/status")["queue_depth"] == 0
# ======================================================================
# (3) no overlapping / duplicate drains under concurrency
# ======================================================================
def test_concurrent_saves_no_duplicate_or_lost_drains(tmp_path):
"""40 saves from 8 threads while the drain loop fires every 50 ms:
afterwards memories holds exactly 40 rows with 40 distinct ids, the queue
is empty, and the server logged no Traceback/IntegrityError (overlapping
drains + plain INSERT would blow up exactly there)."""
with Server(tmp_path, interval="0.05") as s:
errors = []
def worker(i):
for j in range(5):
st, body, _ = s.save("/test/conc", "row %d-%d payload" % (i, j))
if st != 200:
errors.append((st, body))
threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, errors
t0 = time.monotonic()
while time.monotonic() - t0 < 10:
if s.get_json("/status")["queue_depth"] == 0:
break
time.sleep(0.1)
rows = _db_rows(s.db, "SELECT COUNT(*), COUNT(DISTINCT id) FROM memories")
assert rows[0] == (40, 40), rows
assert _db_rows(s.db, "SELECT COUNT(*) FROM write_queue")[0][0] == 0
log = s.stderr()
assert "Traceback" not in log and "IntegrityError" not in log, log
# ======================================================================
# (4) graceful shutdown performs one final drain (and exits promptly)
# ======================================================================
def test_sigterm_final_drain(tmp_path):
"""Interval is 3600 s, so ONLY a shutdown drain can move these rows.
SIGTERM must drain the queue, then exit well before the interval —
which also proves the loop waits on an interruptible Event, not a
busy-poll or an uninterruptible sleep."""
s = Server(tmp_path, interval=LONG)
try:
for i in range(3):
st, body, _ = s.save("/test/final", "shutdown row %d" % i)
assert st == 200, body
assert s.get_json("/status")["queue_depth"] == 3
finally:
rc = s.stop(signal.SIGTERM, timeout=10)
assert rc == 0, "SIGTERM exit code %s, stderr=%r" % (rc, s.stderr())
assert _db_rows(s.db, "SELECT COUNT(*) FROM memories")[0][0] == 3
assert _db_rows(s.db, "SELECT COUNT(*) FROM write_queue")[0][0] == 0
# ======================================================================
# (5) auth on POST /save — fail-closed, bearer, constant-time; reads open
# ======================================================================
def test_save_without_configured_token_is_503(tmp_path):
with Server(tmp_path, token=None) as s:
status, _, _ = s.save("/test/auth", "nope", token=TOKEN)
assert status == 503
def test_save_bad_token_is_401(tmp_path):
with Server(tmp_path) as s:
status, _, hdrs = s.save("/test/auth", "nope", token="wrong-token")
assert status == 401
assert "bearer" in (hdrs.get("WWW-Authenticate") or "").lower()
status, _, _ = s.save("/test/auth", "nope", token=None) # no header
assert status == 401
def test_save_good_token_from_env_is_200(tmp_path):
with Server(tmp_path) as s:
status, body, _ = s.save("/test/auth", "yes", token=TOKEN)
assert status == 200, body
assert isinstance(json.loads(body)["id"], int)
def test_save_token_from_file(tmp_path):
"""No MNEME_TOKEN env; token read from ~/.config/mneme/token (comments and
blank lines skipped), exactly like bullseye's _post_token."""
filetok = "file-t0ken-xyz"
with Server(tmp_path, token=None,
token_file="# comment\n\n%s\n" % filetok) as s:
assert s.save("/test/auth", "no", token=TOKEN)[0] == 401
assert s.save("/test/auth", "yes", token=filetok)[0] == 200
def test_reads_stay_open_without_token(tmp_path):
"""GET endpoints need no auth even when no token is configured."""
with Server(tmp_path, token=None) as s:
for path in ("/query?q=x", "/namespaces", "/recent?n=5",
"/keywords?ns=%2Ftest", "/status", "/healthz"):
status, body, _ = s.req("GET", path)
assert status == 200, "%s -> %s (%r)" % (path, status, body[:200])
def test_server_source_uses_constant_time_compare():
src = SERVER_PY.read_text()
assert "compare_digest" in src, \
"server.py must compare bearer tokens with hmac.compare_digest"
# ======================================================================
# (6) hostile FTS5 metacharacters via HTTP never 500
# ======================================================================
HOSTILE_Q = [
'"', 'a" OR "b', 'NEAR(a, 1)', 'text: foo', '*', '-term',
'(((', 'a AND', 'x OR', 'col:*', '"unterminated',
'éü ☃ " AND (SELECT 1)', 'a;DROP TABLE memories;--',
]
def test_hostile_query_params_do_not_500(tmp_path):
with Server(tmp_path, interval=SHORT) as s:
assert s.save("/test/fts", "benign haystack row")[0] == 200
s.wait_queryable("haystack")
for q in HOSTILE_Q:
path = "/query?q=" + urllib.parse.quote(q, safe="")
status, body, _ = s.req("GET", path)
assert status < 500, "q=%r -> %s (%r)" % (q, status, body[:200])
if status == 200:
json.loads(body) # must still be valid JSON
for k in ("abc", "-1", "999999", ""):
status, body, _ = s.req("GET", "/query?q=haystack&k=" + k)
assert status < 500, "k=%r -> %s" % (k, status)
# ======================================================================
# (7) malformed JSON body -> 4xx (8) oversized body -> 413
# ======================================================================
def test_malformed_json_body_is_4xx(tmp_path):
with Server(tmp_path) as s:
status, _, _ = s.save(None, None, raw=b'{"ns": "/x", txt')
assert 400 <= status < 500
status, _, _ = s.save(None, None, raw=b"")
assert 400 <= status < 500
# wrong content-type is refused too (bullseye pattern: 415)
status, _, _ = s.save("/test/x", "y", ctype="text/plain")
assert status == 415
def test_oversized_body_rejected_413(tmp_path):
"""Declared Content-Length far over the 1 MiB cap must be rejected from
the headers alone (413) — the server must not try to read it all."""
with Server(tmp_path) as s:
con = http.client.HTTPConnection("127.0.0.1", s.port,
timeout=HTTP_TIMEOUT)
try:
con.putrequest("POST", "/save")
con.putheader("Authorization", "Bearer " + TOKEN)
con.putheader("Content-Type", "application/json")
con.putheader("Content-Length", "5000000")
con.endheaders()
con.send(b"x" * 1024) # only a sliver actually sent
r = con.getresponse()
assert r.status == 413, r.status
finally:
con.close()
# ======================================================================
# (9) /healthz is cheap liveness — no DB write, no DB connection
# ======================================================================
def test_healthz_writes_nothing_to_db(tmp_path):
def snap(db):
out = {}
for suf in ("", "-wal", "-shm"):
p = Path(db + suf)
out[suf] = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None
return out
with Server(tmp_path, interval=LONG) as s:
time.sleep(0.3) # let startup fully settle
before = snap(s.db)
for _ in range(3):
status, _, _ = s.req("GET", "/healthz")
assert status == 200
assert snap(s.db) == before, "/healthz touched the database files"
# ======================================================================
# /status shape + read endpoints shape (incl. /similar)
# ======================================================================
def test_status_reports_degraded_and_queue(tmp_path):
with Server(tmp_path, interval=LONG) as s:
assert s.save("/test/st", "one")[0] == 200
assert s.save("/test/st", "two")[0] == 200
st = s.get_json("/status")
assert isinstance(st["degraded"], bool)
assert st["queue_depth"] == 2
assert isinstance(st["queue_age"], (int, float)) and st["queue_age"] >= 0
def test_read_endpoints_shapes(tmp_path):
with Server(tmp_path, interval=SHORT) as s:
status, body, _ = s.save("/test/shapes", "gamma delta epsilon")
assert status == 200
rid = json.loads(body)["id"]
s.wait_queryable("gamma")
ns = s.get_json("/namespaces")
assert isinstance(ns, dict) and "/test/shapes" in ns
assert ns["/test/shapes"]["count"] == 1
recent = s.get_json("/recent?n=5")
assert isinstance(recent, list) and recent[0]["id"] == rid
kw = s.get_json("/keywords?ns=%2Ftest%2Fshapes")
assert isinstance(kw, list) and all(isinstance(w, str) for w in kw)
sim = s.get_json("/similar?to=%d" % rid)
assert isinstance(sim, list)
# ======================================================================
# store.py v1.1 — UNINDEXED behaviour + source-level notes
# ======================================================================
def test_query_does_not_match_ns_tokens(tmp_path):
"""id/ns are UNINDEXED in memories_fts: tokens that appear only in the
namespace path must not be searchable."""
with Server(tmp_path, interval=SHORT) as s:
assert s.save("/zebra/notes", "hello world")[0] == 200
s.wait_queryable("hello") # proves the row IS indexed
assert s.query("zebra") == []
assert s.query("notes") == []
def test_store_drain_uses_plain_insert():
src = STORE_PY.read_text()
assert "INSERT OR REPLACE" not in src.upper().replace(" ", " "), \
"v1.1: drain must use plain INSERT so overlapping drains error loudly"
def test_store_fts_id_ns_unindexed():
src = STORE_PY.read_text()
assert re.search(r"\bid\s+UNINDEXED", src), "fts5: id must be UNINDEXED"
assert re.search(r"\bns\s+UNINDEXED", src), "fts5: ns must be UNINDEXED"
def test_store_connections_explicitly_closed():
"""`with sqlite3.connect(...)` commits but never closes; v1.1 requires an
explicit .close() (or contextlib.closing) for per-batch connections."""
src = STORE_PY.read_text()
assert ".close()" in src or "closing(" in src, \
"store.py never closes its sqlite connections"