"""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())