feat(sp25): PATCH /api/clearhouse with hot-reload
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user