refactor: split test.py into modular pytest integration tests + benchmark tool
CI / build-and-test (push) Failing after 0s
CI / build-and-test (pull_request) Successful in 3m17s

- 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:
2026-07-18 19:19:33 +02:00
parent 43ba149ad5
commit b28bac9f41
12 changed files with 1279 additions and 743 deletions
+5
View File
@@ -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"))
View File
+181
View File
@@ -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()
+299
View File
@@ -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}"
+81
View File
@@ -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)
+132
View File
@@ -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"]
+111
View File
@@ -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"]
+181
View File
@@ -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"