diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59f447a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Oldest supported and current — catches syntax drift both ways. + python-version: ["3.9", "3.14"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Lint (ruff) + run: ruff check . + + - name: Test (pytest) + run: python -m pytest -v diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..95db73a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest~=8.0 +ruff~=0.15.0 diff --git a/ruff.toml b/ruff.toml index c557e66..ba6e0a7 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,4 +1,6 @@ line-length = 79 +exclude = ["data/workflows/scripts"] + [format] -quote-style = "double" \ No newline at end of file +quote-style = "double" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..da19f88 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,32 @@ +# JAWA Test Suite + +Smoke tests for the JAWA web console and webhook receiver. The suite +needs **no Jamf Pro server, no network access, and no root** — Jamf +API calls are faked at the `requests` layer, script execution is +stubbed at `subprocess.Popen`, and all data files are redirected into +a per-test temp directory (test runs leave no artifacts in the repo). + +## Running + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements-dev.txt +.venv/bin/python -m pytest +``` + +## Layout + +- `conftest.py` — fixtures: temp data layout (`jawa_env`), faked + Jamf Pro (`fake_jamf`), and an authenticated console session + (`logged_in_client`). +- `test_smoke_routes.py` — every blueprint surface renders without + a 500; legacy redirects; anonymous access is rejected. +- `test_receiver.py` — `/hooks/` auth validation and script + execution pipeline. +- `test_login.py` — console login/logout against the faked Jamf. + +## xfail markers + +Tests marked `xfail(strict=True)` document known bugs (referenced by +issue ID). When the bug is fixed, the test will XPASS and fail the +run — remove the marker as part of the fix. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..09abcba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,222 @@ +"""Shared fixtures for the JAWA smoke-test harness. + +JAWA resolves every data path as a module-level absolute constant +derived from the repo location, so these fixtures redirect each +module's constants into a per-test temp directory. Jamf Pro HTTP +calls are faked at the ``requests`` layer. No Jamf server, network +access, or root privileges are required to run the suite. +""" + +import json +import logging +import os +import shutil +import sys + +import pytest +import requests + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + +# Importing bin.logger attaches a rotating file handler pointing at +# the repo's data/jawa.log. Swap it for a NullHandler so test runs +# leave no log artifacts in the working tree. +_LOG_PATH = os.path.join(REPO_ROOT, "data", "jawa.log") +_log_preexisting = os.path.exists(_LOG_PATH) + +from bin import logger # noqa: E402,F401 + +_root_logger = logging.getLogger("jawa") +for _handler in list(_root_logger.handlers): + _handler.close() + _root_logger.removeHandler(_handler) +_root_logger.addHandler(logging.NullHandler()) + +if not _log_preexisting and os.path.exists(_LOG_PATH): + os.remove(_LOG_PATH) + +import app as jawa_app # noqa: E402 +from bin import data_store # noqa: E402 +from views import ( # noqa: E402 + credential_view, + home_view, + log_view, + resource_view, + search_view, + template_view, +) +from webhook import jawa_receiver # noqa: E402 + +# The module-level Flask app is a singleton; register blueprints once. +jawa_app.register_blueprints() + +JAMF_URL = "https://jamf.example.test" +TOKEN_EXPIRES = "2099-01-01T00:00:00.000+0000" + + +class FakeJamfResponse: + """Minimal stand-in for requests.Response.""" + + def __init__(self, payload=None, status_code=200): + self._payload = payload if payload is not None else {} + self.status_code = status_code + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.exceptions.HTTPError( + f"{self.status_code} Fake Jamf error" + ) + + +def _fake_jamf_post(url, **kwargs): + if url.endswith("/api/v1/auth/token"): + return FakeJamfResponse( + {"token": "test-token", "expires": TOKEN_EXPIRES} + ) + if url.endswith("/api/v1/auth/invalidate-token"): + return FakeJamfResponse({}, status_code=204) + return FakeJamfResponse({}) + + +def _fake_jamf_get(url, **kwargs): + return FakeJamfResponse({}) + + +@pytest.fixture() +def fake_jamf(monkeypatch): + """Fake every outbound HTTP verb JAWA uses against Jamf Pro.""" + monkeypatch.setattr(requests, "post", _fake_jamf_post) + monkeypatch.setattr(requests, "get", _fake_jamf_get) + monkeypatch.setattr( + requests, "put", lambda *a, **k: FakeJamfResponse({}) + ) + monkeypatch.setattr( + requests, "delete", lambda *a, **k: FakeJamfResponse({}) + ) + + +class JawaEnv: + """Handles to the per-test temp data layout.""" + + def __init__(self, root): + self.root = root + self.data_dir = root / "data" + self.scripts_dir = root / "scripts" + self.files_dir = root / "resources" / "files" + self.webhooks_file = self.data_dir / "webhooks.json" + self.cron_file = self.data_dir / "cron.json" + self.server_file = self.data_dir / "server.json" + self.credentials_file = self.data_dir / "credentials.json" + self.log_file = self.data_dir / "jawa.log" + + def add_webhook(self, entry): + data = json.loads(self.webhooks_file.read_text()) + data.append(entry) + self.webhooks_file.write_text(json.dumps(data)) + + +@pytest.fixture() +def jawa_env(tmp_path, monkeypatch): + """Point every module-level path constant at a temp directory.""" + env = JawaEnv(tmp_path) + env.data_dir.mkdir() + env.scripts_dir.mkdir() + env.files_dir.mkdir(parents=True) + + env.webhooks_file.write_text("[]") + env.cron_file.write_text("[]") + env.credentials_file.write_text("[]") + env.server_file.write_text( + json.dumps( + { + "jawa_address": "https://jawa.example.test", + "jps_url": JAMF_URL, + "alternate_jps": "", + } + ) + ) + env.log_file.touch() + shutil.copy( + os.path.join(REPO_ROOT, "data", "time.json"), + env.data_dir / "time.json", + ) + + webhooks = str(env.webhooks_file) + cron = str(env.cron_file) + server = str(env.server_file) + creds = str(env.credentials_file) + scripts = str(env.scripts_dir) + log_file = str(env.log_file) + + monkeypatch.setattr(data_store, "WEBHOOKS_FILE", webhooks) + monkeypatch.setattr(data_store, "CRON_FILE", cron) + monkeypatch.setattr(data_store, "SERVER_FILE", server) + monkeypatch.setattr( + data_store, "TIME_FILE", str(env.data_dir / "time.json") + ) + monkeypatch.setattr(data_store, "SCRIPTS_DIR", scripts) + + monkeypatch.setattr(home_view, "log_file", log_file) + monkeypatch.setattr(home_view, "server_file", server) + monkeypatch.setattr(home_view, "webhooks_file", webhooks) + monkeypatch.setattr(home_view, "cron_file", cron) + monkeypatch.setattr( + home_view, "resources_dir", str(env.root / "resources") + ) + monkeypatch.setattr(home_view, "files_dir", str(env.files_dir)) + + monkeypatch.setattr(log_view, "log_file", log_file) + monkeypatch.setattr(log_view, "server_json_file", server) + + monkeypatch.setattr(credential_view, "CREDENTIALS_FILE", creds) + + monkeypatch.setattr(resource_view, "log_file", log_file) + monkeypatch.setattr(resource_view, "server_file", server) + monkeypatch.setattr( + resource_view, "resources_dir", str(env.root / "resources") + ) + monkeypatch.setattr(resource_view, "files_dir", str(env.files_dir)) + + monkeypatch.setattr(search_view, "WEBHOOKS_FILE", webhooks) + monkeypatch.setattr(search_view, "CRON_FILE", cron) + + monkeypatch.setattr(template_view, "SCRIPTS_DIR", scripts) + monkeypatch.setattr(template_view, "WEBHOOKS_FILE", webhooks) + monkeypatch.setattr(template_view, "CREDENTIALS_FILE", creds) + + monkeypatch.setattr(jawa_receiver, "server_json_file", server) + monkeypatch.setattr(jawa_receiver, "jp_webhooks_file", webhooks) + monkeypatch.setattr(jawa_receiver, "scripts_dir", scripts) + + # app.py keeps its own globals, set via environment_setup(). + jawa_app.environment_setup(str(env.root)) + + jawa_app.app.config.update(TESTING=True) + jawa_app.app.secret_key = "jawa-test-secret" + return env + + +@pytest.fixture() +def client(jawa_env): + return jawa_app.app.test_client() + + +@pytest.fixture() +def logged_in_client(client, fake_jamf): + """A test client holding an authenticated console session.""" + resp = client.post( + "/login", + data={ + "url": JAMF_URL, + "username": "pytest-admin", + "password": "hunter2", + }, + ) + assert resp.status_code == 302, "login should redirect on success" + assert "/dashboard" in resp.headers["Location"] + return client diff --git a/tests/test_login.py b/tests/test_login.py new file mode 100644 index 0000000..78a179f --- /dev/null +++ b/tests/test_login.py @@ -0,0 +1,28 @@ +"""Console login flow against a fully faked Jamf Pro.""" + + +def test_successful_login_reaches_dashboard(logged_in_client): + resp = logged_in_client.get("/dashboard") + assert resp.status_code == 200 + assert b"dashboard" in resp.data.lower() + + +def test_blank_password_is_rejected(client, fake_jamf): + resp = client.post( + "/login", + data={"url": "https://jamf.example.test", "username": "u", + "password": ""}, + ) + assert resp.status_code == 302 + assert "/logout" in resp.headers["Location"] + + +def test_logout_clears_session(logged_in_client): + logged_in_client.get("/logout") + resp = logged_in_client.get("/dashboard") + assert resp.status_code == 302 + + +def test_anonymous_home_renders(client): + resp = client.get("/") + assert resp.status_code == 200 diff --git a/tests/test_receiver.py b/tests/test_receiver.py new file mode 100644 index 0000000..f721166 --- /dev/null +++ b/tests/test_receiver.py @@ -0,0 +1,182 @@ +"""Inbound webhook receiver: auth validation and script execution. + +Script execution is stubbed at subprocess.Popen, so these tests +verify the full request -> validate -> execute pipeline without +running real scripts. +""" + +import base64 +import io +import json +import subprocess + +import pytest + + +class RecordingPopen: + """Stands in for subprocess.Popen and records invocations.""" + + calls = [] + + def __init__(self, args, stdout=None, stderr=None): + RecordingPopen.calls.append(args) + self.stdout = io.BytesIO(b"script ran\n") + + def wait(self): + return 0 + + +@pytest.fixture() +def fake_popen(monkeypatch): + RecordingPopen.calls = [] + monkeypatch.setattr(subprocess, "Popen", RecordingPopen) + return RecordingPopen + + +def _basic_auth(user, password): + token = base64.b64encode(f"{user}:{password}".encode()).decode() + return {"Authorization": f"Basic {token}"} + + +def _make_webhook(jawa_env, **overrides): + entry = { + "name": "testhook", + "tag": "custom", + "script": str(jawa_env.scripts_dir / "hook_script.sh"), + "webhook_username": "hookuser", + "webhook_password": "hookpass", + "api_key": "null", + "output": False, + "description": "harness webhook", + } + entry.update(overrides) + jawa_env.add_webhook(entry) + return entry + + +PAYLOAD = {"webhook": {"webhookEvent": "ComputerCheckIn"}} + + +def test_valid_auth_runs_script(client, jawa_env, fake_popen): + entry = _make_webhook(jawa_env) + resp = client.post( + "/hooks/testhook", + json=PAYLOAD, + headers=_basic_auth("hookuser", "hookpass"), + ) + assert resp.status_code == 200 + assert resp.get_json()["result"] == "valid webhook received" + assert len(fake_popen.calls) == 1 + argv = fake_popen.calls[0] + assert argv[0] == entry["script"] + # The whole payload is passed as a single JSON argument. + assert json.loads(argv[1]) == PAYLOAD + + +def test_wrong_password_is_rejected(client, jawa_env, fake_popen): + _make_webhook(jawa_env) + resp = client.post( + "/hooks/testhook", + json=PAYLOAD, + headers=_basic_auth("hookuser", "wrong"), + ) + assert resp.status_code == 401 + assert fake_popen.calls == [] + + +def test_unknown_webhook_is_rejected(client, jawa_env, fake_popen): + resp = client.post( + "/hooks/ghosthook", + json=PAYLOAD, + headers=_basic_auth("any", "any"), + ) + assert resp.status_code == 401 + assert fake_popen.calls == [] + + +def test_api_key_auth_runs_script(client, jawa_env, fake_popen): + _make_webhook( + jawa_env, + webhook_username="null", + webhook_password="null", + api_key="sekrit", + ) + resp = client.post( + "/hooks/testhook", + json=PAYLOAD, + headers={"x-api-key": "sekrit"}, + ) + assert resp.status_code == 200 + assert len(fake_popen.calls) == 1 + + +def test_noauth_webhook_accepts_anonymous_post( + client, jawa_env, fake_popen +): + # Documented posture (ASSESSMENT.md): a webhook stored with the + # "null" sentinels is open to anyone who knows its name. If this + # test starts failing, the auth model changed on purpose -- + # update the assessment. + _make_webhook( + jawa_env, + webhook_username="null", + webhook_password="null", + api_key="null", + ) + resp = client.post("/hooks/testhook", json=PAYLOAD) + assert resp.status_code == 200 + assert len(fake_popen.calls) == 1 + + +def test_custom_output_returns_script_result( + client, jawa_env, fake_popen +): + _make_webhook(jawa_env, output=True) + resp = client.post( + "/hooks/testhook", + json=PAYLOAD, + headers=_basic_auth("hookuser", "hookpass"), + ) + assert resp.status_code == 202 + assert "script ran" in resp.get_json()["result"] + + +def test_okta_verification_challenge_is_echoed(client, jawa_env): + resp = client.post( + "/hooks/anyhook", + headers={"x-okta-verification-challenge": "abc123"}, + ) + assert resp.status_code == 200 + assert resp.get_json() == {"verification": "abc123"} + + +def test_null_json_body_is_a_teapot(client, jawa_env, fake_popen): + resp = client.post( + "/hooks/testhook", + data="null", + content_type="application/json", + ) + assert resp.status_code == 418 + assert fake_popen.calls == [] + + +@pytest.mark.xfail( + reason="J4/B2: form-payload fallback is unguarded; a bodyless " + "POST raises instead of returning 4xx", + strict=True, +) +def test_bodyless_post_returns_4xx(client, jawa_env, fake_popen): + resp = client.post("/hooks/testhook") + assert 400 <= resp.status_code < 500 + assert fake_popen.calls == [] + + +@pytest.mark.xfail( + reason="J4/B2: method check happens after body parsing, so a " + "bare GET crashes in the form fallback instead of 405ing", + strict=True, +) +def test_get_method_returns_405(client, jawa_env, fake_popen): + resp = client.get("/hooks/testhook") + assert resp.status_code == 405 + assert fake_popen.calls == [] diff --git a/tests/test_smoke_routes.py b/tests/test_smoke_routes.py new file mode 100644 index 0000000..9efd2ce --- /dev/null +++ b/tests/test_smoke_routes.py @@ -0,0 +1,103 @@ +"""Route sweep: every blueprint surface renders without a 500. + +With TESTING enabled, unhandled exceptions propagate into the test +instead of rendering a 500 page, so a crashing route fails loudly +with its real traceback. +""" + +import pytest + +# (path, ) GET routes reachable with an authenticated session. +# /log/yield is deliberately absent: it is an infinite tail -f +# stream and would hang the test client. +AUTHED_ROUTES = [ + "/", + "/home.html", + "/login", + "/dashboard", + "/setup", + "/cleanup", + "/success", + "/error", + "/automations", + "/automations/jamfpro", + "/automations/okta", + "/automations/custom", + "/automations/cron", + "/automations/jamfpro/new", + "/automations/okta/new", + "/automations/custom/new", + "/automations/cron/new", + "/templates", + "/templates/device-naming", + "/templates/device-naming/script", + "/templates/import", + "/setup/credentials", + "/resources/files", + "/branding", + "/python", + "/bash", + "/search?q=test", + "/api/search?q=test", + "/log/home.html", + "/log/view", + "/log/download", +] + +# Legacy URLs that must permanently redirect to /automations/*. +LEGACY_REDIRECTS = [ + ("/webhooks", "/automations"), + ("/webhooks/jamf", "/automations/jamfpro"), + ("/webhooks/okta", "/automations/okta"), + ("/webhooks/custom", "/automations/custom"), + ("/cron", "/automations/cron"), +] + +# Routes that must not expose content to an anonymous client. +PROTECTED_ROUTES = [ + "/dashboard", + "/setup", + "/cleanup", + "/automations", + "/automations/jamfpro", + "/templates", + "/setup/credentials", + "/resources/files", + "/branding", + "/log/home.html", +] + + +@pytest.mark.parametrize("path", AUTHED_ROUTES) +def test_authed_route_does_not_error(logged_in_client, path): + resp = logged_in_client.get(path) + assert resp.status_code < 500 + + +@pytest.mark.parametrize("path,target", LEGACY_REDIRECTS) +def test_legacy_route_redirects(logged_in_client, path, target): + resp = logged_in_client.get(path) + assert resp.status_code == 301 + assert target in resp.headers["Location"] + + +@pytest.mark.parametrize("path", PROTECTED_ROUTES) +def test_protected_route_rejects_anonymous(client, path): + resp = client.get(path) + # Anonymous access must bounce to login/logout, never render. + assert resp.status_code in (301, 302) + + +def test_unknown_automation_type_is_handled(logged_in_client): + # abort(404) is intercepted by the custom 404 handler, which + # sends signed-in users back to the dashboard. + resp = logged_in_client.get("/automations/nosuchtype") + assert resp.status_code in (301, 302) + assert "/dashboard" in resp.headers["Location"] + + +def test_unknown_page_is_handled(logged_in_client): + # Custom 404 handler redirects signed-in users to the dashboard. + resp = logged_in_client.get("/definitely/not/a/page") + assert resp.status_code in (301, 302) + assert "/dashboard" in resp.headers["Location"]