Ctrl K
ComunidadGuías…

tests/test_admin_settings.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""The admin settings page, and the config file it edits.

Two behaviours matter more than the form itself. A read-only configuration file
is a *normal* deployment (a ConfigMap, a read-only mount, a baked image), so the
page has to stay useful and explain itself rather than failing at the point of
saving. And a save must keep the file's comments — they are what makes the file
editable by hand, and stripping them on the first save would be a poor trade.
"""

from __future__ import annotations

import asyncio
import datetime as dt
import os
import stat
from types import SimpleNamespace
from zoneinfo import ZoneInfo

import pytest
from formdata import as_sent

from app import configfile, lifecycle
from app.settings import PortfolioSettings

HTML = {"accept": "text/html"}

DOCUMENTED = """\
# The application's configuration. Every option is documented here.
app_name: portfolio

# Every date decision is made in this zone.
timezone: Australia/Melbourne

price_feed:
  enabled: true
  hour: 18       # after the market closes
  minute: 30

auth:
  session_ttl_days: 30
  rate_limit:
    max_attempts: 8   # per email and address
"""


@pytest.fixture
def config_file(tmp_path, monkeypatch):
    """A real config file on disk, pointed at by the module under test."""
    path = tmp_path / "config.yaml"
    path.write_text(DOCUMENTED, encoding="utf-8")
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    return path


# --------------------------------------------------------------------------- #
# Can we write it?
# --------------------------------------------------------------------------- #

def test_a_normal_file_is_writable(config_file):
    assert configfile.writability().writable is True


def test_a_missing_file_is_reported_rather_than_crashing(tmp_path, monkeypatch):
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(tmp_path / "absent.yaml"))

    state = configfile.writability()

    assert state.writable is False
    assert "No configuration file" in state.reason


def test_a_read_only_directory_is_detected(config_file, monkeypatch):
    """The ConfigMap case: saving replaces the file through a neighbouring
    temporary file, so an unwritable directory means no save is possible even
    when the file's own bits look fine."""
    directory = config_file.parent
    original = stat.S_IMODE(os.stat(directory).st_mode)
    os.chmod(directory, 0o500)   # r-x: readable, not writable
    try:
        state = configfile.writability()
    finally:
        os.chmod(directory, original)

    assert state.writable is False
    assert "read-only" in state.reason
    # The explanation has to name the shapes this actually happens in, or an
    # administrator is left guessing at their own deployment.
    assert "ConfigMap" in state.reason
    assert str(config_file) in state.reason


def test_the_read_only_explanation_says_what_to_do_instead(config_file, monkeypatch):
    directory = config_file.parent
    original = stat.S_IMODE(os.stat(directory).st_mode)
    os.chmod(directory, 0o500)
    try:
        reason = configfile.writability().reason
    finally:
        os.chmod(directory, original)

    assert "restart" in reason.lower()


# --------------------------------------------------------------------------- #
# Writing
# --------------------------------------------------------------------------- #

def test_saving_keeps_every_comment(config_file):
    """The whole point of a documented config file is that it survives being
    written to. A plain YAML dump would strip all of this."""
    configfile.save({"price_feed.hour": 9})

    written = config_file.read_text(encoding="utf-8")
    assert "# The application's configuration. Every option is documented here." in written
    assert "# Every date decision is made in this zone." in written
    assert "# after the market closes" in written
    assert "# per email and address" in written


def test_saving_changes_only_what_was_given(config_file):
    configfile.save({"price_feed.hour": 9})

    data = configfile.load()
    assert data["price_feed"]["hour"] == 9
    assert data["price_feed"]["minute"] == 30          # untouched
    assert data["auth"]["session_ttl_days"] == 30      # untouched
    assert data["timezone"] == "Australia/Melbourne"   # untouched


def test_a_nested_value_can_be_written(config_file):
    configfile.save({"auth.rate_limit.max_attempts": 4})

    assert configfile.load()["auth"]["rate_limit"]["max_attempts"] == 4


def test_a_key_absent_from_the_file_is_created(config_file):
    # `imports` is not in this file at all.
    configfile.save({"imports.allow_new_instruments": True})

    assert configfile.load()["imports"]["allow_new_instruments"] is True


def test_a_date_is_written_as_a_plain_string(config_file):
    configfile.save({"price_feed.backfill_start": dt.date(2015, 6, 1)})

    assert "2015-06-01" in config_file.read_text(encoding="utf-8")
    assert str(configfile.load()["price_feed"]["backfill_start"]) == "2015-06-01"


def test_the_file_is_replaced_atomically(config_file):
    """No half-written config if the process dies mid-save, and no temporary
    file left behind afterwards."""
    configfile.save({"price_feed.hour": 7})

    leftovers = [p.name for p in config_file.parent.iterdir() if p.name != "config.yaml"]
    assert leftovers == []


# --------------------------------------------------------------------------- #
# Validation
# --------------------------------------------------------------------------- #

def option(name: str) -> configfile.Option:
    return configfile.BY_NAME[name]


def test_a_bad_timezone_is_refused_with_a_usable_message():
    with pytest.raises(ValueError, match="not a known timezone"):
        configfile.coerce(option("timezone"), "Australia/Nowhere")


def test_a_timezone_too_long_for_the_filesystem_is_refused_the_same_way():
    """ZoneInfo looks the name up as a file, so one too long is an OSError
    rather than not-found, and it was a 500 from the settings page."""
    with pytest.raises(ValueError, match="not a known timezone"):
        configfile.coerce(option("timezone"), "x" * 5000)


def test_a_good_timezone_passes():
    assert configfile.coerce(option("timezone"), "Europe/Dublin") == "Europe/Dublin"


@pytest.mark.parametrize("raw", ["not-a-number", "", "9.5"])
def test_a_non_integer_is_refused(raw):
    with pytest.raises(ValueError, match="whole number"):
        configfile.coerce(option("price_feed.hour"), raw)


@pytest.mark.parametrize("raw,message", [("24", "at most"), ("-1", "at least")])
def test_an_out_of_range_integer_is_refused(raw, message):
    with pytest.raises(ValueError, match=message):
        configfile.coerce(option("price_feed.hour"), raw)


def test_a_checkbox_is_read_as_a_boolean():
    assert configfile.coerce(option("auth.cookie_secure"), "on") is True
    assert configfile.coerce(option("auth.cookie_secure"), "") is False


def test_an_unknown_choice_is_refused():
    with pytest.raises(ValueError, match="must be one of"):
        configfile.coerce(option("log_level"), "CHATTY")


def test_a_malformed_date_is_refused():
    with pytest.raises(ValueError, match="YYYY-MM-DD"):
        configfile.coerce(option("price_feed.backfill_start"), "01/06/2015")


# --------------------------------------------------------------------------- #
# What the page shows
# --------------------------------------------------------------------------- #

def test_an_environment_override_is_detected(monkeypatch):
    """Saving the file would change nothing an environment variable already
    decides, and the page says so rather than implying a save took effect."""
    monkeypatch.setenv("APP_AUTH__RATE_LIMIT__MAX_ATTEMPTS", "9")

    assert configfile.overridden_by_environment(option("auth.rate_limit.max_attempts"))
    assert not configfile.overridden_by_environment(option("price_feed.hour"))


def test_effective_values_come_from_the_running_settings():
    settings = PortfolioSettings()

    # tests/config.test.yaml sets 3; the field default is 8.
    assert configfile.effective(settings, option("auth.rate_limit.max_attempts")) == 3


def test_applying_live_updates_the_running_settings_and_the_clock():
    from app import clock

    settings = PortfolioSettings()
    try:
        pending = configfile.apply_live(settings, {
            "timezone": "Europe/Dublin",
            "auth.rate_limit.max_attempts": 7,
        })

        assert settings.timezone == "Europe/Dublin"
        assert settings.auth.rate_limit.max_attempts == 7
        assert str(clock.zone()) == "Europe/Dublin"
        assert pending == []   # neither of those needs a restart
    finally:
        clock.configure("Australia/Melbourne")


def test_settings_needing_a_restart_are_reported_as_such():
    settings = PortfolioSettings()
    try:
        pending = configfile.apply_live(settings, {"log_level": "DEBUG"})

        # Logging is configured once at startup, so the value is stored but
        # will not change what is written until the app restarts. Saying
        # "saved" without saying that would be a lie.
        assert pending == ["Log level"]
    finally:
        clock_reset = __import__("app.clock", fromlist=["clock"])
        clock_reset.configure("Australia/Melbourne")


def test_every_option_resolves_against_the_real_settings_object():
    """A typo in an option's path would render an empty field and silently
    write to the wrong key — so every declared path is walked."""
    settings = PortfolioSettings()

    for opt in configfile.OPTIONS:
        configfile.effective(settings, opt)   # raises AttributeError if wrong


# --------------------------------------------------------------------------- #
# The page itself
# --------------------------------------------------------------------------- #

def test_an_admin_sees_the_settings_page(client, session_factory):
    from test_routes import make_login

    make_login(client, session_factory, admin=True)

    page = client.get("/admin/settings", headers={"accept": "text/html"})

    assert page.status_code == 200
    assert "Timezone" in page.text
    assert "Failed sign-ins allowed" in page.text


def test_a_non_admin_cannot_reach_it(client, session_factory):
    from test_routes import make_login

    make_login(client, session_factory, admin=False)

    assert client.get("/admin/settings",
                      headers={"accept": "text/html"}).status_code == 403


def test_a_read_only_deployment_shows_the_values_but_no_save_button(client, session_factory,
                                                                   monkeypatch, tmp_path):
    """Read-only is normal, so the page must still be worth opening: every
    value visible, the reason stated, and nothing offering to save."""
    from test_routes import make_login

    # Its own directory: tmp_path also holds this test's database, and making
    # that read-only would break the app rather than just the config file.
    conf_dir = tmp_path / "config"
    conf_dir.mkdir()
    path = conf_dir / "config.yaml"
    path.write_text(DOCUMENTED, encoding="utf-8")
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    make_login(client, session_factory, admin=True)
    original = stat.S_IMODE(os.stat(conf_dir).st_mode)
    os.chmod(conf_dir, 0o500)
    try:
        page = client.get("/admin/settings", headers={"accept": "text/html"})
    finally:
        os.chmod(conf_dir, original)

    assert page.status_code == 200
    assert "read here but not changed" in page.text
    assert "ConfigMap" in page.text
    assert "disabled" in page.text          # the controls are greyed out
    assert "Save settings" not in page.text  # and there is nothing to press
    assert "Australia/Melbourne" in page.text  # …but the values are all there


def test_saving_through_a_read_only_file_is_refused(client, session_factory,
                                                    monkeypatch, tmp_path):
    """The page hides the controls, so a POST arriving anyway is refused rather
    than raising an unhandled write error."""
    from test_routes import make_login, session_csrf

    conf_dir = tmp_path / "config"
    conf_dir.mkdir()
    path = conf_dir / "config.yaml"
    path.write_text(DOCUMENTED, encoding="utf-8")
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    make_login(client, session_factory, admin=True)
    csrf = session_csrf(session_factory)
    original = stat.S_IMODE(os.stat(conf_dir).st_mode)
    os.chmod(conf_dir, 0o500)
    try:
        resp = client.post("/admin/settings",
                           data={"timezone": "Europe/Dublin", "_csrf": csrf},
                           headers={"accept": "text/html"})
    finally:
        os.chmod(conf_dir, original)

    assert resp.status_code == 409


# --------------------------------------------------------------------------- #
# Restarting
# --------------------------------------------------------------------------- #

def test_kubernetes_is_recognised_as_certain_to_restart(monkeypatch):
    monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.96.0.1")

    state = lifecycle.supervision()

    assert (state.restarts, state.certain) == (True, True)
    assert state.platform == "Kubernetes"


def test_a_container_is_recognised_but_only_probably(monkeypatch, tmp_path):
    """Nothing inside a container can see whether it was given a restart
    policy, so the wording must not promise one."""
    monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
    monkeypatch.setattr(lifecycle, "_in_container", lambda: True)

    state = lifecycle.supervision()

    assert state.restarts is True
    assert state.certain is False          # inferred, not known
    assert "restart policy" in state.detail


def test_a_plain_process_is_told_it_will_not_come_back(monkeypatch):
    monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False)
    monkeypatch.setattr(lifecycle, "_in_container", lambda: False)

    state = lifecycle.supervision()

    assert state.restarts is False
    assert "start it yourself" in state.detail


def test_the_restart_button_asks_the_process_to_stop(client, session_factory, monkeypatch):
    from test_routes import make_login, session_csrf

    asked = []
    monkeypatch.setattr(lifecycle, "request_stop", lambda reason: asked.append(reason))
    make_login(client, session_factory, admin=True)

    resp = client.post("/admin/settings/restart",
                       data={"_csrf": session_csrf(session_factory)},
                       headers={"accept": "text/html"})

    assert resp.status_code == 200
    assert len(asked) == 1
    assert "Restarting" in resp.text


def test_a_non_admin_cannot_restart_the_application(client, session_factory, monkeypatch):
    from test_routes import make_login, session_csrf

    asked = []
    monkeypatch.setattr(lifecycle, "request_stop", lambda reason: asked.append(reason))
    make_login(client, session_factory, admin=False)

    resp = client.post("/admin/settings/restart",
                       data={"_csrf": session_csrf(session_factory)},
                       headers={"accept": "text/html"})

    assert resp.status_code == 403
    assert asked == []


def test_restarting_needs_the_csrf_token(client, session_factory, monkeypatch):
    from test_routes import make_login

    asked = []
    monkeypatch.setattr(lifecycle, "request_stop", lambda reason: asked.append(reason))
    make_login(client, session_factory, admin=True)

    assert client.post("/admin/settings/restart").status_code == 403
    assert asked == []


def test_save_and_restart_does_both(client, session_factory, monkeypatch, tmp_path):
    """The point of the combined button: a setting that only applies at startup
    should not need two visits to take effect."""
    from test_routes import make_login, session_csrf

    conf_dir = tmp_path / "config"
    conf_dir.mkdir()
    path = conf_dir / "config.yaml"
    path.write_text(DOCUMENTED, encoding="utf-8")
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    asked = []
    monkeypatch.setattr(lifecycle, "request_stop", lambda reason: asked.append(reason))
    make_login(client, session_factory, admin=True)

    resp = client.post("/admin/settings",
                       data={"price_feed.hour": "7", "restart": "1",
                             "_csrf": session_csrf(session_factory)},
                       headers={"accept": "text/html"})

    assert resp.status_code == 200
    assert "Settings saved" in resp.text
    assert len(asked) == 1                              # …and it restarted
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    assert configfile.load()["price_feed"]["hour"] == 7  # …having saved first


def test_saving_without_the_restart_flag_does_not_restart(client, session_factory,
                                                          monkeypatch, tmp_path):
    from test_routes import make_login, session_csrf

    conf_dir = tmp_path / "config"
    conf_dir.mkdir()
    path = conf_dir / "config.yaml"
    path.write_text(DOCUMENTED, encoding="utf-8")
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    asked = []
    monkeypatch.setattr(lifecycle, "request_stop", lambda reason: asked.append(reason))
    make_login(client, session_factory, admin=True)

    client.post("/admin/settings",
                data={"price_feed.hour": "7", "_csrf": session_csrf(session_factory)},
                headers={"accept": "text/html"})

    assert asked == []


def test_restart_is_offered_even_when_the_config_is_read_only(client, session_factory,
                                                              monkeypatch, tmp_path):
    """A read-only config file is exactly the deployment where changes arrive
    from outside — so that is when a restart is most useful, not least."""
    from test_routes import make_login

    conf_dir = tmp_path / "config"
    conf_dir.mkdir()
    path = conf_dir / "config.yaml"
    path.write_text(DOCUMENTED, encoding="utf-8")
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))
    make_login(client, session_factory, admin=True)
    original = stat.S_IMODE(os.stat(conf_dir).st_mode)
    os.chmod(conf_dir, 0o500)
    try:
        page = client.get("/admin/settings", headers={"accept": "text/html"})
    finally:
        os.chmod(conf_dir, original)

    assert "Save settings" not in page.text   # nothing to save
    assert "Restart now" in page.text         # but restarting still makes sense


# --------------------------------------------------------------------------- #
# Saving the page as it is drawn
# --------------------------------------------------------------------------- #
# The tests above post the one field they are about. A browser posts every
# field on the page, and the feed timezone's box showed the zone it was only
# following: the first save, of anything, wrote that zone into the file, and a
# new timezone after it left the daily run in the old one.

def _as_sent(page: str) -> dict:
    """What a browser submits for the settings form as drawn: every field,
    a checkbox only when ticked, a list as its text."""
    return as_sent(page, "/admin/settings")


@pytest.fixture
def live_settings(monkeypatch, tmp_path):
    """The running settings and the clock, put back afterwards: a save
    applies every value to both."""
    from app import clock
    from app import main as main_mod

    conf_dir = tmp_path / "config"
    conf_dir.mkdir()
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(conf_dir / "config.yaml"))
    monkeypatch.setattr(lifecycle, "request_stop", lambda reason: None)
    live = main_mod.settings
    before = live.model_copy(deep=True)
    yield live, conf_dir / "config.yaml"
    for name in type(live).model_fields:
        setattr(live, name, getattr(before, name))
    clock.configure(before.timezone)


def _save_as_drawn(client, change: dict):
    form = _as_sent(client.get("/admin/settings", headers={"accept": "text/html"}).text)
    assert "timezone" in form and "price_feed.timezone" in form, "the form was not read"
    form.update(change)
    resp = client.post("/admin/settings", data=form, headers={"accept": "text/html"})
    assert resp.status_code == 200 and "Settings saved" in resp.text


def test_a_feed_timezone_left_to_follow_still_follows_after_a_save(client, session_factory,
                                                                   live_settings):
    from test_routes import make_login

    live, path = live_settings
    path.write_text(DOCUMENTED, encoding="utf-8")                          # no feed timezone of its own
    live.timezone = live.price_feed.timezone = "Australia/Melbourne"   # as loaded from it
    make_login(client, session_factory, admin=True)

    _save_as_drawn(client, {"auth.session_ttl_days": "14"})
    _save_as_drawn(client, {"timezone": "Australia/Perth"})

    assert live.price_feed.timezone == "Australia/Perth"
    assert (configfile.load().get("price_feed") or {}).get("timezone") is None


def test_a_feed_timezone_set_on_purpose_stays_set(client, session_factory, live_settings):
    from test_routes import make_login

    live, path = live_settings
    path.write_text(DOCUMENTED.replace("  minute: 30\n", "  minute: 30\n  timezone: Asia/Tokyo\n"), encoding="utf-8")
    live.timezone, live.price_feed.timezone = "Australia/Melbourne", "Asia/Tokyo"
    make_login(client, session_factory, admin=True)

    _save_as_drawn(client, {"timezone": "Australia/Perth"})

    assert live.price_feed.timezone == "Asia/Tokyo"
    assert configfile.load()["price_feed"]["timezone"] == "Asia/Tokyo"


def test_a_feed_timezone_from_the_environment_shows_as_set(live_settings, monkeypatch):
    live, path = live_settings
    path.write_text(DOCUMENTED, encoding="utf-8")
    monkeypatch.setenv("APP_PRICE_FEED__TIMEZONE", "Asia/Tokyo")
    live.price_feed.timezone = "Asia/Tokyo"

    assert configfile.shown(live, configfile.BY_NAME["price_feed.timezone"]) == "Asia/Tokyo"
    monkeypatch.delenv("APP_PRICE_FEED__TIMEZONE")
    assert configfile.shown(live, configfile.BY_NAME["price_feed.timezone"]) is None


def test_a_feed_section_left_empty_shows_its_timezone_following(live_settings):
    """Every line under `price_feed:` commented out reads as nothing at all,
    not as a block: the zone is not written there, so it follows."""
    live, path = live_settings
    path.write_text("timezone: Australia/Melbourne\nprice_feed:\n  # hour: 18\n", encoding="utf-8")

    assert configfile.shown(live, configfile.BY_NAME["price_feed.timezone"]) is None


# --------------------------------------------------------------------------- #
# When a save reaches the background jobs
# --------------------------------------------------------------------------- #

# 13:00 in Melbourne, 22:00 the evening before in New York.
NOW = dt.datetime(2026, 10, 12, 2, 0, tzinfo=dt.timezone.utc)


class _Stop(Exception):
    """Ends a background loop at its second wait."""


def _waits(monkeypatch, job, during_first_wait) -> list[float]:
    """Run one of main's background loops to its second wait, with the work
    stubbed and the clock stopped at NOW. `during_first_wait` runs where a
    save made while the loop sleeps would land. Returns each wait, in seconds."""
    from app import main as main_mod

    waits: list[float] = []

    async def wait(seconds):
        waits.append(seconds)
        if len(waits) > 1:
            raise _Stop
        during_first_wait()

    async def ready(what):
        return None

    class Stopped(dt.datetime):
        @classmethod
        def now(cls, tz=None):
            return NOW.astimezone(tz)

    monkeypatch.setattr(main_mod, "asyncio", SimpleNamespace(
        sleep=wait, get_running_loop=asyncio.get_running_loop))
    monkeypatch.setattr(main_mod, "dt", SimpleNamespace(datetime=Stopped, timedelta=dt.timedelta))
    monkeypatch.setattr(main_mod, "_await_database", ready)
    monkeypatch.setattr(main_mod, "_run_feed", lambda: True)
    monkeypatch.setattr(main_mod, "_should_poll_quotes", lambda: False)
    monkeypatch.setattr(main_mod.maintenance, "run", lambda *args: None)
    with pytest.raises(_Stop):
        asyncio.run(getattr(main_mod, job)())
    return waits


@pytest.mark.parametrize("job, schedule", [("_feed_loop", "price_feed"),
                                           ("_maintenance_loop", "maintenance")])
def test_a_feed_timezone_saved_from_the_page_moves_the_next_run(job, schedule, live_settings,
                                                                monkeypatch):
    """The page applies the feed timezone without a restart, and both daily
    jobs run in it. Each loop read the zone once, before its first run, so a
    save moved neither until the app restarted."""
    live, _ = live_settings
    live.price_feed.timezone = "Australia/Melbourne"
    getattr(live, schedule).hour, getattr(live, schedule).minute = 18, 0

    waits = _waits(monkeypatch, job, lambda: configfile.apply_live(
        live, {"price_feed.timezone": "America/New_York"}))

    first, second = (NOW + dt.timedelta(seconds=w) for w in waits)
    assert first.astimezone(ZoneInfo("Australia/Melbourne")).time() == dt.time(18, 0)
    assert second.astimezone(ZoneInfo("America/New_York")).time() == dt.time(18, 0)


def test_live_quotes_wait_the_interval_saved_from_the_page(live_settings, monkeypatch):
    """The page applies the interval without a restart, but the loop read it
    once, before its first poll, and kept waiting the old one."""
    live, _ = live_settings
    live.price_feed.quote_interval_minutes = 15

    waits = _waits(monkeypatch, "_quote_loop", lambda: configfile.apply_live(
        live, {"price_feed.quote_interval_minutes": 5}))

    assert waits == [15 * 60, 5 * 60]


def test_what_startup_reads_is_said_to_wait_for_a_restart(live_settings, monkeypatch):
    """Which background jobs run is decided once, as the app starts. A
    setting read there and applied live would let the page switch a job on
    that nothing ever starts."""
    from pydantic import BaseModel

    from app import main as main_mod

    live, _ = live_settings
    live.price_feed.enabled = live.price_feed.quotes_enabled = live.maintenance.enabled = True
    read: set[str] = set()

    class Reads:
        def __init__(self, target, prefix=""):
            self.target, self.prefix = target, prefix

        def __getattr__(self, name):
            value = getattr(self.target, name)
            read.add(self.prefix + name)
            return Reads(value, f"{self.prefix}{name}.") if isinstance(value, BaseModel) else value

    async def idle():
        return None

    async def start_and_stop():
        async with main_mod.lifespan(None):
            pass

    monkeypatch.setattr(main_mod, "settings", Reads(live))
    for job in ("_feed_loop", "_quote_loop", "_maintenance_loop"):
        monkeypatch.setattr(main_mod, job, idle)
    asyncio.run(start_and_stop())

    startup = read & set(configfile.BY_NAME)
    assert {"price_feed.enabled", "maintenance.enabled"} <= startup
    # Both ways: what waits for a restart is exactly what the app reads as it
    # starts, and the log level, read when the module is imported. Everything
    # else is read when it is used: the tests below show each.
    assert {o.name for o in configfile.OPTIONS if o.restart} == startup | {"log_level"}


def test_housekeeping_moved_from_the_page_moves_the_next_run(live_settings, monkeypatch):
    live, _ = live_settings
    live.price_feed.timezone = "Australia/Melbourne"
    live.maintenance.hour, live.maintenance.minute = 3, 0

    waits = _waits(monkeypatch, "_maintenance_loop", lambda: configfile.apply_live(
        live, {"maintenance.hour": 4, "maintenance.minute": 30}))

    first, second = (NOW + dt.timedelta(seconds=w) for w in waits)
    melbourne = ZoneInfo("Australia/Melbourne")
    assert (first.astimezone(melbourne).time(), second.astimezone(melbourne).time()) == (
        dt.time(3, 0), dt.time(4, 30))


def test_metrics_switched_on_from_the_page_serve_at_once(client, live_settings):
    live, _ = live_settings
    live.metrics.enabled = False
    assert client.get("/metrics").status_code == 404

    configfile.apply_live(live, {"metrics.enabled": True})

    assert client.get("/metrics").status_code == 200


def test_single_sign_on_set_up_from_the_page_is_offered_at_once(client, session_factory,
                                                                 live_settings):
    import factories as fac

    live, _ = live_settings
    live.auth.oidc.enabled = False
    with session_factory() as s:
        fac.make_user(s, "someone@example.test")       # past the first-run wizard
        s.commit()
    assert "Sign in with Home" not in client.get("/login", headers=HTML).text

    configfile.apply_live(live, {
        "auth.oidc.enabled": True, "auth.oidc.issuer": "https://id.example.test",
        "auth.oidc.client_id": "stocktake", "auth.oidc.client_auth": "none",
        "auth.oidc.redirect_uri": "https://stocktake.example.test/auth/oidc/callback",
        "auth.oidc.button_label": "Sign in with Home"})

    assert "Sign in with Home" in client.get("/login", headers=HTML).text


def test_passkeys_switched_on_from_the_page_are_offered_at_once(client, session_factory,
                                                                live_settings):
    from test_routes import make_login

    live, _ = live_settings
    live.auth.webauthn.enabled = False
    make_login(client, session_factory)
    before = client.get("/profile", headers=HTML).text

    configfile.apply_live(live, {
        "auth.webauthn.enabled": True, "auth.webauthn.rp_id": "stocktake.example.test",
        "auth.webauthn.origins": ["https://stocktake.example.test"]})

    after = client.get("/profile", headers=HTML).text
    assert "Turn on passkeys in Admin" in before
    assert "Turn on passkeys in Admin" not in after and "available via HTTPS" in after


def test_a_proxy_trusted_from_the_page_is_believed_at_once(client, session_factory,
                                                           live_settings):
    """The next request through it is taken as HTTPS, so signing in with
    HTTPS-only cookies stops being refused. (`client` points the app at the
    test database; the requests come from the proxy's address instead.)"""
    from fastapi.testclient import TestClient

    import factories as fac
    from app import main as main_mod

    live, _ = live_settings
    live.auth.cookie_secure, live.auth.trusted_proxies = True, []
    with session_factory() as s:
        fac.make_user(s, "someone@example.test")
        s.commit()
    proxied = TestClient(main_mod.app, client=("10.0.0.5", 50000))
    headers = {**HTML, "x-forwarded-proto": "https"}
    assert "cookie_secure" in proxied.get("/login", headers=headers).text

    configfile.apply_live(live, {"auth.trusted_proxies": ["10.0.0.0/8"]})

    assert "cookie_secure" not in proxied.get("/login", headers=headers).text


def test_ocr_switched_off_from_the_page_is_off_at_once(client, session_factory, live_settings,
                                                       monkeypatch):
    from app import statements
    from test_routes import make_login, session_csrf

    live, _ = live_settings
    live.imports.ocr.enabled = True
    monkeypatch.setattr(statements, "_pdf_text", lambda data: "")       # a scan
    monkeypatch.setattr(statements.ocr, "available", lambda: False)
    make_login(client, session_factory)

    def upload() -> str:
        return client.post("/imports-exports/statement", headers=HTML,
                           files={"file": ("scan.pdf", b"%PDF-1.4", "application/pdf")},
                           data={"_csrf": session_csrf(session_factory)}).text

    assert "turned off on this install" not in upload()
    configfile.apply_live(live, {"imports.ocr.enabled": False})
    assert "turned off on this install" in upload()


# --------------------------------------------------------------------------- #
# Reading a field, and writing the file: what a mutation run found unchecked
# --------------------------------------------------------------------------- #

def test_a_pasted_value_loses_the_spaces_around_it():
    """An issuer pasted with a trailing space is not the issuer."""
    assert configfile.coerce(option("auth.oidc.issuer"),
                             "  https://id.example.com/realms/home \t") == \
        "https://id.example.com/realms/home"


@pytest.mark.parametrize("raw", ["on", "ON", "true", "True", "1", "yes", "Yes"])
def test_every_way_of_saying_yes_is_yes(raw):
    assert configfile.coerce(option("auth.cookie_secure"), raw) is True


@pytest.mark.parametrize("opt", [o for o in configfile.OPTIONS if o.kind == "int"],
                         ids=lambda o: o.name)
def test_each_whole_number_takes_its_own_limits_and_nothing_past_them(opt):
    assert configfile.coerce(opt, str(opt.minimum)) == opt.minimum
    assert configfile.coerce(opt, str(opt.maximum)) == opt.maximum
    with pytest.raises(ValueError, match="at least"):
        configfile.coerce(opt, str(opt.minimum - 1))
    with pytest.raises(ValueError, match="at most"):
        configfile.coerce(opt, str(opt.maximum + 1))


def test_a_list_is_one_entry_per_line_with_blank_lines_dropped():
    """Blank between entries, whether empty or spaces: the box is stripped as
    a whole first, so only a line inside it tests the line's own check."""
    assert configfile.coerce(option("auth.trusted_proxies"),
                             " 10.0.0.0/8 \n\n   \n  ::1\n \n") == ["10.0.0.0/8", "::1"]


@pytest.mark.parametrize("opt", [o for o in configfile.OPTIONS if o.kind == "choice"],
                         ids=lambda o: o.name)
def test_every_offered_choice_is_accepted(opt):
    for choice in opt.choices:
        assert configfile.coerce(opt, choice) == choice


def test_saving_into_an_empty_file_works(config_file):
    config_file.write_text("", encoding="utf-8")

    configfile.save({"price_feed.hour": 9})

    assert configfile.load() == {"price_feed": {"hour": 9}}


def test_saving_under_a_section_left_empty_works(config_file):
    """The shipped file leaves `oidc:` with every line under it commented
    out, which YAML reads as nothing at all."""
    config_file.write_text("auth:\n  oidc:\n    # enabled: true\n", encoding="utf-8")

    configfile.save({"auth.oidc.button_label": "Sign in with Home"})

    assert configfile.load()["auth"]["oidc"]["button_label"] == "Sign in with Home"


def test_a_save_changes_its_own_line_and_no_other(config_file):
    """Quotes, list indentation and comments elsewhere are left as written,
    so a save is a one-line diff."""
    before = ("timezone: 'Australia/Melbourne'   # quoted on purpose\n"
              "auth:\n"
              "  trusted_proxies:\n"
              "    - 10.0.0.0/8\n"
              "    - \"192.168.0.0/16\"\n"
              "  session_ttl_days: 30\n")
    config_file.write_text(before, encoding="utf-8")

    configfile.save({"auth.session_ttl_days": 14})

    assert config_file.read_text(encoding="utf-8") == before.replace("session_ttl_days: 30",
                                                     "session_ttl_days: 14")


def test_no_file_at_all_reads_as_nothing_written(tmp_path, monkeypatch):
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(tmp_path / "absent.yaml"))

    assert configfile.load() == {}


# --------------------------------------------------------------------------- #
# Probing and writing the file, and the page's limits: the last pass's survivors
# --------------------------------------------------------------------------- #

def test_probing_leaves_nothing_behind(config_file, tmp_path, monkeypatch):
    """The settings page probes on every load, and the wizard probes a folder
    that may not exist yet. A probe left behind is litter beside somebody's
    configuration."""
    configfile.writability()
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(tmp_path / "fresh" / "config.yaml"))

    assert configfile.creatable().writable is True
    assert sorted(p.name for p in tmp_path.rglob("*")) == ["config.yaml", "fresh"]


def test_a_read_only_file_in_a_writable_folder_cannot_be_saved(config_file):
    """Its folder takes the probe, so only the file's own permission says no.
    The wizard asks the same of a file that is already there."""
    os.chmod(config_file, 0o400)
    try:
        saved, created = configfile.writability(), configfile.creatable()
    finally:
        os.chmod(config_file, 0o600)

    assert (saved.writable, created.writable) == (False, False)
    assert "read-only, which is normal" in saved.reason


@pytest.mark.parametrize("error, shown", [
    (OSError(30, "Read-only file system"), "read-only (Read-only file system), which"),
    (OSError(), "read-only, which"),
], ids=["with-words", "without"])
def test_the_reason_carries_the_systems_own_words_when_it_has_some(config_file, monkeypatch,
                                                                    error, shown):
    def refuse(*args, **kwargs):
        raise error
    monkeypatch.setattr(configfile.tempfile, "NamedTemporaryFile", refuse)

    assert shown in configfile.writability().reason


@pytest.mark.parametrize("error, detail", [
    (OSError(13, "Permission denied"), " (Permission denied)"), (OSError(), ""),
], ids=["with-words", "without"])
def test_the_wizards_reason_carries_the_systems_own_words_when_it_has_some(
        tmp_path, monkeypatch, error, detail):
    import pathlib

    folder = tmp_path / "absent"
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(folder / "config.yaml"))

    def refuse(self, *args, **kwargs):
        raise error
    monkeypatch.setattr(pathlib.Path, "mkdir", refuse)

    assert f"in {folder}{detail}. That is normal" in configfile.creatable().reason


@pytest.mark.parametrize("write", ["save", "write_tree"])
def test_a_write_that_fails_leaves_no_half_written_file(config_file, monkeypatch, write):
    """The new file is written beside the old and swapped in. A failure part
    way must not leave the half-written one, which holds the configuration."""
    from ruamel.yaml import YAML

    def full_disk(self, data, stream):
        stream.write("half")
        raise OSError(28, "No space left on device")
    monkeypatch.setattr(YAML, "dump", full_disk)

    with pytest.raises(OSError):
        if write == "save":
            configfile.save({"auth.session_ttl_days": 14})
        else:
            configfile.write_tree({"timezone": "Asia/Tokyo"})

    assert [p.name for p in config_file.parent.iterdir()] == ["config.yaml"]
    assert config_file.read_text(encoding="utf-8") == DOCUMENTED


def test_a_file_of_nothing_but_comments_reads_as_nothing_and_takes_a_write(tmp_path,
                                                                           monkeypatch):
    """Every line commented out reads as no document at all rather than an
    empty mapping: the reader and the wizard's merge both have to see one."""
    path = tmp_path / "config.yaml"
    path.write_text("# everything here is commented out\n# timezone: Asia/Tokyo\n", encoding="utf-8")
    monkeypatch.setattr(configfile, "CONFIG_FILE", str(path))

    assert configfile.load() == {}
    configfile.write_tree({"timezone": "Asia/Tokyo"})
    assert configfile.load()["timezone"] == "Asia/Tokyo"


@pytest.mark.parametrize("name", ["price_feed.hour", "price_feed.minute",
                                  "maintenance.hour", "maintenance.minute"])
def test_a_time_of_day_takes_every_value_a_clock_has_and_no_other(name):
    """The loops schedule with exactly what `datetime.time` takes."""
    field = name.rsplit(".", 1)[-1]
    limit = {"hour": 24, "minute": 60}[field]

    accepted = []
    for value in range(-1, limit + 1):
        try:
            configfile.coerce(option(name), str(value))
        except ValueError:
            continue
        accepted.append(value)
        dt.time(**{field: value})                 # and the loop can schedule it

    assert accepted == list(range(limit))


@pytest.mark.parametrize("name", [
    "auth.session_ttl_days", "auth.session_absolute_days", "auth.session_idle_minutes",
    "auth.rate_limit.max_attempts", "auth.rate_limit.window_minutes",
    "auth.rate_limit.lockout_minutes", "maintenance.attempt_retention_days",
    "maintenance.staged_upload_hours", "price_feed.quote_interval_minutes",
    "imports.max_upload_mb"])
def test_a_length_or_a_count_takes_one_and_refuses_nothing(name):
    """Nothing would sign everyone straight back out, lock them out on no
    attempts, or refuse every upload. One is the strictest setting there is."""
    assert configfile.coerce(option(name), "1") == 1
    with pytest.raises(ValueError, match="at least 1"):
        configfile.coerce(option(name), "0")


def test_no_idle_warning_at_all_is_a_choice():
    assert configfile.coerce(option("auth.idle_warning_seconds"), "0") == 0


def test_a_blank_button_label_goes_back_to_the_default():
    assert configfile.coerce(option("auth.oidc.button_label"), "  ") is None


@pytest.mark.parametrize("name", ["auth.trusted_proxies", "auth.webauthn.origins"])
def test_a_blank_list_is_saved_as_none_at_all(name):
    assert configfile.coerce(option(name), "") == []