Skip to content
Merged
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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest~=8.0
ruff~=0.15.0
4 changes: 3 additions & 1 deletion ruff.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
line-length = 79

exclude = ["data/workflows/scripts"]

[format]
quote-style = "double"
quote-style = "double"
32 changes: 32 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -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/<name>` 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.
222 changes: 222 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/test_login.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading