Files
cyclone/backend/tests/test_sftp_paramiko.py
T
Nora dd7da18279 fix(sftp): swap inbound/outbound paths — FromHPE is inbound, ToHPE is outbound
The dzinesco SP9 seed had `paths.inbound` and `paths.outbound` mapped to
the wrong Gainwell MFT directories:

  - paths.outbound  was  FromHPE/   (HPE sends files FROM here TO us — inbound)
  - paths.inbound   was  ToHPE/     (we send files TO here — outbound)

So `/api/clearhouse/submit` was writing 837P claims to FromHPE (where
HPE puts acks/835s) and the SP16 scheduler was polling ToHPE (where our
claims go). 999 / TA1 / 835 files in the real FromHPE inbox were
unreachable.

Semantics per operator (2026-06-24):
  - FromHPE = HPE/Gainwell → us  = 999, TA1, 835  (inbound)
  - ToHPE   = us → HPE/Gainwell = 837P claims    (outbound)

**Runtime code (the actual fix):**
  - backend/src/cyclone/store.py            — SP9 seed paths flipped
  - backend/src/cyclone/edi/filenames.py    — docstring corrected

**Test fixtures + assertions (would otherwise fail on the new seed):**
  - backend/tests/test_clearhouse_api.py
  - backend/tests/test_providers_seed.py
  - backend/tests/test_sftp_stub.py        — incl. inbound dir paths
  - backend/tests/test_sftp_paramiko.py
  - backend/tests/test_store_update_clearhouse.py
  - backend/tests/test_api_clearhouse_patch.py
  - backend/tests/test_scheduler.py
  - backend/tests/test_api_scheduler.py

**Docs (text-only — keeps the codebase self-consistent):**
  - README.md
  - docs/reference/co-medicaid.md
  - docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md

**Operator action required after merge:** the existing clearhouse row in
`~/.local/share/cyclone/cyclone.db` was seeded with the old wrong
paths. Easiest recovery:
  1. `rm ~/.local/share/cyclone/cyclone.db` and let `ensure_clearhouse_seeded` re-run on next boot, OR
  2. PATCH /api/clearhouse with the new `paths` block (the SP25
     reconfigure hook picks it up live, including by the running scheduler).

**Verification:**
  - 82 tests in the affected files pass (test_clearhouse_api, test_providers_seed,
    test_sftp_stub, test_sftp_paramiko, test_store_update_clearhouse,
    test_api_clearhouse_patch, test_scheduler, test_api_scheduler, test_filenames).
  - Full backend suite: 1029 pass + 36 pre-existing order-dependent flakes
    unrelated to this change (verified by running the same tests in isolation).
2026-06-24 22:05:55 -06:00

276 lines
10 KiB
Python

"""Tests for the SP13 paramiko-backed SftpClient.
We exercise:
1. Stub behavior unchanged from SP9 (smoke test).
2. Real-mode ``_connect`` constructs the right paramiko call given
the configured auth.
3. Real-mode ``write_file`` calls ``SFTPFile.open(..., 'wb')`` with
the right bytes and remote path.
4. Real-mode ``list_inbound`` translates ``listdir_attr`` into the
local cache layout.
5. Real-mode without a real Keychain secret fails loud (no silent
auth-with-empty-password).
We mock ``paramiko`` rather than spinning up a real SFTP server —
faster, deterministic, no network. The mocks live in
``cyclone.clearhouse`` so the test file doesn't need to know
paramiko's internals.
"""
from __future__ import annotations
import io
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cyclone.clearhouse import SftpClient
from cyclone.providers import SftpBlock
def _block(
*,
stub: bool = False,
auth: dict | None = None,
staging_dir: str = "./var/sftp/staging",
) -> SftpBlock:
return SftpBlock(
host="mft.example.com",
port=22,
username="testuser",
paths={
"outbound": "/CO XIX/PROD/test/ToHPE",
"inbound": "/CO XIX/PROD/test/FromHPE",
},
stub=stub,
staging_dir=staging_dir,
poll_seconds=300,
auth=auth or {"password_keychain_account": "sftp.test.password"},
)
@pytest.fixture(autouse=True)
def _no_real_secrets(monkeypatch):
"""Default: pretend Keychain has a real password."""
monkeypatch.setattr(
"cyclone.clearhouse.secrets.get_secret",
lambda name: "real-password-from-keychain",
)
# --------------------------------------------------------------------------- #
# Stub mode (smoke test from SP9)
# --------------------------------------------------------------------------- #
class TestStubUnchanged:
def test_stub_still_writes_to_local_staging(self, tmp_path: Path):
block = _block(stub=True, staging_dir=str(tmp_path / "staging"))
client = SftpClient(block)
target = client.write_file(
"/CO XIX/PROD/test/FromHPE/file.x12", b"hello",
)
assert target == tmp_path / "staging/CO XIX/PROD/test/FromHPE/file.x12"
assert target.read_bytes() == b"hello"
# --------------------------------------------------------------------------- #
# Real mode — mock paramiko
# --------------------------------------------------------------------------- #
def _make_mock_paramiko(monkeypatch, *, sftp_attrs: list | None = None):
"""Replace ``cyclone.clearhouse._connect``'s paramiko with a mock.
Returns (mock_ssh, mock_sftp) so callers can introspect call args.
"""
# Patch paramiko where the SftpClient uses it. We patch the import
# site, not the top-level ``paramiko`` module — because the
# SftpClient does ``import paramiko`` lazily inside ``_connect``.
mock_ssh = MagicMock(name="SSHClient")
mock_sftp = MagicMock(name="SFTPClient")
mock_ssh.open_sftp.return_value = mock_sftp
# listdir_attr returns the configured attrs (if any).
mock_sftp.listdir_attr.return_value = sftp_attrs or []
# sftp.open returns a context manager that we can write to.
# We make __enter__() return a real BytesIO for read paths so
# shutil.copyfileobj works in the list_inbound / read_file tests.
# For write paths, __enter__() returns a MagicMock with .write().
def _open(path, mode="rb"):
m = MagicMock()
if "wb" in mode:
m.__enter__.return_value = MagicMock()
m.__enter__.return_value.write = MagicMock()
else:
m.__enter__.return_value = io.BytesIO(b"")
return m
mock_sftp.open.side_effect = _open
# Patch the lazy import inside _connect.
fake_paramiko = MagicMock(name="paramiko")
fake_paramiko.SSHClient.return_value = mock_ssh
fake_paramiko.AutoAddPolicy.return_value = "AutoAddPolicy"
fake_paramiko.RSAKey.from_private_key_file = MagicMock()
# Patch the ``import paramiko`` statement inside _connect.
import builtins
real_import = builtins.__import__
def _patched_import(name, *args, **kwargs):
if name == "paramiko":
return fake_paramiko
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _patched_import)
return mock_ssh, mock_sftp
class TestRealModeConnect:
def test_connect_uses_password_from_keychain(self, monkeypatch):
mock_ssh, _ = _make_mock_paramiko(monkeypatch)
client = SftpClient(_block())
with client._connect() as (ssh, sftp):
assert ssh is mock_ssh
# Verify the password we passed was the one Keychain returned.
call_kwargs = mock_ssh.connect.call_args.kwargs
assert call_kwargs["hostname"] == "mft.example.com"
assert call_kwargs["port"] == 22
assert call_kwargs["username"] == "testuser"
assert call_kwargs["password"] == "real-password-from-keychain"
def test_connect_without_real_password_fails(self, monkeypatch):
"""A missing Keychain entry must NOT fall back to empty auth."""
from cyclone.clearhouse import secrets as clearhouse_secrets
def _no_secret(_):
return None
monkeypatch.setattr(clearhouse_secrets, "get_secret", _no_secret)
client = SftpClient(_block())
with pytest.raises(RuntimeError, match="missing or stub"):
with client._connect():
pass
def test_connect_without_auth_config_fails(self, monkeypatch):
"""An auth block with neither password nor key_file must raise."""
_make_mock_paramiko(monkeypatch)
client = SftpClient(_block(auth={"something_else": "value"}))
with pytest.raises(RuntimeError, match="must contain"):
with client._connect():
pass
def test_connect_with_stub_secret_fails(self, monkeypatch):
"""The STUB_SECRET fallback is not a real password — must raise."""
from cyclone.clearhouse import secrets as clearhouse_secrets
from cyclone.secrets import STUB_SECRET
monkeypatch.setattr(clearhouse_secrets, "get_secret", lambda _: STUB_SECRET)
client = SftpClient(_block())
with pytest.raises(RuntimeError, match="missing or stub"):
with client._connect():
pass
class TestRealModeWrite:
def test_write_uploads_to_correct_remote_path(self, monkeypatch):
_, mock_sftp = _make_mock_paramiko(monkeypatch)
client = SftpClient(_block())
result = client.write_file(
"/CO XIX/PROD/test/FromHPE/out.x12", b"X12 content",
)
# The remote path is passed through unchanged.
assert str(result) == "/CO XIX/PROD/test/FromHPE/out.x12"
# The SFTP open() was called with that path + "wb" mode.
called = [c for c in mock_sftp.open.call_args_list
if c.args and c.args[0] == "/CO XIX/PROD/test/FromHPE/out.x12"]
assert called, f"open() was not called with the expected path: {mock_sftp.open.call_args_list}"
assert called[0].args[1] == "wb"
# The bytes were written. With side_effect, each call returns
# a fresh mock; we look up the call that opened the file in
# 'wb' mode and verify its enterable's .write() was invoked.
write_call = [c for c in mock_sftp.open.call_args_list
if c.args and len(c.args) > 1 and "wb" in c.args[1]]
assert write_call, f"no 'wb' open() call: {mock_sftp.open.call_args_list}"
file_mock = write_call[0].return_value
file_mock.__enter__().write.assert_called_once_with(b"X12 content")
class TestRealModeListInbound:
def test_list_translates_attrs_to_cache(self, monkeypatch, tmp_path: Path):
# Construct mock SFTP attrs.
attr = MagicMock()
attr.filename = "TP123-837P_M456-...-1of1_999.x12"
attr.st_mode = 0o100644 # regular file
attr.st_size = 1024
attr.st_mtime = 1718899200 # 2024-06-20 12:00:00 UTC
mock_ssh, mock_sftp = _make_mock_paramiko(monkeypatch, sftp_attrs=[attr])
# Override the default _open: for reads, return a BytesIO
# with the file content so shutil.copyfileobj works.
def _open(path, mode="rb"):
m = MagicMock()
m.__enter__.return_value = io.BytesIO(b"999 content here")
return m
mock_sftp.open.side_effect = _open
# Use a temp staging dir for the cache.
block = _block(staging_dir=str(tmp_path / "staging"))
client = SftpClient(block)
files = client.list_inbound()
assert len(files) == 1
f = files[0]
assert f.name == "TP123-837P_M456-...-1of1_999.x12"
assert f.size == 1024
assert isinstance(f.modified_at, datetime)
assert f.local_path.exists()
assert f.local_path.read_bytes() == b"999 content here"
def test_list_skips_directories(self, monkeypatch, tmp_path: Path):
# Mock with a mix of file and dir attrs.
file_attr = MagicMock()
file_attr.filename = "real.x12"
file_attr.st_mode = 0o100644
file_attr.st_size = 100
file_attr.st_mtime = 1718899200
dir_attr = MagicMock()
dir_attr.filename = "subdir"
dir_attr.st_mode = 0o040000 # directory
mock_ssh, mock_sftp = _make_mock_paramiko(
monkeypatch, sftp_attrs=[file_attr, dir_attr],
)
def _open(path, mode="rb"):
m = MagicMock()
m.__enter__.return_value = io.BytesIO(b"")
return m
mock_sftp.open.side_effect = _open
block = _block(staging_dir=str(tmp_path / "staging"))
client = SftpClient(block)
files = client.list_inbound()
assert len(files) == 1
assert files[0].name == "real.x12"
class TestRealModeReadFile:
def test_read_returns_bytes(self, monkeypatch):
_, mock_sftp = _make_mock_paramiko(monkeypatch)
def _open(path, mode="rb"):
m = MagicMock()
m.__enter__.return_value = io.BytesIO(b"downloaded bytes")
return m
mock_sftp.open.side_effect = _open
client = SftpClient(_block())
data = client.read_file("/some/remote/path.x12")
assert data == b"downloaded bytes"