Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file removed tests/test.tar.gz
Binary file not shown.
Binary file removed tests/test.zip
Binary file not shown.
96 changes: 96 additions & 0 deletions tests/test_safe_extractor.py
Original file line number Diff line number Diff line change
@@ -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", "<h1>Hello</h1>")
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(), "<h1>Hello</h1>"
)

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()
101 changes: 101 additions & 0 deletions tests/test_tmpweb.py
Original file line number Diff line number Diff line change
@@ -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"<html></html>"))
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()