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()