"""Tests for the Pypiserver Docker image.""" import contextlib import os import shutil import socket import subprocess import sys import tempfile import time import typing as t from pathlib import Path import httpx import pypiserver import pytest PYPISERVER_PROCESS_NAME = "pypi-server" TEST_DEMO_PIP_PACKAGE = "pypiserver-mypkg" THIS_DIR = Path(__file__).parent ROOT_DIR = THIS_DIR.parent DOCKERFILE = ROOT_DIR / "Dockerfile" FIXTURES = ROOT_DIR / "fixtures" MYPKG_ROOT = FIXTURES / "mypkg" HTPASS_FILE = FIXTURES / "htpasswd.a.a" # This rule is largely useless when using pytest because of the need to use the # name of the fixture as an argument to the test function or fixture using it # pylint: disable=redefined-outer-name # # Also useless rule for our test context, where we may want to group test # functions in a class to share common fixtures, but where we don't care about # the `self` instance. # pylint: disable=no-self-use @pytest.fixture(scope="session") def image() -> str: """Build the docker image for pypiserver. Return the tag. """ tag = "pypiserver:test" run( "docker", "build", "--file", str(DOCKERFILE), "--tag", tag, str(ROOT_DIR), cwd=ROOT_DIR, ) return tag @pytest.fixture(scope="session") def mypkg_build() -> None: """Ensure the mypkg test fixture package is build.""" # Use make for this so that it will skip the build step if it's not needed run("make", "mypkg", cwd=ROOT_DIR) @pytest.fixture(scope="session") def mypkg_paths( mypkg_build: None, # pylint: disable=unused-argument ) -> t.Dict[str, Path]: """The path to the mypkg sdist file.""" dist_dir = Path(MYPKG_ROOT) / "dist" assert dist_dir.exists() sdist = dist_dir / "pypiserver_mypkg-1.0.0.tar.gz" assert sdist.exists() wheel = dist_dir / "pypiserver_mypkg-1.0.0-py2.py3-none-any.whl" assert wheel.exists() return { "dist_dir": dist_dir, "sdist": sdist, "wheel": wheel, } def wait_for_container(port: int) -> None: """Wait for the container to be available.""" for _ in range(60): try: httpx.get(f"http://localhost:{port}").raise_for_status() except (httpx.RequestError, httpx.HTTPStatusError): time.sleep(1) else: return # If we reach here, we've tried 60 times without success, meaning either # the container is broken or it took more than about a minute to become # functional, either of which cases is something we will want to look into. raise RuntimeError("Could not connect to pypiserver container") def get_socket() -> int: """Find a random, open socket and return it.""" # Close the socket automatically upon exiting the block with contextlib.closing( socket.socket(socket.AF_INET, socket.SOCK_STREAM) ) as sock: # Bind to a random open socket >=1024 sock.bind(("", 0)) # Return the socket number return sock.getsockname()[1] class RunReturn(t.NamedTuple): """Simple wrapper around a simple subprocess call's results.""" returncode: int out: str err: str def run( *cmd: str, capture: bool = False, raise_on_err: bool = True, check_code: t.Callable[[int], bool] = lambda c: c == 0, **popen_kwargs: t.Any, ) -> RunReturn: """Run a command to completion.""" stdout = subprocess.PIPE if capture else None stderr = subprocess.PIPE if capture else None proc = subprocess.Popen(cmd, stdout=stdout, stderr=stderr, **popen_kwargs) out, err = proc.communicate() result = RunReturn( proc.returncode, "" if out is None else out.decode(), "" if err is None else err.decode(), ) if raise_on_err and not check_code(result.returncode): raise RuntimeError(result) return result def uninstall_pkgs() -> None: """Uninstall any packages we've installed.""" res = run("pip", "freeze", capture=True) if any( ln.strip().startswith(TEST_DEMO_PIP_PACKAGE) for ln in res.out.splitlines() ): run("pip", "uninstall", "-y", TEST_DEMO_PIP_PACKAGE) @pytest.fixture(scope="session", autouse=True) def session_cleanup() -> t.Iterator[None]: """Deal with any pollution of the local env.""" yield uninstall_pkgs() @pytest.fixture() def cleanup() -> t.Iterator[None]: """Clean up after tests that may have affected the env.""" yield uninstall_pkgs() class TestCommands: """Test commands other than `run`.""" def test_help(self, image: str) -> None: """We can get help from the docker container.""" res = run("docker", "run", image, "--help", capture=True) assert PYPISERVER_PROCESS_NAME in res.out def test_version(self, image: str) -> None: """We can get the version from the docker container.""" res = run("docker", "run", image, "--version", capture=True) assert res.out.strip() == pypiserver.__version__ class TestPermissions: """Test permission validation, especially with mounted volumes.""" @pytest.mark.parametrize("perms", (0o706, 0o701, 0o704)) def test_needs_rx_on_data(self, image: str, perms: int) -> None: """Read and execute permissions are required on /data.""" # Note we can't run this one as root because then we have to make a file # that even we can't delete. with tempfile.TemporaryDirectory() as tmpdir: # Make sure the directory is not readable for anyone other than # the owner data_dir = Path(tmpdir) / "data" data_dir.mkdir(mode=perms) res = run( "docker", "run", "--rm", "--user", # Run as a not us user ID, so access to /data will be # determined by the "all other users" setting str(os.getuid() + 1), "-v", # Mount the temporary directory as the /data directory f"{data_dir}:/data", image, capture=True, # This should error out, so we check that the code is non-zero check_code=lambda c: c != 0, ) assert "must have read/execute access" in res.err @pytest.mark.parametrize( "extra_args", (("--user", str(os.getuid())), ("--user", str(os.getuid() + 1))), ) def test_needs_rwx_on_packages(self, image: str, extra_args: tuple) -> None: """RWX permission is required on /data/packages.""" with tempfile.TemporaryDirectory() as tmpdir: td_path = Path(tmpdir) # Make the /data directory read/writable by anyone td_path.chmod(0o777) # Add the /data/packages directory, and make it readable by anyone, # but writable only by the owner (td_path / "packages").mkdir(mode=0o444) res = run( "docker", "run", "--rm", *extra_args, "-v", # Mount the temporary directory as the /data directory f"{tmpdir}:/data", image, capture=True, # We should error out in this case check_code=lambda c: c != 0, ) assert "must have read/write/execute access" in res.err def test_runs_as_pypiserver_user(self, image: str) -> None: """Test that the default run uses the pypiserver user.""" host_port = get_socket() res = run( "docker", "run", "--rm", "--detach", "--publish", f"{host_port}:8080", image, capture=True, ) container_id = res.out.strip() try: wait_for_container(host_port) res = run( "docker", "container", "exec", container_id, "ps", "a", capture=True, ) proc_line = next( filter( # grab the process line for the pypi-server process lambda ln: PYPISERVER_PROCESS_NAME in ln, res.out.splitlines(), ) ) user = proc_line.split()[1] # the ps command on these alpine containers doesn't always show the # full user name, so we only check for the first bit assert user.startswith("pypi") finally: run("docker", "container", "rm", "-f", container_id) class ContainerInfo(t.NamedTuple): """Info about a running container""" container_id: str port: int args: tuple class TestBasics: """Test basic pypiserver functionality in a simple unauthed container.""" # We want to automatically parametrize this class' tests with a variety of # pypiserver args, since it should work the same in all of these cases @pytest.fixture( scope="class", params=[ # default (gunicorn) server with cached backend (), # default (gunicorn) server with non-cached backend ("--backend", "simple-dir"), # explicit gunicorn server with a non-cached backend ("--server", "gunicorn", "--backend", "simple-dir"), # explicit gunicorn server ("--server", "gunicorn"), # explicit waitress server ("--server", "wsgiref"), # explicit wsgiref server ("--server", "wsgiref"), ], ) def container( self, request: pytest.FixtureRequest, image: str ) -> t.Iterator[ContainerInfo]: """Run the pypiserver container. Returns the container ID. """ port = get_socket() args = ( "docker", "run", "--rm", "--publish", f"{port}:8080", "--detach", image, "run", "--passwords", ".", "--authenticate", ".", *request.param, ) res = run(*args, capture=True) wait_for_container(port) container_id = res.out.strip() yield ContainerInfo(container_id, port, args) run("docker", "container", "rm", "-f", container_id) @pytest.fixture(scope="class") def upload_mypkg( self, container: ContainerInfo, mypkg_paths: t.Dict[str, Path], ) -> None: """Upload mypkg to the container.""" run( sys.executable, "-m", "twine", "upload", "--repository-url", f"http://localhost:{container.port}", "--username", "", "--password", "", f"{mypkg_paths['dist_dir']}/*", ) @pytest.mark.usefixtures("upload_mypkg") def test_download(self, container: ContainerInfo) -> None: """Download mypkg from the container.""" with tempfile.TemporaryDirectory() as tmpdir: run( sys.executable, "-m", "pip", "download", "--index-url", f"http://localhost:{container.port}/simple", "--dest", tmpdir, "pypiserver_mypkg", ) assert any( "pypiserver_mypkg" in path.name for path in Path(tmpdir).iterdir() ) @pytest.mark.usefixtures("upload_mypkg", "cleanup") def test_install(self, container: ContainerInfo) -> None: """Install mypkg from the container. Note this also ensures that name normalization is working, since we are requesting the package name with a dash, rather than an underscore. """ run( sys.executable, "-m", "pip", "install", "--force-reinstall", "--index-url", f"http://localhost:{container.port}/simple", TEST_DEMO_PIP_PACKAGE, ) run("python", "-c", "'import pypiserver_mypkg; mypkg.pkg_name()'") def test_expected_server(self, container: ContainerInfo) -> None: """Ensure we run the server we think we're running.""" resp = httpx.get(f"http://localhost:{container.port}") server = resp.headers["server"].lower() arg_pairs = tuple(zip(container.args, container.args[1:])) if ( container.args[-1] == "pypiserver:test" or ("--server", "gunicorn") in arg_pairs ): # We specified no overriding args, so we should run gunicorn, or # we specified gunicorn in overriding args. assert "gunicorn" in server elif ("--server", "wsgiref") in arg_pairs: # We explicitly specified the wsgiref server assert "wsgiserver" in server elif ("--server", "waitress") in arg_pairs: # We explicitly specified the wsgiref server assert "waitress" in server else: # We overrode args, so instead of using the gunicorn default, # we use the `auto` option. Bottle won't choose gunicorn as an # auto server, so we have waitress installed in the docker container # as a fallback for these scenarios, since wsgiref is not production # ready assert "waitress" in server def test_welcome(self, container: ContainerInfo) -> None: """View the welcome page.""" resp = httpx.get(f"http://localhost:{container.port}") assert resp.status_code == 200 assert "pypiserver" in resp.text class TestAuthed: """Test basic pypiserver functionality in a simple unauthed container.""" HOST_PORT = get_socket() @pytest.fixture(scope="class") def container(self, image: str) -> t.Iterator[str]: """Run the pypiserver container. Returns the container ID. """ with tempfile.TemporaryDirectory() as tmpdir: dirpath = Path(tmpdir) shutil.copy2(HTPASS_FILE, dirpath / "htpasswd") pkg_path = dirpath / "packages" pkg_path.mkdir(mode=0o777) res = run( "docker", "run", "--rm", "--publish", f"{self.HOST_PORT}:8080", "-v", f"{dirpath / 'htpasswd'}:/data/htpasswd", "--detach", image, "run", "--passwords", "/data/htpasswd", "--authenticate", "download, update", capture=True, ) wait_for_container(self.HOST_PORT) container_id = res.out.strip() yield container_id run("docker", "container", "rm", "-f", container_id) @pytest.fixture(scope="class") def upload_mypkg( self, container: str, # pylint: disable=unused-argument mypkg_paths: t.Dict[str, Path], ) -> None: """Upload mypkg to the container.""" run( sys.executable, "-m", "twine", "upload", "--repository-url", f"http://localhost:{self.HOST_PORT}", "--username", "a", "--password", "a", f"{mypkg_paths['dist_dir']}/*", ) def test_upload_failed_auth( self, container: str, # pylint: disable=unused-argument mypkg_paths: t.Dict[str, Path], ) -> None: """Upload mypkg to the container.""" run( sys.executable, "-m", "twine", "upload", "--repository-url", f"http://localhost:{self.HOST_PORT}", f"{mypkg_paths['dist_dir']}/*", check_code=lambda c: c != 0, ) @pytest.mark.usefixtures("upload_mypkg") def test_download(self) -> None: """Download mypkg from the container.""" with tempfile.TemporaryDirectory() as tmpdir: run( sys.executable, "-m", "pip", "download", "--index-url", f"http://a:a@localhost:{self.HOST_PORT}/simple", "--dest", tmpdir, "pypiserver_mypkg", ) assert any( "pypiserver_mypkg" in path.name for path in Path(tmpdir).iterdir() ) @pytest.mark.usefixtures("upload_mypkg") def test_download_failed_auth(self) -> None: """Download mypkg from the container.""" with tempfile.TemporaryDirectory() as tmpdir: run( sys.executable, "-m", "pip", "download", "--index-url", f"http://foo:bar@localhost:{self.HOST_PORT}/simple", "--dest", tmpdir, "pypiserver_mypkg", check_code=lambda c: c != 0, ) @pytest.mark.usefixtures("upload_mypkg", "cleanup") def test_install(self) -> None: """Install mypkg from the container. Note this also ensures that name normalization is working, since we are requesting the package name with a dash, rather than an underscore. """ run( sys.executable, "-m", "pip", "install", "--force-reinstall", "--index-url", f"http://a:a@localhost:{self.HOST_PORT}/simple", TEST_DEMO_PIP_PACKAGE, ) run("python", "-c", "'import pypiserver_mypkg; mypkg.pkg_name()'") @pytest.mark.usefixtures("upload_mypkg", "cleanup") def test_install_failed_auth(self) -> None: """Install mypkg from the container. Note this also ensures that name normalization is working, since we are requesting the package name with a dash, rather than an underscore. """ run( sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-cache", "--index-url", f"http://foo:bar@localhost:{self.HOST_PORT}/simple", TEST_DEMO_PIP_PACKAGE, check_code=lambda c: c != 0, ) def test_welcome(self) -> None: """View the welcome page.""" resp = httpx.get(f"http://localhost:{self.HOST_PORT}") assert resp.status_code == 200 assert "pypiserver" in resp.text