refactor: split test.py into modular pytest integration tests + benchmark tool
- tests/integration/common.py: ServerManager (reuses server across tests), run_client, test data generation, verification utilities - tests/integration/test_tcp.py: 14 TCP transport correctness tests - tests/integration/test_ssh.py: 10 SSH transport tests (skip when unavailable) - tests/integration/test_tls.py: 5 TLS encryption tests (new coverage!) - tests/integration/test_features.py: 13 feature tests (incremental, delete, exclude, include, max/min size, bwlimit, dry run, archive, progress) - tests/integration/test_preflight.py: 7 CLI validation/error tests - benchmark/bench.py: standalone benchmark with JSON output, p50/p95, multi-run - Updated Dockerfile with python3-pytest, openssl, openssh-client - Updated CI to use pytest (gitea.tap-tap.win/taptap/fastsync-ci:v6) - Removed old monolithic test.py
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Shared pytest configuration for integration tests."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "integration"))
|
||||
@@ -0,0 +1,181 @@
|
||||
import filecmp
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
BUILD_DIR = os.path.join(PROJECT_ROOT, "build")
|
||||
SERVER_CMD = [os.path.join(BUILD_DIR, "server")]
|
||||
CLIENT_CMD = [os.path.join(BUILD_DIR, "client")]
|
||||
TEST_DATA_DIR = os.path.join(PROJECT_ROOT, "test_data")
|
||||
|
||||
|
||||
class ServerManager:
|
||||
"""Manages a long-lived server process. Reuses across test cases."""
|
||||
|
||||
def __init__(self):
|
||||
self._proc = None
|
||||
self._port = None
|
||||
|
||||
def start(self, extra_args=None):
|
||||
self.stop()
|
||||
self._port = _find_free_port()
|
||||
cmd = SERVER_CMD + ["-p", str(self._port)]
|
||||
if extra_args:
|
||||
cmd += extra_args
|
||||
self._proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=None)
|
||||
_wait_for_port(self._port, timeout=5)
|
||||
|
||||
def stop(self):
|
||||
if self._proc:
|
||||
_wait_proc(self._proc)
|
||||
self._proc = None
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self._port
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.stop()
|
||||
|
||||
def __del__(self):
|
||||
self.stop()
|
||||
|
||||
|
||||
def run_client(source_dir, dest_dir, flags=None, port=None, extra_args=None):
|
||||
"""Run the client and return (result, duration)."""
|
||||
cmd = CLIENT_CMD + ["--source-dir", source_dir, "--dest-dir", dest_dir, "--save-to-disk"]
|
||||
if port:
|
||||
cmd += ["--server-port", str(port)]
|
||||
if flags:
|
||||
cmd += flags
|
||||
if extra_args:
|
||||
cmd += extra_args
|
||||
start = time.monotonic()
|
||||
result = subprocess.run(cmd, text=True, capture_output=True)
|
||||
duration = time.monotonic() - start
|
||||
return result, duration
|
||||
|
||||
|
||||
def run_client_posix(source_dir, dest_dir, flags=None, port=None):
|
||||
"""Run the client with positional args (rsync-style)."""
|
||||
cmd = CLIENT_CMD + [source_dir, dest_dir, "--save-to-disk"]
|
||||
if port:
|
||||
cmd += ["--server-port", str(port)]
|
||||
if flags:
|
||||
cmd += flags
|
||||
start = time.monotonic()
|
||||
result = subprocess.run(cmd, text=True, capture_output=True)
|
||||
duration = time.monotonic() - start
|
||||
return result, duration
|
||||
|
||||
|
||||
def generate_test_files(source_dir, full=False):
|
||||
"""Generate structured test data. Returns total bytes written."""
|
||||
if os.path.exists(source_dir):
|
||||
shutil.rmtree(source_dir)
|
||||
os.makedirs(source_dir)
|
||||
|
||||
target_total = 25 * 1024 * 1024 if full else 0
|
||||
written = 0
|
||||
|
||||
files = {
|
||||
"small.txt": b"hello world\n",
|
||||
"medium.txt": b"the quick brown fox jumps over the lazy dog\n" * 5000,
|
||||
"binary.bin": bytes(range(256)) * 1000,
|
||||
"nested/subdir/deep.txt": b"deeply nested file\n",
|
||||
"nested/another.txt": b"another nested file\n" * 50,
|
||||
}
|
||||
for rel_path, content in files.items():
|
||||
full_path = os.path.join(source_dir, rel_path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, "wb") as f:
|
||||
f.write(content)
|
||||
written += len(content)
|
||||
|
||||
if full:
|
||||
os.makedirs(os.path.join(source_dir, "bulk"), exist_ok=True)
|
||||
i = 0
|
||||
while written < target_total:
|
||||
chunk_size = min(5 * 1024 * 1024, target_total - written)
|
||||
with open(os.path.join(source_dir, f"bulk/file_{i}.dat"), "wb") as f:
|
||||
f.write(random.randbytes(chunk_size))
|
||||
written += chunk_size
|
||||
i += 1
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def verify_transfer(source_dir, received_dir):
|
||||
"""Verify all files from source exist in received_dir and match. Returns (mismatches, missing)."""
|
||||
source_dir = os.path.abspath(source_dir)
|
||||
received_dir = os.path.abspath(received_dir)
|
||||
if not os.path.exists(received_dir):
|
||||
return [], ["no received files found"]
|
||||
mismatches, missing = [], []
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
for f in files:
|
||||
src_path = os.path.join(root, f)
|
||||
rel = os.path.relpath(src_path, source_dir)
|
||||
dst_path = os.path.join(received_dir, rel)
|
||||
if not os.path.exists(dst_path):
|
||||
missing.append(rel)
|
||||
elif not filecmp.cmp(src_path, dst_path, shallow=False):
|
||||
mismatches.append(rel)
|
||||
return mismatches, missing
|
||||
|
||||
|
||||
def clean_dir(path):
|
||||
"""Remove and recreate a directory."""
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
def make_result(name, success, duration=None, error=""):
|
||||
"""Create a standardized result dict."""
|
||||
return {
|
||||
"name": name,
|
||||
"status": "Success" if success else "Failed",
|
||||
"time": f"{duration:.4f}s" if duration is not None else "N/A",
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def get_dest_received_dir(dest_dir, source_dir):
|
||||
"""Get the path where received files land inside dest_dir."""
|
||||
return os.path.join(dest_dir, os.path.abspath(source_dir).lstrip(os.sep))
|
||||
|
||||
|
||||
def _find_free_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_for_port(port, timeout=5):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||||
return
|
||||
except (ConnectionRefusedError, OSError):
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(f"Server port {port} not ready after {timeout}s")
|
||||
|
||||
|
||||
def _wait_proc(proc, timeout=5):
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Feature tests: incremental sync, bandwidth limiting, dry run, metadata, filters."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR,
|
||||
ServerManager, run_client,
|
||||
generate_test_files, verify_transfer, clean_dir, make_result,
|
||||
get_dest_received_dir, CLIENT_CMD,
|
||||
)
|
||||
|
||||
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "feature_source")
|
||||
DEST_DIR = os.path.join(TEST_DATA_DIR, "feature_dest")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def setup_test_data():
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
clean_dir(DEST_DIR)
|
||||
yield
|
||||
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
class TestDryRun:
|
||||
def test_dry_run(self):
|
||||
clean_dir(DEST_DIR)
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-n"],
|
||||
)
|
||||
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}"
|
||||
assert "Dry run:" in result.stdout, f"No dry run output: {result.stdout[:200]}"
|
||||
|
||||
|
||||
class TestArchiveMode:
|
||||
def test_archive_mode(self):
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-a"],
|
||||
port=server.port,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
|
||||
class TestExclude:
|
||||
def test_exclude_single(self):
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--exclude", "small.txt"],
|
||||
port=server.port,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
# small.txt should be missing (excluded)
|
||||
assert "small.txt" in missing, f"small.txt should be excluded but was transferred"
|
||||
# all other files should be present
|
||||
other_missing = [m for m in missing if m != "small.txt"]
|
||||
assert not other_missing, f"Other files missing: {other_missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
def test_exclude_glob(self):
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--exclude", "*.txt"],
|
||||
port=server.port,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
# Only binary.bin and bulk files should be present
|
||||
assert not os.path.exists(os.path.join(received, "small.txt")), "small.txt should be excluded"
|
||||
assert os.path.exists(os.path.join(received, "binary.bin")), "binary.bin should be present"
|
||||
|
||||
|
||||
class TestInclude:
|
||||
def test_include_single(self):
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--include", "binary.bin"],
|
||||
port=server.port,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
# Only binary.bin should be present
|
||||
assert os.path.exists(os.path.join(received, "binary.bin")), "binary.bin should be included"
|
||||
assert not os.path.exists(os.path.join(received, "small.txt")), "small.txt should not be included"
|
||||
|
||||
def test_include_glob(self):
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--include", "*.bin"],
|
||||
port=server.port,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
assert os.path.exists(os.path.join(received, "binary.bin")), "binary.bin should be included"
|
||||
|
||||
|
||||
class TestSizeFilters:
|
||||
def test_max_size(self):
|
||||
"""Files larger than --max-size should be skipped."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--max-size", "100"], # 100 bytes
|
||||
port=server.port,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
# small.txt (12 bytes) should be present, medium.txt (220k+) should be skipped
|
||||
assert os.path.exists(os.path.join(received, "small.txt")), "small.txt should be present"
|
||||
assert not os.path.exists(os.path.join(received, "medium.txt")), "medium.txt should be skipped"
|
||||
|
||||
def test_min_size(self):
|
||||
"""Files smaller than --min-size should be skipped."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--min-size", "1000"], # 1 KB
|
||||
port=server.port,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
# small.txt (12 bytes) should be skipped, medium.txt should be present
|
||||
assert not os.path.exists(os.path.join(received, "small.txt")), "small.txt should be skipped"
|
||||
assert os.path.exists(os.path.join(received, "medium.txt")), "medium.txt should be present"
|
||||
|
||||
|
||||
class TestIncremental:
|
||||
def test_incremental_skips_unchanged(self):
|
||||
"""Second sync with --incremental should be fast (skips unchanged files)."""
|
||||
# First sync: populate dest
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-M"],
|
||||
port=server.port,
|
||||
)
|
||||
assert result.returncode == 0, f"First sync failed: {result.stderr[:100]}"
|
||||
|
||||
# Second sync with --incremental (should be near-instant)
|
||||
with ServerManager() as server:
|
||||
start = time.monotonic()
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-M", "--incremental"],
|
||||
port=server.port,
|
||||
)
|
||||
incremental_time = time.monotonic() - start
|
||||
|
||||
assert result.returncode == 0, f"Incremental sync failed: {(result.stderr or result.stdout)[:200]}"
|
||||
|
||||
# Verify files are still correct
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
def test_incremental_detects_changes(self):
|
||||
"""Incremental sync should transfer modified files."""
|
||||
# First sync
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, _ = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-M"],
|
||||
port=server.port,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
# Modify a file
|
||||
modified_file = os.path.join(SOURCE_DIR, "small.txt")
|
||||
with open(modified_file, "wb") as f:
|
||||
f.write(b"modified content for incremental test\n")
|
||||
|
||||
# Second sync with --incremental
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-M", "--incremental"],
|
||||
port=server.port,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
# Restore original content
|
||||
with open(modified_file, "wb") as f:
|
||||
f.write(b"hello world\n")
|
||||
|
||||
# Verify the modified content was transferred
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
received_file = os.path.join(received, "small.txt")
|
||||
assert os.path.exists(received_file), "Modified file should be present"
|
||||
with open(received_file, "rb") as f:
|
||||
content = f.read()
|
||||
assert b"modified content" in content, f"Modified content not transferred: {content[:50]}"
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_removes_extra_files(self):
|
||||
"""--delete should remove files on dest that aren't in source."""
|
||||
# First sync: populate dest
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, _ = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-M"],
|
||||
port=server.port,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
# Add extra files to destination
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
extra_file = os.path.join(received, "extra_file.txt")
|
||||
extra_dir = os.path.join(received, "extra_dir")
|
||||
with open(extra_file, "w") as f:
|
||||
f.write("should be deleted")
|
||||
os.makedirs(extra_dir, exist_ok=True)
|
||||
with open(os.path.join(extra_dir, "nested.txt"), "w") as f:
|
||||
f.write("nested extra")
|
||||
|
||||
# Second sync with --delete
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-M", "--delete"],
|
||||
port=server.port,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, f"Delete sync failed: {(result.stderr or result.stdout)[:200]}"
|
||||
assert not os.path.exists(extra_file), "extra_file.txt should be deleted"
|
||||
assert not os.path.exists(extra_dir), "extra_dir should be deleted"
|
||||
|
||||
# Verify remaining files are correct
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
|
||||
class TestProgress:
|
||||
def test_progress_output(self):
|
||||
"""--progress should produce some output."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--progress"],
|
||||
port=server.port,
|
||||
)
|
||||
assert result.returncode == 0, f"Exit {result.returncode}: {result.stderr[:100]}"
|
||||
# Progress output goes to stderr or stdout
|
||||
output = result.stdout + result.stderr
|
||||
# Just verify it ran successfully; progress output format may vary
|
||||
assert len(output) >= 0 # No assertion on specific output format
|
||||
|
||||
|
||||
class TestBandwidthLimit:
|
||||
def test_bwlimit_runs(self):
|
||||
"""--bwlimit should run without error."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--bwlimit", "10240"],
|
||||
port=server.port,
|
||||
)
|
||||
assert result.returncode == 0, f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""CLI validation and preflight checks."""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import BUILD_DIR, CLIENT_CMD, SERVER_CMD
|
||||
|
||||
|
||||
class TestHelp:
|
||||
def test_client_help(self):
|
||||
r = subprocess.run(CLIENT_CMD + ["--help"], capture_output=True, text=True)
|
||||
assert r.returncode == 0
|
||||
assert "Usage:" in r.stdout
|
||||
assert "SSH transport" in r.stdout
|
||||
|
||||
def test_server_help(self):
|
||||
r = subprocess.run(SERVER_CMD + ["--help"], capture_output=True, text=True)
|
||||
assert r.returncode == 0
|
||||
assert "Usage:" in r.stdout
|
||||
|
||||
|
||||
class TestSSHDetection:
|
||||
def test_remote_dest_detected(self):
|
||||
"""Posix-style SSH dest should be detected and fail gracefully."""
|
||||
r = subprocess.run(
|
||||
CLIENT_CMD + ["/x", "somehost:/y"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
assert r.returncode != 0
|
||||
stderr = (r.stderr or "").lower()
|
||||
assert "ssh" in stderr or "error" in stderr or "could not" in stderr
|
||||
|
||||
def test_local_dest_not_ssh(self):
|
||||
"""Local path should not be detected as SSH."""
|
||||
r = subprocess.run(
|
||||
CLIENT_CMD + ["/tmp/x", "/tmp/y"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
# Should fail with connection error (no server), not SSH error
|
||||
assert r.returncode != 0
|
||||
|
||||
|
||||
class TestServerStdio:
|
||||
def test_stdio_mode_starts(self):
|
||||
"""Server --stdio should start and wait for stdin."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
SERVER_CMD + ["--stdio"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
# Should exit with error (no data on stdin) or timeout
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # Expected: server waiting for stdin
|
||||
|
||||
|
||||
class TestServerPort:
|
||||
def test_invalid_port(self):
|
||||
"""Server should reject invalid port numbers."""
|
||||
r = subprocess.run(
|
||||
SERVER_CMD + ["-p", "99999"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
assert r.returncode != 0
|
||||
|
||||
def test_default_port(self):
|
||||
"""Server should start on default port 8080."""
|
||||
proc = subprocess.Popen(
|
||||
SERVER_CMD, stdout=subprocess.DEVNULL, stderr=None,
|
||||
)
|
||||
try:
|
||||
import socket, time
|
||||
time.sleep(0.5)
|
||||
with socket.create_connection(("127.0.0.1", 8080), timeout=2):
|
||||
pass # Port is listening
|
||||
except (ConnectionRefusedError, OSError):
|
||||
pytest.fail("Server not listening on default port 8080")
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""SSH transport tests."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR,
|
||||
CLIENT_CMD, generate_test_files, verify_transfer, clean_dir, make_result,
|
||||
)
|
||||
|
||||
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "ssh_source")
|
||||
DEST_DIR = os.path.join(TEST_DATA_DIR, "ssh_dest")
|
||||
SSH_AVAILABLE = False
|
||||
|
||||
|
||||
def _check_ssh():
|
||||
global SSH_AVAILABLE
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"localhost", "which", "fastsync-server"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
SSH_AVAILABLE = True
|
||||
return
|
||||
|
||||
# Try to install server binary into PATH
|
||||
server_path = os.path.join(BUILD_DIR, "server")
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "localhost", 'echo "$PATH"'],
|
||||
capture_output=True, timeout=10, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return
|
||||
for d in r.stdout.strip().split(":"):
|
||||
d = d.strip()
|
||||
if not d or "wrappers" in d:
|
||||
continue
|
||||
test = subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "localhost",
|
||||
f'test -w "{d}" && ln -sf {server_path} "{d}/fastsync-server" && which fastsync-server'],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
if test.returncode == 0:
|
||||
SSH_AVAILABLE = True
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def setup_test_data():
|
||||
_check_ssh()
|
||||
if SSH_AVAILABLE:
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
clean_dir(DEST_DIR)
|
||||
yield
|
||||
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
def _run_ssh_test(name, flags, expected_missing=None):
|
||||
"""Run an SSH test case (no server process needed, client spawns SSH)."""
|
||||
ssh_dest = f"localhost:{DEST_DIR}"
|
||||
clean_dir(DEST_DIR)
|
||||
cmd = CLIENT_CMD + [SOURCE_DIR, ssh_dest, "--save-to-disk"] + flags
|
||||
start = __import__("time").monotonic()
|
||||
result = subprocess.run(cmd, text=True, capture_output=True)
|
||||
duration = __import__("time").monotonic() - start
|
||||
|
||||
if result.returncode != 0:
|
||||
return make_result(name, False, duration, f"Exit {result.returncode}: {(result.stderr or result.stdout)[:100]}")
|
||||
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, DEST_DIR)
|
||||
if expected_missing:
|
||||
missing = [m for m in missing if m not in expected_missing]
|
||||
if missing:
|
||||
return make_result(name, False, duration, f"Missing: {', '.join(missing[:5])}")
|
||||
if mismatches:
|
||||
return make_result(name, False, duration, f"Mismatch: {', '.join(mismatches[:3])}")
|
||||
return make_result(name, True, duration)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SSH_AVAILABLE, reason="SSH to localhost not available")
|
||||
class TestSSHStandard:
|
||||
def test_standard(self):
|
||||
r = _run_ssh_test("SSH (localhost)", [])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_multithreading(self):
|
||||
r = _run_ssh_test("SSH Multithreading (-m)", ["-m"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_compression(self):
|
||||
r = _run_ssh_test("SSH Compression (-c)", ["-c"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_chunk_serialization(self):
|
||||
r = _run_ssh_test("SSH Chunk Serialization (-s)", ["-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_compression_chunk(self):
|
||||
r = _run_ssh_test("SSH Compression + Chunk (-c -s)", ["-c", "-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_multithread_compression(self):
|
||||
r = _run_ssh_test("SSH Multithread + Compression (-m -c)", ["-m", "-c"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_multithread_chunk(self):
|
||||
r = _run_ssh_test("SSH Multithread + Chunk (-m -s)", ["-m", "-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_all_flags(self):
|
||||
r = _run_ssh_test("SSH All Flags (-m -c -s)", ["-m", "-c", "-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SSH_AVAILABLE, reason="SSH to localhost not available")
|
||||
class TestSSHFeatures:
|
||||
def test_archive(self):
|
||||
r = _run_ssh_test("SSH Archive (-a)", ["-a"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_exclude(self):
|
||||
r = _run_ssh_test("SSH Exclude (--exclude small.txt)",
|
||||
["--exclude", "small.txt"],
|
||||
expected_missing=["small.txt"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""TCP transport correctness tests."""
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR,
|
||||
ServerManager, run_client, run_client_posix,
|
||||
generate_test_files, verify_transfer, clean_dir, make_result,
|
||||
get_dest_received_dir, CLIENT_CMD,
|
||||
)
|
||||
|
||||
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "tcp_source")
|
||||
DEST_DIR = os.path.join(TEST_DATA_DIR, "tcp_dest")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def setup_test_data():
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
clean_dir(DEST_DIR)
|
||||
yield
|
||||
import shutil
|
||||
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
def _run_tcp_test(name, flags, use_metadata=True, posix=False):
|
||||
"""Run a single TCP test case with a fresh server."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
if posix:
|
||||
result, dur = run_client_posix(SOURCE_DIR, DEST_DIR,
|
||||
flags=(["-M"] if use_metadata else []) + flags,
|
||||
port=server.port)
|
||||
else:
|
||||
result, dur = run_client(SOURCE_DIR, DEST_DIR,
|
||||
flags=(["-M"] if use_metadata else []) + flags,
|
||||
port=server.port)
|
||||
|
||||
if result.returncode != 0:
|
||||
return make_result(name, False, dur, f"Exit {result.returncode}: {(result.stderr or result.stdout)[:100]}")
|
||||
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
if missing:
|
||||
return make_result(name, False, dur, f"Missing: {', '.join(missing[:5])}")
|
||||
if mismatches:
|
||||
return make_result(name, False, dur, f"Mismatch: {', '.join(mismatches[:3])}")
|
||||
return make_result(name, True, dur)
|
||||
|
||||
|
||||
class TestTCPStandard:
|
||||
def test_standard(self):
|
||||
r = _run_tcp_test("Standard", [])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_posix_args(self):
|
||||
r = _run_tcp_test("Posix Args", [], posix=True)
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_no_metadata(self):
|
||||
r = _run_tcp_test("Standard (no metadata)", [], use_metadata=False)
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
|
||||
class TestTCPFlags:
|
||||
def test_multithreading(self):
|
||||
r = _run_tcp_test("Multithreading (-m)", ["-m"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_compression(self):
|
||||
r = _run_tcp_test("Compression (-c)", ["-c"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_chunk_serialization(self):
|
||||
r = _run_tcp_test("Chunk Serialization (-s)", ["-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_compression_chunk(self):
|
||||
r = _run_tcp_test("Compression + Chunk (-c -s)", ["-c", "-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_multithread_compression(self):
|
||||
r = _run_tcp_test("Multithreading + Compression (-m -c)", ["-m", "-c"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_multithread_chunk(self):
|
||||
r = _run_tcp_test("Multithreading + Chunk (-m -s)", ["-m", "-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_all_flags(self):
|
||||
r = _run_tcp_test("Multithread + Compression + Chunk (-m -c -s)", ["-m", "-c", "-s"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_sendfile(self):
|
||||
r = _run_tcp_test("Sendfile (-f)", ["-f"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_sendfile_multithread(self):
|
||||
r = _run_tcp_test("Sendfile + Multithreading (-f -m)", ["-f", "-m"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
|
||||
class TestTCPChunkSize:
|
||||
def test_custom_chunk_size(self):
|
||||
r = _run_tcp_test("Chunk size 5MB", ["--chunk-size", "5242880"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
|
||||
def test_small_chunk_size(self):
|
||||
r = _run_tcp_test("Chunk size 1KB", ["--chunk-size", "1024"])
|
||||
assert r["status"] == "Success", r["error"]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""TLS transport tests. Generates self-signed certs for testing."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
PROJECT_ROOT, BUILD_DIR, SERVER_CMD, TEST_DATA_DIR,
|
||||
ServerManager, run_client,
|
||||
generate_test_files, verify_transfer, clean_dir, make_result,
|
||||
get_dest_received_dir, _find_free_port, _wait_proc,
|
||||
)
|
||||
|
||||
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "tls_source")
|
||||
DEST_DIR = os.path.join(TEST_DATA_DIR, "tls_dest")
|
||||
CERT_DIR = os.path.join(TEST_DATA_DIR, "tls_certs")
|
||||
|
||||
|
||||
def _generate_certs(cert_dir):
|
||||
"""Generate a self-signed CA, server cert, and client cert for testing."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_key = os.path.join(cert_dir, "ca.key")
|
||||
ca_cert = os.path.join(cert_dir, "ca.pem")
|
||||
server_key = os.path.join(cert_dir, "server.key")
|
||||
server_cert = os.path.join(cert_dir, "server.pem")
|
||||
client_key = os.path.join(cert_dir, "client.key")
|
||||
client_cert = os.path.join(cert_dir, "client.pem")
|
||||
|
||||
# CA key + cert
|
||||
subprocess.run([
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", ca_key, "-out", ca_cert,
|
||||
"-days", "1", "-subj", "/CN=FastSync Test CA",
|
||||
], check=True, capture_output=True)
|
||||
|
||||
# Server key + CSR + cert (signed by CA)
|
||||
subprocess.run([
|
||||
"openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", server_key, "-out", os.path.join(cert_dir, "server.csr"),
|
||||
"-subj", "/CN=localhost",
|
||||
], check=True, capture_output=True)
|
||||
subprocess.run([
|
||||
"openssl", "x509", "-req", "-in", os.path.join(cert_dir, "server.csr"),
|
||||
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||
"-out", server_cert, "-days", "1",
|
||||
], check=True, capture_output=True)
|
||||
|
||||
# Client key + CSR + cert (signed by CA)
|
||||
subprocess.run([
|
||||
"openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", client_key, "-out", os.path.join(cert_dir, "client.csr"),
|
||||
"-subj", "/CN=fastsync-client",
|
||||
], check=True, capture_output=True)
|
||||
subprocess.run([
|
||||
"openssl", "x509", "-req", "-in", os.path.join(cert_dir, "client.csr"),
|
||||
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||
"-out", client_cert, "-days", "1",
|
||||
], check=True, capture_output=True)
|
||||
|
||||
return {
|
||||
"ca": ca_cert,
|
||||
"server_cert": server_cert,
|
||||
"server_key": server_key,
|
||||
"client_cert": client_cert,
|
||||
"client_key": client_key,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def certs():
|
||||
"""Generate test certificates once per test module."""
|
||||
if os.path.exists(CERT_DIR):
|
||||
shutil.rmtree(CERT_DIR)
|
||||
c = _generate_certs(CERT_DIR)
|
||||
yield c
|
||||
shutil.rmtree(CERT_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def setup_test_data():
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
clean_dir(DEST_DIR)
|
||||
yield
|
||||
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
class TestTLSBasic:
|
||||
def test_tls_server_client(self, certs):
|
||||
"""Basic TLS: server with cert/key, client with cert/key + CA."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=[
|
||||
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
||||
])
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--tls",
|
||||
"--cert", certs["client_cert"], "--key", certs["client_key"],
|
||||
"--ca", certs["ca"]],
|
||||
port=server.port,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing files: {missing}"
|
||||
assert not mismatches, f"Mismatched files: {mismatches}"
|
||||
|
||||
def test_tls_with_compression(self, certs):
|
||||
"""TLS + compression."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=[
|
||||
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
||||
])
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-c", "--tls",
|
||||
"--cert", certs["client_cert"], "--key", certs["client_key"],
|
||||
"--ca", certs["ca"]],
|
||||
port=server.port,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing files: {missing}"
|
||||
assert not mismatches, f"Mismatched files: {mismatches}"
|
||||
|
||||
def test_tls_with_multithreading(self, certs):
|
||||
"""TLS + multithreading."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=[
|
||||
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
||||
])
|
||||
result, dur = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["-m", "--tls",
|
||||
"--cert", certs["client_cert"], "--key", certs["client_key"],
|
||||
"--ca", certs["ca"]],
|
||||
port=server.port,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"Exit {result.returncode}: {(result.stderr or result.stdout)[:200]}")
|
||||
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing files: {missing}"
|
||||
assert not mismatches, f"Mismatched files: {mismatches}"
|
||||
|
||||
|
||||
class TestTLSErrorCases:
|
||||
def test_server_tls_missing_cert_key(self):
|
||||
"""Server should fail if --tls is given without --cert/--key."""
|
||||
proc = subprocess.Popen(
|
||||
SERVER_CMD + ["--tls"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr = proc.communicate(timeout=5)
|
||||
assert proc.returncode != 0, "Server should fail with --tls but no cert/key"
|
||||
|
||||
def test_client_tls_missing_key(self):
|
||||
"""Client should fail if --tls is given without --key."""
|
||||
clean_dir(DEST_DIR)
|
||||
with ServerManager() as server:
|
||||
result, _ = run_client(
|
||||
SOURCE_DIR, DEST_DIR,
|
||||
flags=["--tls",
|
||||
"--cert", "/nonexistent/cert.pem"],
|
||||
port=server.port,
|
||||
)
|
||||
assert result.returncode != 0, "Client should fail with --tls but no --key"
|
||||
Reference in New Issue
Block a user