list_backups, check_backup and make_backup are Settings → Backups for an agent, admin only like the page. make_backup runs the page's job in the background and is audited as "<user> via MCP". Restoring stays in the browser. get_overview names a scheduled backup that failed, is two hours past its slot, or has no bucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.9 KiB
Python
114 lines
4.9 KiB
Python
"""Integration (#5836): the MCP's backup tools against real Postgres.
|
|
|
|
The history Steward keeps in its settings, read back through list_backups;
|
|
make_backup refused with no bucket, then started (the run itself stubbed, as
|
|
in test_backup_job.py) and audited; the overview names a failing backup.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
if not os.environ.get("STEWARD_DATABASE_URL"):
|
|
pytest.skip("needs Postgres")
|
|
from steward.app import create_app
|
|
return create_app(testing=False)
|
|
|
|
|
|
async def _configure(app, **over):
|
|
from steward.core.settings import set_setting
|
|
values = {"backup.enabled": True, "backup.s3_endpoint": "http://user:pw@archives:8081",
|
|
"backup.s3_bucket": "steward", "backup.s3_access_key": "AK",
|
|
"backup.s3_secret_key": "SK", "backup.schedule_hour": 3, "backup.schedule_tz": "UTC",
|
|
"backup.history": [], "backup.last_success_at": "", "backup.last_attempt_at": "", **over}
|
|
async with app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
for k, v in values.items():
|
|
await set_setting(db, k, v)
|
|
|
|
|
|
def test_backups_read_made_and_reported_through_mcp(app, monkeypatch):
|
|
from sqlalchemy import select
|
|
from steward.backup import job
|
|
from steward.core.api_tokens import Caller
|
|
from steward.mcp import _context
|
|
from steward.mcp.tools import backups, overview
|
|
from steward.models.audit import AuditEvent
|
|
from steward.models.users import User, UserRole
|
|
|
|
tag = uuid.uuid4().hex[:8]
|
|
now = datetime.now(timezone.utc)
|
|
good_at, failed_at = now - timedelta(days=1), now - timedelta(minutes=10)
|
|
history = [
|
|
{"at": failed_at.isoformat(), "ok": False, "by": "schedule", "name": "", "size": 0,
|
|
"secs": 1.0, "error": "Upload of steward-x.tar refused: 503"},
|
|
{"at": good_at.isoformat(), "ok": True, "by": f"bk-{tag}", "name": "steward-20261014T030000Z.tar",
|
|
"size": 52000, "secs": 4.2, "error": "",
|
|
"check": {"ok": True, "changes": 0, "rows": 310, "summary": "Matches what is in place."}},
|
|
]
|
|
made = []
|
|
|
|
async def _fake_run(app_, by="schedule", user_id=None):
|
|
made.append((by, user_id))
|
|
return {}
|
|
|
|
monkeypatch.setattr(job, "run_backup", _fake_run)
|
|
|
|
async def _go():
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
user = User(username=f"bk-{tag}", email=f"bk-{tag}@test", password_hash="x",
|
|
role=UserRole.admin)
|
|
s.add(user)
|
|
await s.flush()
|
|
user_id = user.id
|
|
_context._app_ctx.set(app)
|
|
_context._caller_ctx.set(Caller(user_id=user_id, username=f"bk-{tag}",
|
|
role=UserRole.admin, token_id=9))
|
|
|
|
await _configure(app, **{"backup.s3_bucket": ""})
|
|
with pytest.raises(ValueError, match="bucket isn't set up.*Nothing was started"):
|
|
await backups.make_backup()
|
|
|
|
await _configure(app, **{"backup.history": history,
|
|
"backup.last_success_at": good_at.isoformat(),
|
|
"backup.last_attempt_at": failed_at.isoformat()})
|
|
listed = await backups.list_backups()
|
|
assert listed["enabled"] is True and listed["running"] is False
|
|
assert listed["bucket"] == {"endpoint": "http://user:***@archives:8081", "bucket": "steward",
|
|
"region": "us-east-1"}
|
|
assert listed["verdict"]["tone"] == "bad" and "503" in listed["verdict"]["error"]
|
|
assert listed["schedule"]["next_at"] is not None
|
|
failed, good = listed["runs"]
|
|
assert failed["ok"] is False and failed["by"] == "schedule" and failed["name"] is None
|
|
assert good["name"] == "steward-20261014T030000Z.tar" and good["size_bytes"] == 52000
|
|
assert good["check"]["rows"] == 310 and listed["last_success_age_s"] >= 86000
|
|
assert (await backups.list_backups(limit=1))["truncated"] is True
|
|
|
|
started = await backups.make_backup()
|
|
assert started["started"] is True
|
|
await asyncio.sleep(0) # the background task runs the stub
|
|
assert made == [(f"bk-{tag} via MCP", user_id)]
|
|
|
|
async with app.db_sessionmaker() as s:
|
|
rows = list((await s.execute(select(AuditEvent).where(
|
|
AuditEvent.user_id == user_id, AuditEvent.action == "backup.started"))).scalars())
|
|
assert len(rows) == 1 and rows[0].username == f"bk-{tag} via MCP"
|
|
|
|
ov = await overview.get_overview()
|
|
mine = [f for f in ov["findings"] if f["kind"] == "backup_failed"]
|
|
assert len(mine) == 1 and "refused: 503" in mine[0]["summary"]
|
|
|
|
await _configure(app, **{"backup.enabled": False})
|
|
|
|
asyncio.run(_go())
|