Files
NotesAgentic/backend/tests/test_sidecar_auth.py

161 lines
6.8 KiB
Python

import asyncio
import json
import os
from pathlib import Path
import queue
import secrets
import subprocess
import sys
import threading
import urllib.error
import urllib.request
import pytest
from app.sidecar import SessionAuth, bootstrap, proof
def test_bootstrap_is_bounded_and_requires_session_entropy(tmp_path):
data = dict(protocol=1, secret="01" * 32, challenge="02" * 32,
generation="03" * 32, data_dir=str(tmp_path), launcher_pid=123)
assert bootstrap(json.dumps(data).encode() + b"\n") == data
for invalid in [b"{}\n", b"x" * 16385, b"{}", b"null\n"]:
with pytest.raises(ValueError):
bootstrap(invalid)
data["secret"] = "short"
with pytest.raises(ValueError):
bootstrap(json.dumps(data).encode() + b"\n")
def test_session_auth_covers_every_route_and_rejects_duplicate_headers():
calls = []
async def app(scope, receive, send):
calls.append(scope["path"])
await send({"type": "http.response.start", "status": 204, "headers": []})
auth = SessionAuth(app, "ab" * 32, "cd" * 32, 4567)
valid = [(b"host", b"127.0.0.1:4567"),
(b"authorization", ("Bearer " + "ab" * 32).encode()),
(b"x-core-generation", ("cd" * 32).encode())]
async def request(headers, path):
messages = []
async def send(message):
messages.append(message)
await auth({"type": "http", "headers": headers, "path": path}, None, send)
return messages[0]["status"]
for path in ["/health", "/api/status", "/api/events", "/api/export/file", "/docs", "/unknown"]:
for bad in [[], valid[:2], valid + [valid[1]],
valid + [(b"origin", b"tauri://localhost")],
[(b"host", b"evil.test")] + valid[1:],
valid[:2] + [(b"x-core-generation", b"old")]]:
assert asyncio.run(request(bad, path)) == 401
assert asyncio.run(request(valid, path)) == 204
assert len(calls) == 6
def test_session_auth_rejects_missing_wrong_and_old_generation_100_times_without_side_effects():
calls = []
async def app(scope, receive, send):
calls.append(scope["path"])
await send({"type": "http.response.start", "status": 204, "headers": []})
secret = "ab" * 32
generation = "cd" * 32
auth = SessionAuth(app, secret, generation, 4567)
host = (b"host", b"127.0.0.1:4567")
authorization = (b"authorization", f"Bearer {secret}".encode())
current = (b"x-core-generation", generation.encode())
cases = {
"missing": [host, current],
"wrong": [host, (b"authorization", ("Bearer " + "ef" * 32).encode()), current],
"old": [host, authorization, (b"x-core-generation", ("01" * 32).encode())],
}
async def request(headers):
messages = []
async def send(message):
messages.append(message)
await auth({"type": "http", "headers": headers, "path": "/api/notes"}, None, send)
return messages
for name, headers in cases.items():
for _ in range(100):
messages = asyncio.run(request(headers))
assert messages[0]["status"] == 401, name
assert json.loads(messages[1]["body"])["error"]["code"] == "AUTH_REQUIRED"
assert calls == []
def test_handshake_proof_binds_port_pid_generation_and_challenge():
args = ["01" * 32, "02" * 32, "03" * 32, 123, 4567, 123]
expected = proof(*args)
assert len(expected) == 64
for i in range(1, len(args)):
changed = args.copy()
changed[i] = "04" * 32 if isinstance(args[i], str) else args[i] + 1
assert proof(*changed) != expected
def test_real_sidecar_bootstrap_auth_and_parent_eof(tmp_path):
config = dict(protocol=1, secret=secrets.token_hex(32), challenge=secrets.token_hex(32),
generation=secrets.token_hex(32), data_dir=str(tmp_path / "core"))
executable = os.environ.get("OPENNEXUS_CORE_TEST_BINARY")
command = [executable] if executable else [sys.executable, "-m", "app.sidecar"]
diagnostics = (tmp_path / "core-stderr.log").open("wb")
process = subprocess.Popen(command,
cwd=Path(__file__).resolve().parents[1],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=diagnostics,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
try:
assert config["secret"] not in "\0".join(command)
assert config["secret"] not in "\0".join(os.environ.values())
config["launcher_pid"] = process.pid
process.stdin.write(json.dumps(config).encode() + b"\n")
process.stdin.flush()
received = queue.Queue()
threading.Thread(target=lambda: received.put(process.stdout.readline(16385)), daemon=True).start()
line = received.get(timeout=30)
assert line, (tmp_path / "core-stderr.log").read_text(encoding="utf-8", errors="replace")[-4000:]
ready = json.loads(line)
assert ready["launcher_pid"] == process.pid
assert ready["pid"] > 0
assert ready["proof"] == proof(config["secret"], config["challenge"], config["generation"],
ready["pid"], ready["port"], process.pid)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
url = f'http://127.0.0.1:{ready["port"]}'
with pytest.raises(urllib.error.HTTPError) as error:
opener.open(url + "/health", timeout=5)
assert error.value.code == 401
request = urllib.request.Request(url + "/health", headers={
"Authorization": "Bearer " + config["secret"],
"X-Core-Generation": config["generation"],
})
with opener.open(request, timeout=5) as response:
assert json.load(response)["status"] == "ok"
for disabled in ("/docs", "/redoc", "/openapi.json", "/docs/oauth2-redirect"):
request = urllib.request.Request(url + disabled, headers={
"Authorization": "Bearer " + config["secret"],
"X-Core-Generation": config["generation"],
})
with pytest.raises(urllib.error.HTTPError) as error:
opener.open(request, timeout=5)
assert error.value.code == 404
process.stdin.close()
assert process.wait(timeout=10) == 0
diagnostics.flush()
planted = config["secret"].encode()
assert planted not in (tmp_path / "core-stderr.log").read_bytes()
for artifact in (tmp_path / "core").rglob("*"):
if artifact.is_file():
assert planted not in artifact.read_bytes(), artifact.name
finally:
if not process.stdin.closed:
process.stdin.close()
if process.poll() is None:
process.kill()
process.wait(timeout=10)
diagnostics.close()