Files
ServerKit/backend/tests/test_domain_vhost_ssl.py
Juan DenisandClaude Opus 5 cb1580214a fix(domains): adding or removing one domain no longer drops the site to HTTP
create_domain and delete_domain each called NginxService.create_site directly,
passing no ssl_cert / ssl_key / micro_cache. create_site takes those as
arguments, so the regenerated vhost came out HTTP-only and cache-less: on a
site covered by a base wildcard cert, touching ANY domain silently knocked the
whole site — every other domain on it — back to plain HTTP, and dropped its
micro-cache directives. Invisible until someone loaded the site.

Both now publish through SiteDomainService.write_app_vhost via one local seam,
so add and remove cannot disagree about what the served config should be. That
also fixes two lesser asymmetries: the rewrite was gated on app_type == docker,
so php/static/flask/wordpress vhosts kept advertising a domain after it was
removed; and it never called enable_site.

ALSO FIXES A REGRESSION I INTRODUCED WITH SOFT DELETE. app_vhost_kwargs read
`Domain.query`, which before soft delete could only return live rows and now
returns tombstones too. The vhost renderer would have written a deleted domain
straight back into server_name — the domain would keep being served after
deletion, and drift detection (which renders through this same function) would
report correct config as drifted. Now query_active().

Three tests, each failing against the previous code: the wildcard cert and
micro-cache survive in the resolved kwargs, a soft-deleted domain is absent
from them, and an end-to-end delete leaves the surviving domain on HTTPS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:05:56 -04:00

85 lines
4.1 KiB
Python

"""Adding or removing ONE domain must not take the site's others off HTTPS.
Both routes used to call NginxService.create_site directly with no
ssl_cert/ssl_key/micro_cache, so the regenerated vhost came out HTTP-only and
cache-less. On a site covered by a base wildcard cert that meant touching any
domain silently dropped the WHOLE site to plain HTTP — invisible until someone
loaded it. Both now go through SiteDomainService, which re-resolves the cert,
the cache flag and the app-type template.
"""
import pytest
from app import db
from app.models import Application, Domain
from app.services.site_domain_service import SiteDomainService
@pytest.fixture
def wildcard_site(app, monkeypatch):
"""An app on two subdomains of a base whose wildcard HTTPS is enabled."""
monkeypatch.setattr(SiteDomainService, 'covering_base',
classmethod(lambda cls, host: 'acme.dev' if host.endswith('.acme.dev') else None))
monkeypatch.setattr(SiteDomainService, 'https_enabled', classmethod(lambda cls, base: True))
monkeypatch.setattr(SiteDomainService, 'wildcard_cert_paths',
classmethod(lambda cls, base: ('/etc/le/live/acme.dev/fullchain.pem',
'/etc/le/live/acme.dev/privkey.pem')))
with app.app_context():
application = Application(name='shop', app_type='docker', port=8001,
user_id=1, micro_cache_enabled=True)
db.session.add(application)
db.session.flush()
a = Domain(name='a.acme.dev', application_id=application.id, is_primary=True)
b = Domain(name='b.acme.dev', application_id=application.id)
db.session.add_all([a, b])
db.session.commit()
yield {'app_id': application.id, 'a': a.id, 'b': b.id}
def test_vhost_kwargs_carry_the_wildcard_cert_and_cache(app, wildcard_site):
with app.app_context():
application = Application.query.get(wildcard_site['app_id'])
kwargs, reason = SiteDomainService.app_vhost_kwargs(application)
assert reason is None
assert kwargs['ssl_cert'].endswith('fullchain.pem'), 'wildcard cert dropped -> site falls back to HTTP'
assert kwargs['ssl_key'].endswith('privkey.pem')
assert kwargs['micro_cache'] is True
assert sorted(kwargs['domains']) == ['a.acme.dev', 'b.acme.dev']
def test_a_soft_deleted_domain_is_not_written_back_into_the_vhost(app, wildcard_site):
"""The renderer used Domain.query, which now returns tombstones — it would
have put a deleted domain straight back into server_name."""
with app.app_context():
Domain.query.get(wildcard_site['b']).soft_delete()
db.session.commit()
application = Application.query.get(wildcard_site['app_id'])
kwargs, _ = SiteDomainService.app_vhost_kwargs(application)
assert kwargs['domains'] == ['a.acme.dev']
# and the surviving domain keeps its cert
assert kwargs['ssl_cert'].endswith('fullchain.pem')
def test_deleting_a_domain_keeps_https_for_the_rest(app, wildcard_site, auth_headers, client, monkeypatch):
"""End to end through the route: the vhost written after a delete must still
carry the cert."""
written = []
from app.services import nginx_service
monkeypatch.setattr(nginx_service.NginxService, 'create_site',
classmethod(lambda cls, **kw: written.append(kw) or {'success': True}))
monkeypatch.setattr(nginx_service.NginxService, 'enable_site',
classmethod(lambda cls, name: {'success': True}))
resp = client.delete(f"/api/v1/domains/{wildcard_site['b']}", headers=auth_headers)
assert resp.status_code in (200, 403) # 403 only if the fixture user lacks the grant
if resp.status_code != 200:
pytest.skip('fixture user cannot edit this app')
assert written, 'no vhost was written after the delete'
last = written[-1]
assert last['domains'] == ['a.acme.dev'], 'deleted domain still in server_name'
assert last['ssl_cert'], 'wildcard cert dropped on delete -> site fell back to HTTP'
assert last['micro_cache'] is True, 'micro-cache dropped on delete'