130 lines
5.8 KiB
Python
130 lines
5.8 KiB
Python
import importlib.util
|
|
from pathlib import Path
|
|
import unittest
|
|
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"backup_restore_acceptance",
|
|
Path(__file__).resolve().parents[1] / "deploy" / "backup_restore_acceptance.py")
|
|
acceptance = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(acceptance)
|
|
|
|
COMMIT = "a" * 40
|
|
RELEASE_SHA = "b" * 64
|
|
|
|
|
|
def valid_record():
|
|
return {
|
|
"schemaVersion": 1,
|
|
"system": "guestops-backup-recovery",
|
|
"evidenceId": "backup-restore",
|
|
"dataClassification": "synthetic-only",
|
|
"releaseVersion": "0.2.1",
|
|
"releaseCommit": COMMIT,
|
|
"releaseRecordSha256": RELEASE_SHA,
|
|
"archiveSha256": "c" * 64,
|
|
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
|
"hostIdentifier": "guestops-sandbox-01",
|
|
"images": {
|
|
"api": {"reference": f"guestops-api:{COMMIT}", "id": "sha256:" + "d" * 64},
|
|
"worker": {"reference": f"guestops-worker:{COMMIT}", "id": "sha256:" + "e" * 64},
|
|
"mongo": {"reference": "mongo:8.0", "id": "sha256:" + "f" * 64},
|
|
},
|
|
"owners": {
|
|
"backup": "Backup owner",
|
|
"monitoring": "Monitoring owner",
|
|
"recoveryOperator": "Recovery operator",
|
|
"technicalEscalation": "Technical owner",
|
|
"retention": "Retention owner",
|
|
"independentReviewer": "Independent reviewer",
|
|
},
|
|
"startedAt": "2026-10-01T09:00:00Z",
|
|
"endedAt": "2026-10-01T12:00:00Z",
|
|
"reviewedAt": "2026-10-01T13:00:00Z",
|
|
"recoveryObjectives": {
|
|
"targetRpoHours": 24, "observedRpoHours": 1,
|
|
"targetRtoMinutes": 240, "observedRtoMinutes": 180,
|
|
},
|
|
"retention": {
|
|
"localVerifiedDays": 7, "offHostDaily": 35,
|
|
"offHostMonthly": 12, "legalHoldOverrideTested": True,
|
|
},
|
|
"backup": {
|
|
"createdAt": "2026-10-01T08:00:00Z",
|
|
"sha256": "1" * 64,
|
|
"transferredSha256": "1" * 64,
|
|
"privateKeyPresentOnHost": False,
|
|
},
|
|
"rollback": {
|
|
"previousReleaseCommit": "2" * 40,
|
|
"previousArchiveSha256": "3" * 64,
|
|
"previousImages": {
|
|
"api": {"reference": "guestops-api:" + "2" * 40, "id": "sha256:" + "4" * 64},
|
|
"worker": {"reference": "guestops-worker:" + "2" * 40, "id": "sha256:" + "5" * 64},
|
|
},
|
|
"persistentVolumesReplaced": False,
|
|
"restoredReleaseCommit": COMMIT,
|
|
"unresolvedOperations": 0,
|
|
"finalControls": {
|
|
"googleSending": "disabled", "faqLiveMode": "disabled",
|
|
"pmsWrites": "disabled", "paymentCreation": "disabled",
|
|
},
|
|
},
|
|
"monitoringState": "healthy",
|
|
"unresolvedCriticalFindings": 0,
|
|
"scenarios": [
|
|
{"id": scenario, "status": "pass", "evidence": [f"restricted-{index}"]}
|
|
for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1)
|
|
],
|
|
}
|
|
|
|
|
|
class BackupRestoreAcceptanceTests(unittest.TestCase):
|
|
def test_complete_record_passes(self):
|
|
acceptance.validate(valid_record(), COMMIT, RELEASE_SHA)
|
|
|
|
def test_release_identity_and_images_are_bound(self):
|
|
record = valid_record(); record["releaseCommit"] = "9" * 40
|
|
with self.assertRaisesRegex(ValueError, "approved candidate"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
record = valid_record(); record["images"]["api"]["reference"] = "guestops-api:latest"
|
|
with self.assertRaisesRegex(ValueError, "full approved commit"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
record = valid_record(); record["rollback"]["previousImages"]["worker"]["id"] = "mutable"
|
|
with self.assertRaisesRegex(ValueError, "retained previous release identity"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
|
|
def test_recovery_objectives_and_checksums_must_pass(self):
|
|
record = valid_record(); record["recoveryObjectives"]["observedRtoMinutes"] = 241
|
|
with self.assertRaisesRegex(ValueError, "four-hour"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
record = valid_record(); record["backup"]["transferredSha256"] = "9" * 64
|
|
with self.assertRaisesRegex(ValueError, "checksums must match"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
record = valid_record(); record["recoveryObjectives"]["observedRpoHours"] = 2
|
|
with self.assertRaisesRegex(ValueError, "match the backup"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
|
|
def test_independent_review_retention_and_final_state_are_required(self):
|
|
record = valid_record(); record["owners"]["independentReviewer"] = record["owners"]["backup"]
|
|
with self.assertRaisesRegex(ValueError, "different"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
record = valid_record(); record["retention"]["legalHoldOverrideTested"] = False
|
|
with self.assertRaisesRegex(ValueError, "Retention"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
record = valid_record(); record["rollback"]["persistentVolumesReplaced"] = True
|
|
with self.assertRaisesRegex(ValueError, "must not replace"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
|
|
def test_exact_passed_scenarios_and_monitoring_are_required(self):
|
|
record = valid_record(); record["scenarios"].pop()
|
|
with self.assertRaisesRegex(ValueError, "exact backup/recovery scenario"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
record = valid_record(); record["monitoringState"] = "degraded"
|
|
with self.assertRaisesRegex(ValueError, "Monitoring must be healthy"):
|
|
acceptance.validate(record, COMMIT, RELEASE_SHA)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|