diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d1b1853 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,21 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Run tests + run: python -m unittest discover -s tests -p 'test_*.py' -v diff --git a/.gitignore b/.gitignore index 06109db..2302c30 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,10 @@ coverage.xml .pytest_cache/ cover/ +# Test archives are generated locally by tests when needed. +tests/*.tar.gz +tests/*.zip + # Translations *.mo *.pot diff --git a/README.md b/README.md index 1d9c32f..dffba5c 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,22 @@ I'd recommend adding this to the crontab: 0 */6 * * * curl -X DELETE http://127.0.0.1:8000 ``` +## Testing + +Run the test suite from the repository root with Python's standard-library test +runner: + +```bash +python3 -m unittest discover -s tests -p 'test_*.py' -v +``` + +The test suite runs without NGINX, Gunicorn, or access to the production +database. It uses temporary configuration, database, and extraction directories +so test runs remain isolated from the deployed service. + ## Roadmap/To Do -- [ ] Add tests. +- [x] Add tests. - [x] Improve front page, including adding terms of use. - [x] Migrate to shared infrastructure. - [x] Add better installation instructions. diff --git a/tests/test.tar.gz b/tests/test.tar.gz deleted file mode 100644 index 3e053d6..0000000 Binary files a/tests/test.tar.gz and /dev/null differ diff --git a/tests/test.zip b/tests/test.zip deleted file mode 100644 index a79d739..0000000 Binary files a/tests/test.zip and /dev/null differ diff --git a/tests/test_safe_extractor.py b/tests/test_safe_extractor.py new file mode 100644 index 0000000..d5c1466 --- /dev/null +++ b/tests/test_safe_extractor.py @@ -0,0 +1,96 @@ +import io +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path + +from src.safe_extractor import safe_extract + + +class SafeExtractTests(unittest.TestCase): + def test_extracts_zip_archive(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("site/index.html", "

Hello

") + archive.seek(0) + + with tempfile.TemporaryDirectory() as directory: + extract_path = Path(directory) + safe_extract(archive, extract_path, archive_type="zip") + + self.assertEqual( + (extract_path / "site/index.html").read_text(), "

Hello

" + ) + + def test_extracts_tar_archive(self): + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w:gz") as tar_file: + content = b"body{}" + info = tarfile.TarInfo("site/style.css") + info.size = len(content) + tar_file.addfile(info, io.BytesIO(content)) + archive.seek(0) + + with tempfile.TemporaryDirectory() as directory: + extract_path = Path(directory) + safe_extract(archive, extract_path, archive_type="tar") + + self.assertEqual((extract_path / "site/style.css").read_bytes(), b"body{}") + + def test_blocks_zip_path_traversal(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("../outside.txt", "should not be written") + zip_file.writestr("site/index.html", "safe") + archive.seek(0) + + with tempfile.TemporaryDirectory() as directory: + extract_path = Path(directory) + safe_extract(archive, extract_path, archive_type="zip") + + self.assertFalse((extract_path.parent / "outside.txt").exists()) + self.assertEqual((extract_path / "site/index.html").read_text(), "safe") + + def test_rejects_invalid_zip(self): + with ( + tempfile.TemporaryDirectory() as directory, + self.assertRaisesRegex(ValueError, "Bad zip file"), + ): + safe_extract( + io.BytesIO(b"not a zip"), + Path(directory), + archive_type="zip", + ) + + def test_rejects_archive_over_size_limit(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("site/index.html", "12345") + archive.seek(0) + + with ( + tempfile.TemporaryDirectory() as directory, + self.assertRaisesRegex(ValueError, "too big"), + ): + safe_extract( + archive, + Path(directory), + max_size=4, + archive_type="zip", + ) + + def test_restores_working_directory_after_failure(self): + original_directory = Path.cwd() + + with ( + tempfile.TemporaryDirectory() as directory, + self.assertRaisesRegex(ValueError, "Unknown file type"), + ): + safe_extract(io.BytesIO(), Path(directory), archive_type="rar") + + self.assertEqual(Path.cwd(), original_directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tmpweb.py b/tests/test_tmpweb.py new file mode 100644 index 0000000..7556412 --- /dev/null +++ b/tests/test_tmpweb.py @@ -0,0 +1,101 @@ +import base64 +import importlib +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +class TmpwebTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp_directory = tempfile.TemporaryDirectory() + cls.root = Path(cls.temp_directory.name) + (cls.root / "config.toml").write_text( + f'domain = "https://example.test"\n' + "default_retention = 7\n" + "max_site_size = 1024\n" + f'web_root = "{cls.root / "web"}"\n' + f'database_location = "{cls.root / "tmpweb.db"}"\n' + ) + (cls.root / "web").mkdir() + cls.original_directory = Path.cwd() + os.chdir(cls.root) + sys.path.insert(0, str(cls.original_directory / "src")) + cls.module = importlib.import_module("tmpweb") + + @classmethod + def tearDownClass(cls): + os.chdir(cls.original_directory) + sys.path.remove(str(cls.original_directory / "src")) + sys.modules.pop("tmpweb", None) + cls.temp_directory.cleanup() + + def test_is_valid_json(self): + self.assertTrue(self.module.is_valid_json(b'{"site": true}')) + self.assertTrue(self.module.is_valid_json(b" [1, 2] ")) + self.assertFalse(self.module.is_valid_json(b"")) + self.assertFalse(self.module.is_valid_json(b"{invalid}")) + + def test_unwrap_multipart_returns_first_file(self): + multipart = ( + b"--boundary\r\n" + b'Content-Disposition: form-data; name="file"; filename="site.zip"\r\n' + b"Content-Type: application/zip\r\n\r\n" + b"archive contents\r\n" + b"--boundary--\r\n" + ) + + self.assertEqual(self.module.unwrap_multipart(multipart), b"archive contents") + + def test_get_web_root_finds_directory_containing_files(self): + site_root = self.root / "nested" + (site_root / "site" / "public").mkdir(parents=True) + (site_root / "site" / "public" / "index.html").write_text("home") + + self.assertEqual( + self.module.get_web_root(site_root), site_root / "site" / "public" + ) + + def test_authorisation_accepts_matching_token(self): + token = "tmpweb_test_token" + self.module.db.execute( + "INSERT INTO api_tokens VALUES(?, ?);", (token, "test@example.test") + ) + self.module.db.commit() + credentials = base64.b64encode(f"token:{token}".encode()).decode() + + self.assertTrue( + self.module.is_authorised({"HTTP_AUTHORIZATION": f"Basic {credentials}"}) + ) + self.assertFalse( + self.module.is_authorised({"HTTP_AUTHORIZATION": "Bearer anything"}) + ) + + def test_app_routes_ping_and_rejects_remote_delete(self): + responses = [] + + def start_response(status, headers): + responses.append((status, headers)) + + ping_body = self.module.app( + {"REQUEST_METHOD": "GET", "PATH_INFO": "/ping"}, start_response + ) + self.assertEqual(responses[-1][0], "200 OK") + self.assertEqual(ping_body, []) + + delete_body = self.module.app( + { + "REQUEST_METHOD": "DELETE", + "PATH_INFO": "/", + "REMOTE_ADDR": "192.0.2.10", + }, + start_response, + ) + self.assertEqual(responses[-1][0], "403 Forbidden") + self.assertEqual(delete_body, []) + + +if __name__ == "__main__": + unittest.main()