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
30 changes: 29 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@

TMP_POLICY_DIR = _install_tmp_policy()

# Several modules resolve their data directory ONCE, at import time, from this
# variable: `shared/saga/impl/storage.py` (`SAGA_DIR`), `shared/read_only.py`
# (which builds the module-level `read_only_replica` singleton on import) and
# `features/auth/__init__.py` (`DEFAULT_KEYS_FILE`, `DEFAULT_TOKEN_FILE`).
# Importing any of them while the real value is in place is enough to write to
# the operator's live memory directory — no test body has to do anything wrong.
# Measured on 2026-10-07 before this line existed: `~/.mcp-ariel-memory/replica`
# held a 14 MB database and `sagas/` 15875 state files (63 MB).
#
# This has to run here, before pytest imports the first test module, because by
# the time a fixture executes the constants above are already resolved.
_OPERATOR_DATA_DIR = os.environ.get("MCP_MEMORY_DATA_DIR")
SESSION_DATA_DIR = tempfile.mkdtemp(prefix="ariel-test-data-")
os.environ["MCP_MEMORY_DATA_DIR"] = SESSION_DATA_DIR

import pytest


Expand Down Expand Up @@ -54,10 +69,16 @@ def hermetic_global_db():
cm (adaptive_threshold, DreamBuffer, ConsolidationEngine...) would
otherwise read/write the real ~/.mcp-ariel-memory data dir. Mutating the
singleton in place keeps those references valid.

The dir is the one already published as MCP_MEMORY_DATA_DIR in this file
(see the top): the module-level constants that resolve from it are created
on import, so this fixture cannot be what points them somewhere safe — it
can only agree with what was decided there. Its own teardown below is what
removes the directory and puts the operator's value back.
"""
from shared.connection import connection_manager

session_dir = tempfile.mkdtemp(prefix="ariel-test-global-")
session_dir = SESSION_DATA_DIR
original_dir = connection_manager.base_dir
connection_manager.base_dir = Path(session_dir)
connection_manager._conns.clear() # drop any already-open real-dir handles
Expand All @@ -80,6 +101,13 @@ def hermetic_global_db():
pass
connection_manager.base_dir = original_dir
connection_manager._conns.clear()
# Put the operator's value back: this process is not the only consumer of
# the variable, and leaving a deleted path published would be worse than
# never having set it.
if _OPERATOR_DATA_DIR is None:
os.environ.pop("MCP_MEMORY_DATA_DIR", None)
else:
os.environ["MCP_MEMORY_DATA_DIR"] = _OPERATOR_DATA_DIR
# This fixture is the only thing that ever learns the path, so it is the
# only thing that can remove it — same rule the eval harness had to learn
# the hard way (one leaked directory per run, 972 of them / 1.6G in 48h).
Expand Down
88 changes: 46 additions & 42 deletions tests/shared/test_saga_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def _pin_master_key(monkeypatch):
_secrets._master_cache.clear()


from unittest.mock import patch, AsyncMock
from unittest.mock import AsyncMock
from pathlib import Path
import tempfile

Expand Down Expand Up @@ -41,53 +41,57 @@ def engine(store):


@pytest.mark.asyncio
async def test_backup_saga_success(engine, temp_dir):
# Setup mock environment
with patch("pathlib.Path.home", return_value=temp_dir):
base = temp_dir / ".mcp-ariel-memory"
base.mkdir(parents=True)
db_file = base / DB_NAME
db_file.write_text("dummy database content")
async def test_backup_saga_success(engine, temp_dir, monkeypatch):
# The saga resolves its base dir from MCP_MEMORY_DATA_DIR, which the suite
# now points at its own session dir (see tests/conftest.py), so patching
# `pathlib.Path.home` no longer decides anything: the variable wins. The
# test has to say where it wants its database instead of relying on the
# variable being unset.
base = temp_dir / ".mcp-ariel-memory"
base.mkdir(parents=True)
monkeypatch.setenv("MCP_MEMORY_DATA_DIR", str(base))
(base / DB_NAME).write_text("dummy database content")

steps = create_backup_saga()
state = SagaState(saga_id="backup_test", name="backup", context={})

steps = create_backup_saga()
state = SagaState(saga_id="backup_test", name="backup", context={})

# Execute
result = await engine.execute(state, steps)
# Execute
result = await engine.execute(state, steps)

# Verify
assert "backup_path" in result
backup_path = Path(result["backup_path"])
assert backup_path.exists()
assert (backup_path / DB_NAME).exists()
assert state.status == SagaStatus.COMPLETED
# Verify
assert "backup_path" in result
backup_path = Path(result["backup_path"])
assert backup_path.exists()
assert (backup_path / DB_NAME).exists()
assert state.status == SagaStatus.COMPLETED


@pytest.mark.asyncio
async def test_backup_saga_compensation(engine, temp_dir):
async def test_backup_saga_compensation(engine, temp_dir, monkeypatch):
# Setup mock environment where verification fails
with patch("pathlib.Path.home", return_value=temp_dir):
base = temp_dir / ".mcp-ariel-memory"
base.mkdir(parents=True)
db_file = base / DB_NAME
db_file.write_text("dummy database content")

steps = create_backup_saga()
# Force failure in second step
steps[1].action = AsyncMock(side_effect=ValueError("verify failed"))

state = SagaState(saga_id="backup_fail", name="backup", context={})

# Execute
with pytest.raises(ValueError, match="verify failed"):
await engine.execute(state, steps)

# Verify compensation (backup dir removed)
assert state.status == SagaStatus.COMPENSATED
# We need to find the backup dir from context
backup_path_str = state.context.get("backup_path")
if backup_path_str:
assert not Path(backup_path_str).exists()
base = temp_dir / ".mcp-ariel-memory"
base.mkdir(parents=True)
monkeypatch.setenv("MCP_MEMORY_DATA_DIR", str(base))
(base / DB_NAME).write_text("dummy database content")

steps = create_backup_saga()
# Force failure in second step
steps[1].action = AsyncMock(side_effect=ValueError("verify failed"))

state = SagaState(saga_id="backup_fail", name="backup", context={})

# Execute
with pytest.raises(ValueError, match="verify failed"):
await engine.execute(state, steps)

# Verify compensation (backup dir removed)
assert state.status == SagaStatus.COMPENSATED
# The backup has to have been made for its removal to mean anything; before
# this test named its own data dir the source was missing, the first step
# reported `skipped_no_source`, and this assertion never ran.
backup_path_str = state.context.get("backup_path")
assert backup_path_str, "the backup step must have produced a directory to compensate"
assert not Path(backup_path_str).exists()


@pytest.mark.asyncio
Expand Down
36 changes: 23 additions & 13 deletions tests/test_auth_backup.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
"""
Tests for auth — unique tests only.

Each test names its own store path. Constructed bare, `APIKeyAuth()` and
`BearerAuth()` fall back to the CWD-relative `data/auth/*.enc`, so these tests
used to append to `<repo>/data/auth/keys.enc` on every run — 704 KB of
accumulated `alice` keys by 2026-10-07, and growing by ~860 B per run. It is
gitignored (`*.enc`), so nothing was ever committed, but `test_api_key_list`
asserting `len(keys) >= 2` was satisfied by that history rather than by the
test. The suite-wide fix for the modules that resolve their directory at import
time is in `tests/conftest.py`; this file is fixed here because the fallback it
was hitting is relative, and no environment variable can redirect that.
"""

import pytest


@pytest.mark.asyncio
async def test_api_key_create():
async def test_api_key_create(tmp_path):
from features.auth import APIKeyAuth

auth = APIKeyAuth()
auth = APIKeyAuth(keys_file=tmp_path / "keys.enc")
key = auth.create_key("alice", "test key")
assert key.startswith("ak_")
assert len(key) > 20


@pytest.mark.asyncio
async def test_api_key_verify():
async def test_api_key_verify(tmp_path):
from features.auth import APIKeyAuth

auth = APIKeyAuth()
auth = APIKeyAuth(keys_file=tmp_path / "keys.enc")
key = auth.create_key("alice", "test key")
info = auth.verify(key)
assert info is not None
Expand All @@ -28,10 +38,10 @@ async def test_api_key_verify():


@pytest.mark.asyncio
async def test_api_key_revoke():
async def test_api_key_revoke(tmp_path):
from features.auth import APIKeyAuth

auth = APIKeyAuth()
auth = APIKeyAuth(keys_file=tmp_path / "keys.enc")
key = auth.create_key("alice", "test key")
assert auth.verify(key) is not None
revoked = auth.revoke(key)
Expand All @@ -40,21 +50,21 @@ async def test_api_key_revoke():


@pytest.mark.asyncio
async def test_api_key_list():
async def test_api_key_list(tmp_path):
from features.auth import APIKeyAuth

auth = APIKeyAuth()
auth = APIKeyAuth(keys_file=tmp_path / "keys.enc")
auth.create_key("alice", "key1")
auth.create_key("alice", "key2")
keys = auth.list_keys()
assert len(keys) >= 2
assert len(keys) == 2


@pytest.mark.asyncio
async def test_bearer_auth():
async def test_bearer_auth(tmp_path):
from features.auth import BearerAuth

ba = BearerAuth()
ba = BearerAuth(token_file=tmp_path / "token.enc")
token = ba.get_token()
assert token.startswith("mt_")
assert ba.verify("Bearer " + token) is True
Expand All @@ -63,10 +73,10 @@ async def test_bearer_auth():


@pytest.mark.asyncio
async def test_bearer_rotate():
async def test_bearer_rotate(tmp_path):
from features.auth import BearerAuth

ba = BearerAuth()
ba = BearerAuth(token_file=tmp_path / "token.enc")
old_token = ba.get_token()
new_token = ba.rotate()
assert old_token != new_token
Expand Down
Loading