From 439a3c3d4b10b6f220f7d960619f40b18d85302f Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 24 Jun 2026 15:38:14 -0600 Subject: [PATCH] feat(sp25): PATCH /api/clearhouse with hot-reload --- backend/src/cyclone/api.py | 66 ++++++++ backend/tests/test_api_clearhouse_patch.py | 174 +++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 backend/tests/test_api_clearhouse_patch.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 3a79445..7a67a2a 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -2604,6 +2604,72 @@ def get_clearhouse(): return json.loads(ch.model_dump_json()) +@app.patch("/api/clearhouse", dependencies=[Depends(matrix_gate)]) +async def patch_clearhouse(body: dict) -> Any: + """Replace the singleton clearhouse row (SP25). + + The full ``Clearhouse`` model is required — we don't accept partial + updates because the operator-facing use case is "I'm switching the + loop to real MFT" or "I'm pointing at a different MFT server", + not "I'm tweaking one field at a time." Validation errors are + returned as 422 (Pydantic default). + + After a successful write, the running scheduler is hot-reloaded + via ``scheduler.reconfigure_scheduler()`` so the next tick uses + the new SftpBlock without a process restart. + """ + from cyclone import scheduler as _scheduler_mod + from cyclone.providers import Clearhouse as _Clearhouse, SftpBlock as _SftpBlock + + # Strict-validate the sftp_block sub-dict FIRST. Pydantic v2's + # default mode coerces strings to bools (e.g. ``"stub": "yes"`` + # silently becomes True), which would hide a real operator + # mistake. The Clearhouse model itself stays in loose mode so + # ISO-string ``updated_at`` (the JSON round-trip shape) keeps + # parsing. + raw_sb = body.get("sftp_block", {}) + try: + _SftpBlock.model_validate(raw_sb, strict=True) + except Exception as exc: + raise HTTPException( + status_code=422, detail=f"invalid sftp_block: {exc}", + ) from exc + + # Now validate the full body in loose mode. + try: + parsed = _Clearhouse.model_validate(body) + except Exception as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + # SP25: when sftp_block.stub=false, the block must carry an auth + # account name and a non-empty host. The Pydantic model catches + # some of these; this catches the "empty password_keychain_account" + # case (which Pydantic allows because it's a free-form dict). + sb = parsed.sftp_block + if not sb.stub: + if not sb.host: + raise HTTPException( + status_code=422, + detail="sftp_block.host is required when stub=false", + ) + auth = sb.auth or {} + if not auth.get("password_keychain_account") and not auth.get("key_file"): + raise HTTPException( + status_code=422, + detail=( + "sftp_block.auth must contain either " + "'password_keychain_account' or 'key_file' when stub=false" + ), + ) + + updated = store.update_clearhouse(parsed) + await _scheduler_mod.reconfigure_scheduler( + updated.sftp_block, + sftp_block_name=updated.name or "default", + ) + return json.loads(updated.model_dump_json()) + + @app.post("/api/clearhouse/submit", dependencies=[Depends(matrix_gate)]) def submit_to_clearhouse(request: Request, body: dict): """Submit a batch of claims to the clearhouse (SFTP). SP9: stub. diff --git a/backend/tests/test_api_clearhouse_patch.py b/backend/tests/test_api_clearhouse_patch.py new file mode 100644 index 0000000..3717a0e --- /dev/null +++ b/backend/tests/test_api_clearhouse_patch.py @@ -0,0 +1,174 @@ +"""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/FromHPE", + "inbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE", + }, + "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"