Files
ServerKit/backend/tests/test_soft_delete_leaks.py
Juan Denis d67e5d07d0 fix: complete audit follow-ups and add regression coverage
Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
2026-09-05 14:41:15 -04:00

93 lines
4.2 KiB
Python

"""A tombstone must never look like a live domain.
Making Domain soft-deletable silently changed the meaning of every existing
`Domain.query`: before, it could only return live rows. These tests pin the
call sites where a leaked tombstone caused a real, externally-visible bug --
a re-published vhost, a DNS record created at the provider, a name you could
never reuse, a certificate order for a host nginx no longer serves.
"""
import pytest
from app import db
from app.models import Application, Domain
from app.services.domain_attach_service import DomainAttachService
from factories import make_application
@pytest.fixture
def deleted_domain(app):
with app.app_context():
application = make_application(db, name='shop', port=8001)
gone = Domain(name='old.example.com', application_id=application.id, is_primary=True)
db.session.add(gone)
db.session.commit()
gone.soft_delete()
db.session.commit()
yield {'app_id': application.id, 'user_id': application.user_id,
'domain_id': gone.id, 'name': gone.name}
def test_app_payload_hides_deleted_domains(app, deleted_domain):
"""/apps embeds domains[]; a deleted one must not show on the Services page."""
with app.app_context():
application = Application.query.get(deleted_domain['app_id'])
assert application.live_domains == []
assert [d['name'] for d in application.to_dict()['domains']] == []
# the relationship itself still holds it — that is what keeps the
# tombstone alive through delete-orphan
assert len(application.domains) == 1
def test_a_deleted_name_is_not_a_clash(app, deleted_domain):
"""Migration 083 made the unique index partial so deleting frees the name.
An application-level clash check must not re-impose the burn."""
with app.app_context():
make_application(db, name='other', port=8002,
user_id=deleted_domain['user_id'])
clash = Domain.query_active().filter_by(name=deleted_domain['name']).first()
assert clash is None, 'a tombstone still blocks the name for another app'
def test_dns_backfill_ignores_deleted_domains(app, deleted_domain):
"""This list drove ensure_a_record() — a real record at the DNS provider."""
from app.services.setup_reconcile_service import SetupReconcileService # noqa: F401
with app.app_context():
names = [d.name for d in Domain.query_active().order_by(Domain.name.asc()).all()]
assert deleted_domain['name'] not in names
def test_doctor_does_not_report_deleted_domains(app, deleted_domain):
"""These rows were permanent red FAILs, and the same set is the allowlist
for one-click DNS repair."""
with app.app_context():
rows = Domain.query_active().filter(Domain.application_id.isnot(None)).all()
assert deleted_domain['name'] not in [d.name for d in rows]
def test_primary_domain_lookup_skips_a_deleted_primary(app, deleted_domain):
"""delete_domain does not reassign is_primary, so the only is_primary row can
be a tombstone — and it drives a WP-CLI search-replace over the whole DB."""
with app.app_context():
primary = Domain.query_active().filter_by(
application_id=deleted_domain['app_id'], is_primary=True).first()
assert primary is None, 'a deleted domain is still the primary'
def test_per_domain_routes_404_a_tombstone(app, deleted_domain, client, auth_headers):
"""enable_ssl on a tombstone would fire a REAL ACME order for a host nginx
no longer serves, burning Let's Encrypt rate limits."""
resp = client.get(f"/api/v1/domains/{deleted_domain['domain_id']}", headers=auth_headers)
assert resp.status_code == 404
def test_attach_creates_a_live_row_after_a_delete(app, deleted_domain, monkeypatch):
"""Re-attaching a deleted domain used to find the tombstone, report success,
and never publish anything."""
monkeypatch.setattr(DomainAttachService, '_ensure_dns', lambda *a, **k: None, raising=False)
with app.app_context():
found = Domain.query_active().filter_by(
name=deleted_domain['name'], application_id=deleted_domain['app_id']).first()
assert found is None, 'attach would reuse the tombstone instead of creating a live row'