"""Steward's backup job (steward/backup/job.py): when a backup is due, what the Backups page says, and the Test button's words.""" import asyncio from datetime import datetime, timezone from zoneinfo import ZoneInfo from quart import render_template, session from steward.backup import job from steward.backup.s3 import S3Error, StoredObject from steward.core.settings import DEFAULTS UTC = timezone.utc NY = ZoneInfo("America/New_York") def at(h, m=0, day=12): return datetime(2026, 10, day, h, m, tzinfo=UTC) def test_the_slot_is_todays_hour_once_it_has_come_else_yesterdays(): assert job.last_slot(at(2), 3, UTC) == at(3, day=11) assert job.last_slot(at(4), 3, UTC) == at(3) # 03:00 in New York in October is 07:00 UTC. assert job.last_slot(at(8), 3, NY) == at(7) def test_due_once_per_slot_and_retried_after_an_hour(): assert job.is_due(at(4), 3, UTC, last_success=None, last_attempt=None) assert not job.is_due(at(4), 3, UTC, last_success=at(3, 1), last_attempt=at(3, 1)) # Yesterday's success doesn't cover today's slot: a missed day is made up. assert job.is_due(at(9), 3, UTC, last_success=at(3, 1, day=11), last_attempt=at(3, 1, day=11)) # A failure at 03:01 waits an hour before the next try. assert not job.is_due(at(3, 30), 3, UTC, last_success=None, last_attempt=at(3, 1)) assert job.is_due(at(4, 2), 3, UTC, last_success=None, last_attempt=at(3, 1)) def test_history_keeps_the_newest_thirty(): history = [{"n": i} for i in range(30)] kept = job.history_with(history, {"n": "new"}) assert len(kept) == job.HISTORY_KEEP and kept[0] == {"n": "new"} and kept[-1] == {"n": 28} def _settings(**over): return {**DEFAULTS, "backup.s3_endpoint": "http://archives:8081", "backup.s3_bucket": "steward", "backup.s3_access_key": "AK", "backup.s3_secret_key": "SK", **over} def test_a_target_needs_every_field(): t = job.target_from(_settings()) assert t.endpoint == "http://archives:8081" and t.bucket == "steward" and t.region == "us-east-1" assert job.target_from(_settings(**{"backup.s3_secret_key": ""})) is None def test_the_hour_is_shown_on_the_readers_clock(): assert job.shown_hour(3, NY, UTC, now=at(12)) == 7 assert job.shown_hour(3, UTC, UTC, now=at(12)) == 3 def test_the_verdict_names_what_matters_first(): ok = {"at": at(3).isoformat(), "ok": True, "name": "steward-x.tar", "size": 2048, "error": ""} bad = {"at": at(4).isoformat(), "ok": False, "name": "", "size": 0, "error": "HTTP 403: the key is wrong."} assert job.verdict(DEFAULTS, False)["tone"] == "off" assert job.verdict(_settings(**{"backup.enabled": True}), False)["tone"] == "warn" assert job.verdict(_settings(**{"backup.enabled": True, "backup.s3_bucket": ""}), False)["tone"] == "bad" good = job.verdict(_settings(**{"backup.enabled": True, "backup.history": [ok], "backup.last_success_at": ok["at"]}), False) assert good["tone"] == "ok" and "steward-x.tar (2.0 KiB)" in good["text"] and good["moment"] == at(3) failed = job.verdict(_settings(**{"backup.enabled": True, "backup.history": [bad, ok], "backup.last_success_at": ok["at"]}), False) assert failed["tone"] == "bad" and failed["error"] == bad["error"] and failed["good"] == at(3) assert job.verdict(DEFAULTS, True)["text"] == "A backup is being made now" def test_test_button_reads_an_append_only_refusal_as_reached(monkeypatch): target = job.target_from(_settings()) async def denied(*a, **k): raise S3Error("Listing steward refused: HTTP 403 AccessDenied: the key isn't allowed to do that.") async def wrong(*a, **k): raise S3Error("Listing steward refused: HTTP 403 SignatureDoesNotMatch: the secret key is wrong.") async def two(*a, **k): return [StoredObject("steward-20261011T030000Z.tar", 1, ""), StoredObject("steward-20261012T030000Z.tar", 1, "")] monkeypatch.setattr(job, "list_objects", denied) ok, said = asyncio.run(job.test_target(target)) assert ok and "append-only" in said monkeypatch.setattr(job, "list_objects", wrong) ok, said = asyncio.run(job.test_target(target)) assert not ok and "secret key is wrong" in said monkeypatch.setattr(job, "list_objects", two) ok, said = asyncio.run(job.test_target(target)) assert ok and "2 Steward backups, the newest steward-20261012T030000Z.tar" in said async def _render(app, **ctx): async with app.test_request_context("/settings/backups/"): session["user_role"] = "admin" session["tz"] = "America/New_York" return await render_template("settings/backups.html", **ctx) async def test_the_page_leads_with_the_verdict_and_lists_runs_in_the_readers_zone(app): rows = job.history_rows([ {"at": at(4).isoformat(), "ok": False, "by": "schedule", "name": "", "size": 0, "secs": 1.2, "error": "HTTP 403 SignatureDoesNotMatch: the secret key is wrong."}, {"at": at(3).isoformat(), "ok": True, "by": "admin", "name": "steward-20261012T030000Z.tar", "size": 2048, "secs": 3.4, "error": ""}, ]) settings = _settings(**{"backup.enabled": True}) v = {"tone": "bad", "text": "The last backup failed", "moment": at(4), "error": rows[0]["error"], "good": at(3), "failed": True} page = await _render(app, settings=settings, flash=None, shown_hour=3, verdict=v, history=rows, has_secret=True) main = page[page.index("' in page