Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

import pytest

import backend # noqa: F401 - exercises the package import
Expand All @@ -8,6 +10,66 @@
import api_provider


_REAL_DATA_DIR = (Path.home() / ".graphlink").resolve()


@pytest.fixture(autouse=True)
def _never_touch_the_real_user_data_dir(monkeypatch):
"""Hard-fail any test that opens the developer's REAL ~/.graphlink files.

create_app() defaults settings_state_file/chat_db_path to
~/.graphlink/session.dat and ~/.graphlink/chats.db (see
graphlink_settings_store.SettingsManager and chat_library.DEFAULT_DB_PATH).
A test that constructs the app without overriding BOTH therefore reads and
REWRITES the live settings/chat history of whoever runs the suite - which
is exactly what happened: several files here called create_app() bare, and
every `pytest` run silently rewrote the real session.dat (empirically
confirmed by watching its mtime change). It was benign only by luck -
bootstrap_provider_state() happened to write back an identical value, and
its except-branch would have overwritten the real current_mode outright.

This guard makes that class of bug impossible to reintroduce silently: it
fails loudly at the moment of access, naming the offending path, rather
than leaving a future contributor to notice their own data drifting. Every
test must pass a tmp_path/TemporaryDirectory-derived override - see
test_assets.py's or test_http_trust_boundary.py's make_client helpers for
the established shape.
"""
def _guard(path, what):
try:
resolved = Path(path).resolve()
except (OSError, ValueError): # unresolvable path - nothing real to hit
return
if resolved == _REAL_DATA_DIR or _REAL_DATA_DIR in resolved.parents:
raise AssertionError(
f"test touched REAL user data: {what} -> {resolved}. Pass a "
f"tmp_path-derived settings_state_file=/chat_db_path= instead "
f"(see backend/tests/conftest.py's own docstring)."
)

import graphlink_settings_store as settings_store
from backend import chat_library

real_settings_init = settings_store.SettingsManager.__init__

def guarded_settings_init(self, state_file=None, *args, **kwargs):
_guard(
state_file if state_file is not None else _REAL_DATA_DIR / "session.dat",
"SettingsManager(state_file=...)",
)
return real_settings_init(self, state_file, *args, **kwargs)

real_connect = chat_library._connect

def guarded_connect(db_path, *args, **kwargs):
_guard(db_path, "chat_library._connect(db_path=...)")
return real_connect(db_path, *args, **kwargs)

monkeypatch.setattr(settings_store.SettingsManager, "__init__", guarded_settings_init)
monkeypatch.setattr(chat_library, "_connect", guarded_connect)
yield


@pytest.fixture(autouse=True)
def _chat_stream_delegates_to_patched_chat(monkeypatch):
"""R4.4: send_message's reply path now always calls api_provider.chat_stream
Expand Down
37 changes: 30 additions & 7 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@


@pytest.fixture
def authed_client():
def authed_client(tmp_path):
# ADR-004 stage 4.2: TrustedHostMiddleware now rejects any Host other
# than 127.0.0.1 - TestClient's own default ("testserver") would
# otherwise 400 every request in this file before the auth checks under
Expand All @@ -41,8 +41,18 @@ def authed_client():
# Starlette's TestClient.websocket_connect hardcodes Host: testserver
# independent of base_url (confirmed via a raw-ASGI-scope probe) -
# headers= is what actually reaches the WS upgrade request's own Host.
#
# settings_state_file/chat_db_path: without these, create_app() falls
# through to its real production defaults (~/.graphlink/session.dat,
# ~/.graphlink/chats.db) - every test using this fixture would read AND
# rewrite the developer's real live settings/chat data. Same isolation
# convention as test_assets.py/test_app_ws.py's own make_client().
return TestClient(
create_app(auth_token=TOKEN),
create_app(
auth_token=TOKEN,
settings_state_file=tmp_path / "session.dat",
chat_db_path=tmp_path / "chats.db",
),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)
Expand Down Expand Up @@ -193,7 +203,12 @@ def test_the_spa_bootstrap_is_not_gated(tmp_path):
(spa_dir / "assets" / "index.js").write_text("console.log(1)", encoding="utf-8")

client = TestClient(
create_app(spa_dir=spa_dir, auth_token=TOKEN),
create_app(
spa_dir=spa_dir,
auth_token=TOKEN,
settings_state_file=tmp_path / "session.dat",
chat_db_path=tmp_path / "chats.db",
),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)
Expand Down Expand Up @@ -258,9 +273,13 @@ def test_an_unauthenticated_ws_handshake_creates_no_session(authed_client):
# -- auth disabled (the test/dev default) -----------------------------------


def test_auth_disabled_when_no_token_is_configured(monkeypatch):
def test_auth_disabled_when_no_token_is_configured(monkeypatch, tmp_path):
monkeypatch.delenv(DEV_AUTH_TOKEN_ENV, raising=False)
client = TestClient(create_app(), base_url="http://127.0.0.1", headers={"host": "127.0.0.1"})
client = TestClient(
create_app(settings_state_file=tmp_path / "session.dat", chat_db_path=tmp_path / "chats.db"),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)

assert client.app.state.auth_token is None
assert client.get("/api/health").status_code == 200
Expand All @@ -269,11 +288,15 @@ def test_auth_disabled_when_no_token_is_configured(monkeypatch):
assert ws.receive_json()["topic"] == "system"


def test_the_dev_env_var_supplies_a_token_when_no_explicit_one_is_passed(monkeypatch):
def test_the_dev_env_var_supplies_a_token_when_no_explicit_one_is_passed(monkeypatch, tmp_path):
# The vite-dev workflow's escape hatch, matching the existing
# GRAPHLINK_DEV_WS_ORIGIN precedent - unset in every real launch.
monkeypatch.setenv(DEV_AUTH_TOKEN_ENV, "dev-token")
client = TestClient(create_app(), base_url="http://127.0.0.1", headers={"host": "127.0.0.1"})
client = TestClient(
create_app(settings_state_file=tmp_path / "session.dat", chat_db_path=tmp_path / "chats.db"),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)

assert client.get("/api/health").status_code == 401
assert client.get("/api/health?token=dev-token").status_code == 200
33 changes: 30 additions & 3 deletions backend/tests/test_http_trust_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

from __future__ import annotations

import tempfile
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

Expand All @@ -36,11 +39,30 @@ def _make_client(**create_app_kwargs) -> TestClient:
# and headers= are required (websocket_connect hardcodes its own Host
# independent of base_url - a Starlette TestClient quirk, confirmed via
# a raw-ASGI-scope probe, not assumed).
return TestClient(
create_app(auth_token=TOKEN, **create_app_kwargs),
#
# settings_state_file/chat_db_path: without an explicit override,
# create_app() falls through to its real production defaults
# (~/.graphlink/session.dat, ~/.graphlink/chats.db) - every one of this
# file's ~14 callers would read AND rewrite the developer's real live
# settings/chat data. A TemporaryDirectory here (not a tmp_path fixture
# argument) matches test_assets.py's own make_client() exactly, since
# this helper is called directly by test functions with no tmp_path
# parameter of their own. **create_app_kwargs is applied last, so an
# individual test can still override these if it ever needs to.
state_dir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
state_path = Path(state_dir.name)
kwargs = {
"settings_state_file": state_path / "session.dat",
"chat_db_path": state_path / "chats.db",
**create_app_kwargs,
}
client = TestClient(
create_app(auth_token=TOKEN, **kwargs),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)
client._state_tmpdir = state_dir # type: ignore[attr-defined]
return client


# -- TrustedHostMiddleware: the Host-header check ----------------------------
Expand Down Expand Up @@ -255,7 +277,12 @@ def test_the_spa_bootstrap_is_not_gated_by_origin(tmp_path):
(spa_dir / "assets").mkdir(parents=True)
(spa_dir / "index.html").write_text("<html>graphlink</html>", encoding="utf-8")
client = TestClient(
create_app(spa_dir=spa_dir, auth_token=TOKEN),
create_app(
spa_dir=spa_dir,
auth_token=TOKEN,
settings_state_file=tmp_path / "session.dat",
chat_db_path=tmp_path / "chats.db",
),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)
Expand Down
62 changes: 62 additions & 0 deletions backend/tests/test_real_data_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Proves conftest.py's _never_touch_the_real_user_data_dir guard actually
fires.

A guard that silently never triggers is worse than no guard - it reads as
protection while providing none. This mirrors the same "prove the gate
catches a deliberate regression" convention ADR-019's own CI gates follow:
each test here reproduces the exact real-data access the guard exists to
stop, and asserts it is refused.

See conftest.py's own docstring for the bug this class of guard closes.
"""

from __future__ import annotations

from pathlib import Path

import pytest

REAL_DIR = Path.home() / ".graphlink"


def test_constructing_a_settings_manager_on_the_real_session_dat_is_refused():
from graphlink_settings_store import SettingsManager

with pytest.raises(AssertionError, match="REAL user data"):
SettingsManager(REAL_DIR / "session.dat")


def test_the_real_path_is_refused_even_when_reached_via_the_default_argument():
# The original bug's exact shape: nobody passes the real path explicitly,
# they just omit the override and let the production default apply.
from graphlink_settings_store import SettingsManager

with pytest.raises(AssertionError, match="REAL user data"):
SettingsManager()


def test_connecting_to_the_real_chats_db_is_refused():
from backend import chat_library

with pytest.raises(AssertionError, match="REAL user data"):
chat_library._connect(REAL_DIR / "chats.db")


def test_creating_the_app_without_path_overrides_is_refused():
# The end-to-end case: the four helpers that regressed all looked exactly
# like this - a bare create_app() whose defaults resolve to the real dir.
from backend.app import create_app

with pytest.raises(AssertionError, match="REAL user data"):
create_app()


def test_a_tmp_path_derived_override_is_allowed(tmp_path):
# The complementary half: a guard that refused EVERYTHING would pass the
# four tests above while breaking the whole suite, so prove the correct
# usage still works.
from graphlink_settings_store import SettingsManager

manager = SettingsManager(tmp_path / "session.dat")

assert manager.state_file == tmp_path / "session.dat"
23 changes: 21 additions & 2 deletions backend/tests/test_security_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@

from __future__ import annotations

import tempfile
from pathlib import Path

import pytest
from fastapi import WebSocketDisconnect

Expand All @@ -40,11 +43,27 @@ def _make_client(**create_app_kwargs) -> TestClient:
# hardcodes its own Host independent of base_url - both kwargs are
# required for every other test in this file to even reach the checks
# under test. Matches backend/tests/test_auth.py's authed_client fixture.
return TestClient(
create_app(auth_token=TOKEN, **create_app_kwargs),
#
# settings_state_file/chat_db_path: without an explicit override,
# create_app() falls through to its real production defaults
# (~/.graphlink/session.dat, ~/.graphlink/chats.db) - every one of this
# file's create_app()-backed callers would read AND rewrite the
# developer's real live settings/chat data. Matches test_assets.py's own
# make_client() and test_http_trust_boundary.py's _make_client().
state_dir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
state_path = Path(state_dir.name)
kwargs = {
"settings_state_file": state_path / "session.dat",
"chat_db_path": state_path / "chats.db",
**create_app_kwargs,
}
client = TestClient(
create_app(auth_token=TOKEN, **kwargs),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)
client._state_tmpdir = state_dir # type: ignore[attr-defined]
return client


# -- Invariant 1: auth-required ----------------------------------------------
Expand Down
21 changes: 19 additions & 2 deletions backend/tests/test_session_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,28 @@ def broken_evict(bus):


def _make_client() -> TestClient:
return TestClient(
create_app(auth_token="test-token"),
# settings_state_file/chat_db_path: without an explicit override,
# create_app() falls through to its real production defaults
# (~/.graphlink/session.dat, ~/.graphlink/chats.db) - every one of this
# helper's callers would read AND rewrite the developer's real live
# settings/chat data. Matches test_assets.py's own make_client() and
# test_http_trust_boundary.py's _make_client().
import tempfile
from pathlib import Path

state_dir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
state_path = Path(state_dir.name)
client = TestClient(
create_app(
auth_token="test-token",
settings_state_file=state_path / "session.dat",
chat_db_path=state_path / "chats.db",
),
base_url="http://127.0.0.1",
headers={"host": "127.0.0.1"},
)
client._state_tmpdir = state_dir # type: ignore[attr-defined]
return client


def test_ws_rejects_an_unknown_session_id():
Expand Down
Loading