|
1 | 1 | """Tests for the Towlion spec conformance validator.""" |
2 | 2 |
|
3 | 3 | import os |
| 4 | +import subprocess |
4 | 5 | import sys |
| 6 | +from unittest.mock import patch, MagicMock |
5 | 7 |
|
6 | 8 | import pytest |
7 | 9 |
|
@@ -114,3 +116,166 @@ def test_non_strict_mode_allows_warnings(self): |
114 | 116 | v = Validator(app_dir=VALID_APP, tier=1, strict=False) |
115 | 117 | exit_code = v.run() |
116 | 118 | assert exit_code == 0 |
| 119 | + |
| 120 | + |
| 121 | +class TestTier2DockerfileSecurity: |
| 122 | + def _make_app(self, tmp_path, dockerfile_content): |
| 123 | + """Create minimal app structure with given Dockerfile.""" |
| 124 | + (tmp_path / "app").mkdir() |
| 125 | + (tmp_path / "app" / "Dockerfile").write_text(dockerfile_content) |
| 126 | + (tmp_path / "app" / "main.py").write_text('from fastapi import FastAPI\napp = FastAPI()\n@app.get("/health")\ndef h(): return {"status":"ok"}') |
| 127 | + (tmp_path / "deploy").mkdir() |
| 128 | + (tmp_path / "deploy" / "docker-compose.yml").write_text("services:\n app:\n build: .\n healthcheck:\n test: curl http://localhost:8000/health\n") |
| 129 | + (tmp_path / "deploy" / "docker-compose.standalone.yml").write_text("services:\n app:\n build: .\n ports:\n - '8000:8000'\n") |
| 130 | + (tmp_path / "deploy" / "Caddyfile").write_text("{$APP_DOMAIN}\nreverse_proxy app:8000\n") |
| 131 | + (tmp_path / "deploy" / "env.template").write_text("APP_DOMAIN=x\nDATABASE_URL=x\nREDIS_URL=x\nJWT_SECRET=x\n") |
| 132 | + (tmp_path / ".github" / "workflows").mkdir(parents=True) |
| 133 | + (tmp_path / ".github" / "workflows" / "deploy.yml").write_text("on: push\n") |
| 134 | + (tmp_path / "scripts").mkdir() |
| 135 | + (tmp_path / "scripts" / "health-check.sh").write_text("#!/bin/sh\n") |
| 136 | + (tmp_path / "README.md").write_text("# App\n") |
| 137 | + (tmp_path / "requirements.txt").write_text("fastapi\nuvicorn\n") |
| 138 | + |
| 139 | + def test_dockerfile_with_user_passes(self): |
| 140 | + v = run_validator(VALID_APP, tier=2) |
| 141 | + assert has_result(v, Result.PASS, "Dockerfile sets USER") |
| 142 | + |
| 143 | + def test_dockerfile_without_user_warns(self, tmp_path): |
| 144 | + self._make_app(tmp_path, "FROM python:3.12-slim\nWORKDIR /app\nCMD ['python']\n") |
| 145 | + v = run_validator(str(tmp_path), tier=2) |
| 146 | + assert has_result(v, Result.WARN, "Dockerfile sets USER") |
| 147 | + |
| 148 | + def test_dockerfile_add_url_warns(self, tmp_path): |
| 149 | + self._make_app(tmp_path, "FROM python:3.12-slim\nADD https://example.com/file.tar.gz /app/\nUSER app\nCMD ['python']\n") |
| 150 | + v = run_validator(str(tmp_path), tier=2) |
| 151 | + assert has_result(v, Result.WARN, "Dockerfile ADD from URL") |
| 152 | + |
| 153 | + def test_dockerfile_env_secret_warns(self, tmp_path): |
| 154 | + self._make_app(tmp_path, "FROM python:3.12-slim\nENV DB_PASSWORD=hunter2\nUSER app\nCMD ['python']\n") |
| 155 | + v = run_validator(str(tmp_path), tier=2) |
| 156 | + assert has_result(v, Result.WARN, "Dockerfile ENV secrets") |
| 157 | + |
| 158 | + def test_dockerfile_missing_skips(self, tmp_path): |
| 159 | + self._make_app(tmp_path, "") |
| 160 | + os.remove(tmp_path / "app" / "Dockerfile") |
| 161 | + v = run_validator(str(tmp_path), tier=2) |
| 162 | + assert has_result(v, Result.SKIP, "Dockerfile security") |
| 163 | + |
| 164 | + |
| 165 | +class TestTier2ComposeHardening: |
| 166 | + def test_resource_limits_present(self): |
| 167 | + v = run_validator(VALID_APP, tier=2) |
| 168 | + assert has_result(v, Result.PASS, "Resource limits configured") |
| 169 | + |
| 170 | + def test_resource_limits_missing_warns(self, tmp_path): |
| 171 | + (tmp_path / "deploy").mkdir() |
| 172 | + (tmp_path / "deploy" / "docker-compose.yml").write_text("services:\n app:\n build: .\n healthcheck:\n test: curl http://localhost:8000/health\n") |
| 173 | + v = Validator(app_dir=str(tmp_path), tier=2, strict=False) |
| 174 | + v._check_resource_limits() |
| 175 | + assert has_result(v, Result.WARN, "Resource limits configured") |
| 176 | + |
| 177 | + def test_read_only_fs_present(self): |
| 178 | + v = run_validator(VALID_APP, tier=2) |
| 179 | + assert has_result(v, Result.PASS, "Read-only filesystem enabled") |
| 180 | + |
| 181 | + def test_read_only_fs_missing_warns(self, tmp_path): |
| 182 | + (tmp_path / "deploy").mkdir() |
| 183 | + (tmp_path / "deploy" / "docker-compose.yml").write_text("services:\n app:\n build: .\n") |
| 184 | + v = Validator(app_dir=str(tmp_path), tier=2, strict=False) |
| 185 | + v._check_read_only_fs() |
| 186 | + assert has_result(v, Result.WARN, "Read-only filesystem enabled") |
| 187 | + |
| 188 | + |
| 189 | +class TestTier2AlembicConfig: |
| 190 | + def test_alembic_with_config_passes(self): |
| 191 | + v = run_validator(VALID_APP, tier=2) |
| 192 | + assert has_result(v, Result.PASS, "Alembic config present") |
| 193 | + |
| 194 | + def test_alembic_without_config_warns(self, tmp_path): |
| 195 | + (tmp_path / "requirements.txt").write_text("fastapi\nalembic\n") |
| 196 | + v = Validator(app_dir=str(tmp_path), tier=2, strict=False) |
| 197 | + v._check_alembic_config() |
| 198 | + assert has_result(v, Result.WARN, "Alembic config present") |
| 199 | + |
| 200 | + def test_no_alembic_dep_skips(self, tmp_path): |
| 201 | + (tmp_path / "requirements.txt").write_text("fastapi\nuvicorn\n") |
| 202 | + v = Validator(app_dir=str(tmp_path), tier=2, strict=False) |
| 203 | + v._check_alembic_config() |
| 204 | + assert has_result(v, Result.SKIP, "Alembic config") |
| 205 | + |
| 206 | + |
| 207 | +class TestTier3WithMocking: |
| 208 | + """Tier 3 tests using mocked subprocess.run — no Docker required.""" |
| 209 | + |
| 210 | + def _make_mock_result(self, returncode=0, stdout="", stderr=""): |
| 211 | + mock = MagicMock() |
| 212 | + mock.returncode = returncode |
| 213 | + mock.stdout = stdout |
| 214 | + mock.stderr = stderr |
| 215 | + return mock |
| 216 | + |
| 217 | + @patch("subprocess.run") |
| 218 | + def test_compose_config_pass(self, mock_run): |
| 219 | + mock_run.return_value = self._make_mock_result(returncode=0) |
| 220 | + v = Validator(app_dir=VALID_APP, tier=3, strict=False) |
| 221 | + v._check_compose_config() |
| 222 | + assert has_result(v, Result.PASS, "docker compose config validates") |
| 223 | + |
| 224 | + @patch("subprocess.run") |
| 225 | + def test_compose_config_fail(self, mock_run): |
| 226 | + mock_run.return_value = self._make_mock_result(returncode=1, stderr="invalid yaml") |
| 227 | + v = Validator(app_dir=VALID_APP, tier=3, strict=False) |
| 228 | + v._check_compose_config() |
| 229 | + assert has_result(v, Result.FAIL, "docker compose config validates") |
| 230 | + |
| 231 | + @patch("subprocess.run") |
| 232 | + def test_container_build_pass(self, mock_run): |
| 233 | + mock_run.return_value = self._make_mock_result(returncode=0) |
| 234 | + v = Validator(app_dir=VALID_APP, tier=3, strict=False) |
| 235 | + v._check_container_build() |
| 236 | + assert has_result(v, Result.PASS, "Container builds successfully") |
| 237 | + |
| 238 | + @patch("subprocess.run") |
| 239 | + def test_container_build_fail(self, mock_run): |
| 240 | + mock_run.return_value = self._make_mock_result(returncode=1, stderr="build error") |
| 241 | + v = Validator(app_dir=VALID_APP, tier=3, strict=False) |
| 242 | + v._check_container_build() |
| 243 | + assert has_result(v, Result.FAIL, "Container builds successfully") |
| 244 | + |
| 245 | + def test_container_build_missing_skips(self, tmp_path): |
| 246 | + v = Validator(app_dir=str(tmp_path), tier=3, strict=False) |
| 247 | + v._check_container_build() |
| 248 | + assert has_result(v, Result.SKIP, "Container build") |
| 249 | + |
| 250 | + @patch("subprocess.run") |
| 251 | + def test_docker_not_available_skips_all(self, mock_run): |
| 252 | + mock_run.side_effect = FileNotFoundError("docker not found") |
| 253 | + v = Validator(app_dir=VALID_APP, tier=3, strict=False) |
| 254 | + v.check_runtime() |
| 255 | + assert has_result(v, Result.SKIP, "Docker available") |
| 256 | + assert len(v.results) == 1 |
| 257 | + |
| 258 | + @patch("subprocess.run") |
| 259 | + def test_frontend_build_pass(self, mock_run): |
| 260 | + mock_run.return_value = self._make_mock_result(returncode=0) |
| 261 | + v = Validator(app_dir=VALID_APP, tier=3, strict=False) |
| 262 | + v._check_frontend_build() |
| 263 | + # valid-app has no frontend, so it should skip |
| 264 | + assert has_result(v, Result.SKIP, "Frontend build") |
| 265 | + |
| 266 | + def test_frontend_build_missing_skips(self, tmp_path): |
| 267 | + v = Validator(app_dir=str(tmp_path), tier=3, strict=False) |
| 268 | + v._check_frontend_build() |
| 269 | + assert has_result(v, Result.SKIP, "Frontend build") |
| 270 | + |
| 271 | + @patch("subprocess.run") |
| 272 | + def test_standalone_compose_config_pass(self, mock_run): |
| 273 | + mock_run.return_value = self._make_mock_result(returncode=0) |
| 274 | + v = Validator(app_dir=VALID_APP, tier=3, strict=False) |
| 275 | + v._check_standalone_compose_config() |
| 276 | + assert has_result(v, Result.PASS, "Standalone compose config validates") |
| 277 | + |
| 278 | + def test_standalone_compose_config_missing_skips(self, tmp_path): |
| 279 | + v = Validator(app_dir=str(tmp_path), tier=3, strict=False) |
| 280 | + v._check_standalone_compose_config() |
| 281 | + assert has_result(v, Result.SKIP, "Standalone compose config") |
0 commit comments