"""SP25 — PATCH /api/clearhouse endpoint. Lets the operator flip sftp_block.stub and adjust host/port/paths without raw SQL. The endpoint validates via the Clearhouse Pydantic model, writes via store.update_clearhouse(), then hot-reloads the running scheduler via scheduler.reconfigure_scheduler(). """ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest from fastapi.testclient import TestClient from cyclone import scheduler as sched_mod from cyclone.api import app from cyclone.auth import deps from cyclone.store import store as cycl_store @pytest.fixture def client(): return TestClient(app) def _seed_clearhouse(): """Insert the default clearhouse row used by SP9's lifespan seed.""" cycl_store.ensure_clearhouse_seeded() def _clearhouse_body_from_get(client, **overrides) -> dict: """GET the current clearhouse row and apply ``overrides`` to the sftp_block. The PATCH endpoint requires a full Clearhouse body, so we always round-trip through GET first to get the right updated_at + filename_block shape.""" r = client.get("/api/clearhouse") assert r.status_code == 200, r.text body = r.json() sb = dict(body["sftp_block"]) sb.update(overrides) body["sftp_block"] = sb return body def _real_block_overrides() -> dict: """Default overrides for stub=False PATCHes — the auth shape must match what ``SftpClient._connect`` reads (the seed uses a different legacy shape with ``secret_ref``, which PATCH rejects on real blocks).""" return { "stub": False, "host": "mft.example.com", "auth": {"password_keychain_account": "sftp.gainwell.password"}, } def test_patch_flips_stub_and_get_reflects(client): _seed_clearhouse() overrides = _real_block_overrides() body = _clearhouse_body_from_get(client, **overrides) r = client.patch("/api/clearhouse", json=body) assert r.status_code == 200, r.text payload = r.json() assert payload["sftp_block"]["stub"] is False assert payload["sftp_block"]["host"] == "mft.example.com" r2 = client.get("/api/clearhouse") assert r2.status_code == 200 assert r2.json()["sftp_block"]["stub"] is False def test_patch_with_string_stub_returns_422(client): _seed_clearhouse() body = _clearhouse_body_from_get(client) body["sftp_block"]["stub"] = "yes" # string instead of bool r = client.patch("/api/clearhouse", json=body) assert r.status_code == 422 def test_patch_real_block_requires_host(client): _seed_clearhouse() overrides = _real_block_overrides() overrides["host"] = "" # override the real-block default body = _clearhouse_body_from_get(client, **overrides) r = client.patch("/api/clearhouse", json=body) assert r.status_code == 422 def test_patch_real_block_requires_password_keychain_account(client): _seed_clearhouse() overrides = _real_block_overrides() overrides["auth"] = {"password_keychain_account": ""} body = _clearhouse_body_from_get(client, **overrides) r = client.patch("/api/clearhouse", json=body) assert r.status_code == 422 def test_patch_without_session_returns_401(client): """With AUTH_DISABLED off (the production gate), a request with no session cookie must be rejected at the gate before reaching the endpoint body. Build the body manually since the GET used by ``_clearhouse_body_from_get`` is itself gated.""" _seed_clearhouse() # Build a valid body without going through the gated GET. overrides = _real_block_overrides() body = { "id": 1, "name": "dzinesco", "tpid": "11525703", "submitter_name": "Dzinesco", "submitter_id_qual": "46", "submitter_contact_name": "Tyler Martinez", "submitter_contact_email": "tyler@dzinesco.com", "filename_block": { "tz": "America/Denver", "outbound_template": "{tpid}-{tx}-{ts}-1of1.{ext}", "inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12", }, "sftp_block": { "host": overrides.get("host", "mft.example.com"), "port": 22, "username": "colorado-fts\\coxix_prod_11525703", "paths": { "outbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE", "inbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE", }, "stub": overrides.get("stub", False), "staging_dir": "./var/sftp/staging", "auth": overrides.get("auth", {"password_keychain_account": "sftp.gainwell.password"}), }, "updated_at": "2026-06-24T00:00:00+00:00", } deps.AUTH_DISABLED = False try: r = client.patch("/api/clearhouse", json=body) assert r.status_code == 401 finally: deps.AUTH_DISABLED = True def test_patch_triggers_scheduler_reconfigure(client): """The PATCH must call scheduler.reconfigure_scheduler() with the new SftpBlock so the next tick uses real MFT instead of the stub.""" _seed_clearhouse() with patch.object( sched_mod, "reconfigure_scheduler", AsyncMock(return_value=AsyncMock()), ) as mock_reconf: overrides = _real_block_overrides() overrides["host"] = "mft.gainwelltechnologies.com" body = _clearhouse_body_from_get(client, **overrides) r = client.patch("/api/clearhouse", json=body) assert r.status_code == 200, r.text assert mock_reconf.await_count == 1 # The new sftp_block is passed positionally; sftp_block_name # comes from the clearhouse row's ``name`` field. call_args = mock_reconf.await_args assert call_args.args[0].stub is False assert call_args.args[0].host == "mft.gainwelltechnologies.com" assert call_args.kwargs.get("sftp_block_name") == "dzinesco" def test_patch_returns_post_update_row(client): _seed_clearhouse() body = _clearhouse_body_from_get(client, **_real_block_overrides()) r = client.patch("/api/clearhouse", json=body) assert r.status_code == 200 payload = r.json() assert payload["name"] == "dzinesco" assert payload["tpid"] == "11525703" assert payload["sftp_block"]["stub"] is False assert payload["sftp_block"]["host"] == "mft.example.com"