Files
FabledSteward/tests/integration/test_backup_job.py
T
bvandeusenandClaude Opus 5.5 4c546a9c4d
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m13s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 1m12s
test(backup): run 9076's three failures (#5673, #5674)
- The audit-wording guard now reads both branches of a conditional action
  ("backup.made" if ok else "backup.failed"), and its self-test covers that.
- The Backups save test no longer asserts the session's tz, which sign-in
  state sets, not the test.
- The restore integration test uses the app's own metadata. all_metadata
  without plugin dirs imports the `plugins` package as the tests do; loading
  by path under the loader's names is only for `steward restore`. Loading by
  path in the shared test process had mapped traefik and unifi tables a
  second time for later tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:27:24 -04:00

170 lines
7.2 KiB
Python

"""Integration: the backup job records each run against Postgres (#5673).
pg_dump isn't in this lane (the publish smoke step runs the real one), so the
dump and the upload are stubbed; what is real is everything Steward records:
history, the last-success time, metrics, audit, and the sealed secret key.
Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import os
import time
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://archives:8081",
"backup.s3_bucket": "steward", "backup.s3_access_key": "AK",
"backup.s3_secret_key": "SK", "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 _stub(monkeypatch, *, fail_upload: str = ""):
from steward.backup import job
from steward.backup.dump import Made
from steward.backup.s3 import S3Error
sent = []
async def make_backup(database_url, secret_key, heads, workdir, now=None):
path = workdir / "steward-stub.tar"
path.write_bytes(b"x" * 4096)
return Made(path, path.name, 4096, {"migration_heads": heads})
async def put_file(target, key, path, **kw):
if fail_upload:
raise S3Error(fail_upload)
sent.append((target.bucket, key, path.read_bytes()))
monkeypatch.setattr(job, "make_backup", make_backup)
monkeypatch.setattr(job, "put_file", put_file)
return sent
async def _state(app):
from sqlalchemy import select
from steward.core.settings import get_all_settings
from steward.models.audit import AuditEvent
from steward.models.metrics import PluginMetricLatest
async with app.db_sessionmaker() as db:
settings = await get_all_settings(db)
metrics = dict((await db.execute(select(PluginMetricLatest.metric_name, PluginMetricLatest.value).where(
PluginMetricLatest.source_module == "steward",
PluginMetricLatest.resource_name == "backup"))).all())
audits = list((await db.execute(select(AuditEvent.action).where(
AuditEvent.action.like("backup.%")).order_by(AuditEvent.timestamp.desc(), AuditEvent.id.desc()).limit(1))).scalars())
return settings, metrics, audits
def test_a_sent_backup_is_recorded_and_a_failed_one_says_why(app, monkeypatch):
from steward.backup import job
async def _go():
await _configure(app)
sent = _stub(monkeypatch)
entry = await job.run_backup(app, by="admin-x")
ok_state = await _state(app)
_stub(monkeypatch, fail_upload="Upload of steward-stub.tar refused: HTTP 403 AccessDenied.")
failed = await job.run_backup(app)
bad_state = await _state(app)
return sent, entry, ok_state, failed, bad_state
sent, entry, (settings, metrics, audits), failed, (settings2, _, audits2) = asyncio.run(_go())
assert sent == [("steward", "steward-stub.tar", b"x" * 4096)]
assert entry["ok"] and entry["by"] == "admin-x" and entry["size"] == 4096
assert settings["backup.history"][0]["name"] == "steward-stub.tar"
assert settings["backup.last_success_at"] == entry["at"]
assert metrics["backup_bytes"] == 4096 and metrics["backup_age_secs"] == 0
assert audits == ["backup.made"]
assert not failed["ok"] and "AccessDenied" in failed["error"]
assert [h["ok"] for h in settings2["backup.history"]] == [False, True]
assert settings2["backup.last_success_at"] == entry["at"] # the good one still stands
assert settings2["backup.last_attempt_at"] == failed["at"]
assert audits2 == ["backup.failed"]
def test_the_tick_records_the_age_and_backs_up_when_due(app, monkeypatch):
from steward.backup import job
async def _go():
long_ago = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
await _configure(app, **{"backup.last_success_at": long_ago, "backup.last_attempt_at": long_ago})
sent = _stub(monkeypatch)
await job.tick(app)
settings, metrics, _ = await _state(app)
await job.tick(app) # not due again until the next slot
return sent, settings
sent, settings = asyncio.run(_go())
assert len(sent) == 1
assert settings["backup.history"][0]["by"] == "schedule"
def test_the_tick_does_nothing_while_backups_are_off(app, monkeypatch):
from steward.backup import job
async def _go():
await _configure(app, **{"backup.enabled": False})
sent = _stub(monkeypatch)
await job.tick(app)
return sent
assert asyncio.run(_go()) == []
def test_the_secret_key_is_sealed_and_a_blank_box_keeps_it(app):
from sqlalchemy import select
from steward.models.settings import AppSetting
from steward.models.users import User, UserRole
async def _go():
await _configure(app)
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"bk-{uid[:8]}", email=f"bk-{uid[:8]}@test",
password_hash="x", role=UserRole.admin, is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="admin", username=f"bk-{uid[:8]}", _csrf="test-token",
login_at=time.time(), tz="UTC")
form = {"csrf_token": "test-token", "backup.enabled": "on", "backup.s3_endpoint": "http://archives:8081",
"backup.s3_bucket": "steward", "backup.s3_region": "", "backup.s3_access_key": "AK2",
"backup.s3_secret_key": "new-secret", "backup.schedule_hour": "4"}
r1 = await client.post("/settings/backups/", form=form)
async with app.db_sessionmaker() as db:
stored = (await db.execute(select(AppSetting.value_json).where(
AppSetting.key == "backup.s3_secret_key"))).scalar()
r2 = await client.post("/settings/backups/", form=form | {"backup.s3_secret_key": ""})
page = await client.get("/settings/backups/")
from steward.core.settings import get_all_settings
async with app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return r1.status_code, stored, r2.status_code, page.status_code, await page.get_data(as_text=True), settings
s1, stored, s2, s3, page, settings = asyncio.run(_go())
assert s1 == 302 and s2 == 302 and s3 == 200
assert "enc:v1:" in stored and "new-secret" not in stored
assert settings["backup.s3_secret_key"] == "new-secret"
assert settings["backup.s3_access_key"] == "AK2" and settings["backup.s3_region"] == "us-east-1"
assert settings["backup.schedule_hour"] == 4
assert "new-secret" not in page and "kept; type to change" in page