Files
ServerKit/backend/tests/test_wp_php_limits.py
T
Juan DenisandClaude Opus 4.8 aaf99212cb feat(wordpress): durable per-site PHP limit writes + wire the PHP tab (#24)
Finish #24's deferred limit-WRITE side, Docker-correct for wordpress:*-apache
(Apache + mod_php):

- set_php_limits: whitelist + re.fullmatch-validate each directive (fullmatch so
  a newline-smuggled extra ini line can't pass the $ anchor), write a host-side
  php-conf/zz-serverkit.ini and bind-mount that single file into conf.d so it
  survives container recreate. File written before the mount is added; new mount
  applies via compose up, content-only change via restart so php.ini reloads.
  Partial updates merge; mount injected once.
- Route POST /sites/<id>/php/limits (admin), client setPhpLimits, and an Edit
  PHP Limits panel in the PHP tab (inputs seeded from live values, partial save).
- Fix latent bug: PhpTab (the #24 read panel) was built but never wired into
  WordPressDetail (no tab button / render branch) — the whole PHP panel was
  invisible. Added 'php' to VALID_TABS + a tab + render branch.

7 new tests; full backend suite 200 passed / 12 skipped; frontend lint-clean +
production build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:43:12 -04:00

106 lines
4.7 KiB
Python

"""Tests for #24 per-site PHP limit writes — validation + durable conf.d ini /
bind-mount. The Docker apply step is mocked (no Docker on the dev/CI host); the
file-rendering, compose-mutation, merge, and injection-rejection logic is real."""
import os
import yaml
def _compose_site(tmp_path):
"""A minimal Docker-stack WP site dir with a docker-compose.yml."""
(tmp_path / 'docker-compose.yml').write_text(
"services:\n"
" wordpress:\n"
" image: wordpress:6.4-apache\n"
" volumes:\n"
" - wordpress_html:/var/www/html\n"
)
return str(tmp_path)
def _mock_docker(monkeypatch):
from app.services.wordpress_service import WordPressService
from app.services.docker_service import DockerService
monkeypatch.setattr(DockerService, 'compose_up', lambda *a, **k: {'success': True})
monkeypatch.setattr(DockerService, 'compose_restart', lambda *a, **k: {'success': True})
monkeypatch.setattr(WordPressService, '_wait_for_wp_ready', staticmethod(lambda p: None))
monkeypatch.setattr(WordPressService, 'get_php_info', staticmethod(lambda p: {'limits': {}}))
# ---- validation / injection rejection (no Docker needed) -------------------
def test_rejects_unknown_directive(tmp_path):
from app.services.wordpress_service import WordPressService
res = WordPressService.set_php_limits(_compose_site(tmp_path), {'evil_directive': '1'})
assert res['success'] is False and 'Unknown PHP limit' in res['error']
def test_rejects_injection_value(tmp_path):
from app.services.wordpress_service import WordPressService
# A newline-smuggled extra directive must not pass validation.
res = WordPressService.set_php_limits(_compose_site(tmp_path),
{'memory_limit': '256M\nevil = 1'})
assert res['success'] is False and 'Invalid value' in res['error']
# ...and nothing was written.
assert not os.path.exists(os.path.join(_compose_site(tmp_path), 'php-conf', 'zz-serverkit.ini'))
def test_rejects_non_stack_site(tmp_path):
from app.services.wordpress_service import WordPressService
res = WordPressService.set_php_limits(str(tmp_path), {'memory_limit': '256M'})
assert res['success'] is False and 'Not a Docker-stack site' in res['error']
# ---- durable write + bind-mount (Docker mocked) ----------------------------
def test_writes_ini_and_injects_single_file_mount(tmp_path, monkeypatch):
from app.services.wordpress_service import WordPressService
_mock_docker(monkeypatch)
path = _compose_site(tmp_path)
res = WordPressService.set_php_limits(path, {'memory_limit': '512M', 'max_execution_time': '120'})
assert res['success'] is True
ini = os.path.join(path, 'php-conf', 'zz-serverkit.ini')
content = open(ini).read()
assert 'memory_limit = 512M' in content
assert 'max_execution_time = 120' in content
vols = yaml.safe_load(open(os.path.join(path, 'docker-compose.yml')))['services']['wordpress']['volumes']
assert any('php-conf/zz-serverkit.ini:/usr/local/etc/php/conf.d/zz-serverkit.ini' in v for v in vols)
def test_partial_update_merges_and_mount_not_duplicated(tmp_path, monkeypatch):
from app.services.wordpress_service import WordPressService
_mock_docker(monkeypatch)
path = _compose_site(tmp_path)
WordPressService.set_php_limits(path, {'memory_limit': '256M'})
res = WordPressService.set_php_limits(path, {'upload_max_filesize': '64M'})
assert res['success'] is True
# The second (partial) update keeps the first directive.
assert res['limits']['memory_limit'] == '256M'
assert res['limits']['upload_max_filesize'] == '64M'
# The single-file mount is added once, not duplicated on the second call.
vols = yaml.safe_load(open(os.path.join(path, 'docker-compose.yml')))['services']['wordpress']['volumes']
assert len([v for v in vols if 'zz-serverkit.ini' in v]) == 1
def test_accepts_unlimited_and_size_suffixes(tmp_path, monkeypatch):
from app.services.wordpress_service import WordPressService
_mock_docker(monkeypatch)
path = _compose_site(tmp_path)
res = WordPressService.set_php_limits(path, {'memory_limit': '-1', 'max_input_time': '-1',
'upload_max_filesize': '2G', 'max_input_vars': '5000'})
assert res['success'] is True
assert res['limits']['memory_limit'] == '-1'
# ---- ini parsing -----------------------------------------------------------
def test_read_php_ini_skips_comments_and_garbage(tmp_path):
from app.services.wordpress_service import WordPressService
p = tmp_path / 'x.ini'
p.write_text('; a comment\nmemory_limit = 128M\n\nno equals here\nmax_input_vars = 2000\n')
assert WordPressService._read_php_ini(str(p)) == {'memory_limit': '128M', 'max_input_vars': '2000'}