wrolpi/modules/zim/test/test_read_config_value.py
Roland aeebe8d833
zim: address PR review — config race, YAML null, path traversal, pinned build
Triage of Greptile findings on the default-Zim / configurable-directory PR:

- Stale config on restart (P1): settings update() only queues an async
  background_save, and the restart switch has no ordering guarantee (the switch
  worker pops LIFO), so start_kiwix_serve.sh could read the old zims_destination.
  Save the config synchronously before activating the restart switch.
- YAML null (P1): read_config_value.sh now treats an unquoted null/~ as unset
  (returns the default), matching PyYAML/the Python config layer.  A quoted
  "null" remains the literal string.
- Path traversal (P1/security): the settings API now rejects a zims_destination
  containing "..", and start_kiwix_serve.sh defends in depth by falling back to
  `zims` for any absolute/traversal/empty value, so Kiwix can never scan or
  rewrite library.xml outside the media root.
- Reproducible build (P2): pin the zim-tools image to 3.7.0 instead of :latest.

Tests added for null handling, traversal rejection (API + script).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:10:09 -06:00

84 lines
2.9 KiB
Python

"""Tests for wrolpi/scripts/read_config_value.sh.
The reader extracts a top-level scalar from wrolpi.yaml with zero dependencies
so non-Python scripts (e.g. start_kiwix_serve.sh, the Docker Zim entrypoint) can
read config values. Exercised directly via bash.
"""
import os
import pathlib
import subprocess
import pytest
REPO_ROOT = pathlib.Path(__file__).parents[3]
READER = REPO_ROOT / 'wrolpi' / 'scripts' / 'read_config_value.sh'
def _read(media: pathlib.Path, key: str, default: str = '') -> str:
env = {**os.environ, 'MEDIA_DIRECTORY': str(media)}
result = subprocess.run(['bash', str(READER), key, default], env=env,
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
return result.stdout.strip('\n')
@pytest.fixture
def media_with_config(tmp_path):
media = tmp_path / 'media'
(media / 'config').mkdir(parents=True)
def write(text: str):
(media / 'config' / 'wrolpi.yaml').write_text(text)
return media, write
def test_reads_scalar_value(media_with_config):
media, write = media_with_config
write('zims_destination: mycustomzims\nvideos_destination: videos\n')
assert _read(media, 'zims_destination', 'zims') == 'mycustomzims'
def test_strips_surrounding_quotes(media_with_config):
media, write = media_with_config
write('videos_destination: "quoted videos"\n')
assert _read(media, 'videos_destination', 'x') == 'quoted videos'
def test_missing_key_returns_default(media_with_config):
media, write = media_with_config
write('videos_destination: videos\n')
assert _read(media, 'zims_destination', 'zims') == 'zims'
def test_empty_value_returns_default(media_with_config):
media, write = media_with_config
write('zims_destination:\n')
assert _read(media, 'zims_destination', 'zims') == 'zims'
@pytest.mark.parametrize('null_repr', ['null', 'Null', 'NULL', '~'])
def test_yaml_null_returns_default(media_with_config, null_repr):
"""An unquoted YAML null means "unset" -> default, matching the Python config layer."""
media, write = media_with_config
write(f'zims_destination: {null_repr}\n')
assert _read(media, 'zims_destination', 'zims') == 'zims'
def test_quoted_null_is_literal_string(media_with_config):
"""A quoted "null" is the string null, not YAML null."""
media, write = media_with_config
write('zims_destination: "null"\n')
assert _read(media, 'zims_destination', 'zims') == 'null'
def test_missing_config_file_returns_default(tmp_path):
# No config directory/file at all (fresh install) -> default, no error.
assert _read(tmp_path / 'media', 'zims_destination', 'zims') == 'zims'
def test_ignores_indented_nested_keys(media_with_config):
media, write = media_with_config
# A nested key with the same name must not be matched at the top level.
write('some_section:\n zims_destination: nested\ntop: value\n')
assert _read(media, 'zims_destination', 'zims') == 'zims'