c3a6c53096
Three changes that unblock the daily inbound pull from Gainwell's FromHPE MFT path: 1. INBOUND_RE now accepts both 'TP' and 'tp' prefixes via inline (?i:TP) scoping — case-folding the whole pattern would let lowercase '837p' / tracking IDs through, which is invalid HCPF. The rest of the pattern is still case-sensitive. 2. _list_inbound_paramiko skips *_warn.txt entries. Gainwell's MFT drops ~583 advisory text-format notes in the same inbound dir; they come first alphabetically and were padding every poll with ~80 min of pointless downloads. 3. New SftpClient.list_inbound_names() + download_inbound() pair gives a metadata-only listing and on-demand fetch. The scheduler's existing full-listing path still works (now without the warn padding); the new path is what the new /api/admin/scheduler/pull-inbound endpoint and the 'cyclone pull-inbound' CLI use to fast-target a date range without paying the cost of a full ~6000-file download. Scheduler.process_inbound_files() runs the same per-file pipeline as a regular tick on the pre-fetched list, so dedup via processed_inbound_files still applies. Tests added in test_filenames.py (lowercase + mixed-case cases), test_sftp_paramiko.py (warn skip + no-download listing), and test_scheduler.py (process_inbound_files idempotency). With this, the daily 385-file pull for 20260624 completes in seconds via 'docker exec cyclone-backend-1 python -m cyclone pull-inbound --date 20260624 --block dzinesco' (or the equivalent POST to /api/admin/scheduler/pull-inbound?date=20260624).
336 lines
12 KiB
Python
336 lines
12 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"
|
|
|
|
def test_list_skips_warn_txt_files(self, monkeypatch, tmp_path: Path):
|
|
# Gainwell's MFT drops advisory *_warn.txt files in the same
|
|
# inbound dir. They're text-format side-channel notes (not X12
|
|
# envelopes) and must be skipped at list time.
|
|
real_attr = MagicMock()
|
|
real_attr.filename = "TP123-837P_M456-20260520231513488-1of1_999.x12"
|
|
real_attr.st_mode = 0o100644
|
|
real_attr.st_size = 1024
|
|
real_attr.st_mtime = 1718899200
|
|
|
|
warn_attr = MagicMock()
|
|
warn_attr.filename = "TP123-837P-202606181208100000-1of1_warn.txt"
|
|
warn_attr.st_mode = 0o100644
|
|
warn_attr.st_size = 200
|
|
warn_attr.st_mtime = 1718899200
|
|
|
|
mock_ssh, mock_sftp = _make_mock_paramiko(
|
|
monkeypatch, sftp_attrs=[real_attr, warn_attr],
|
|
)
|
|
|
|
def _open(path, mode="rb"):
|
|
m = MagicMock()
|
|
m.__enter__.return_value = io.BytesIO(b"x12 content")
|
|
return m
|
|
|
|
mock_sftp.open.side_effect = _open
|
|
|
|
block = _block(staging_dir=str(tmp_path / "staging"))
|
|
client = SftpClient(block)
|
|
files = client.list_inbound()
|
|
|
|
names = [f.name for f in files]
|
|
assert "TP123-837P_M456-20260520231513488-1of1_999.x12" in names
|
|
assert not any(n.endswith("_warn.txt") for n in names)
|
|
assert len(files) == 1
|
|
|
|
def test_list_inbound_names_does_not_download(self, monkeypatch, tmp_path: Path):
|
|
# list_inbound_names() must do a metadata-only SFTP listing —
|
|
# no sftp.open() / no file written to the cache.
|
|
attr = MagicMock()
|
|
attr.filename = "TP123-837P_M456-20260520231513488-1of1_999.x12"
|
|
attr.st_mode = 0o100644
|
|
attr.st_size = 1024
|
|
attr.st_mtime = 1718899200
|
|
|
|
mock_ssh, mock_sftp = _make_mock_paramiko(
|
|
monkeypatch, sftp_attrs=[attr],
|
|
)
|
|
|
|
block = _block(staging_dir=str(tmp_path / "staging"))
|
|
client = SftpClient(block)
|
|
files = client.list_inbound_names()
|
|
|
|
# sftp.open() must NOT have been called (no download).
|
|
mock_sftp.open.assert_not_called()
|
|
assert len(files) == 1
|
|
# The InboundFile.local_path is set to the planned cache path
|
|
# but the file itself doesn't exist yet.
|
|
assert not files[0].local_path.exists()
|
|
|
|
|
|
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"
|