# Run the Tests

Unlike the other how-to guides, this one assumes a checkout of the repository rather than a running app: the tests ship in the repo, not in the installed package. See the [Tutorial](tutorial-getting-started.md) if you don't have one yet.


# Run the suite

The tests live in `tests/` and need `pytest`, which comes with the `dev` extra:

``` bash
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest
```

`testpaths` is set in `pyproject.toml`, so plain `pytest` from the repo root collects the right directory. The whole suite runs in about three seconds; most of that is PBKDF2, which is slow on purpose.

Run one file, or one test, the usual way:

``` bash
.venv/bin/pytest tests/test_auth.py
.venv/bin/pytest tests/test_auth.py::test_lockout_expires
.venv/bin/pytest -k lockout
```


# How the suite stays out of your real data

Running the tests never touches your real accounts or audit trail. Here's why.


*\[Rich HTML output -- view on the documentation site\]*


Each splam module works out where its file belongs once, the first time it's imported, and then remembers the answer. `auth` remembers where `credentials.json` is, `audit` remembers `audit_log.jsonl`, and the two override files work the same way.

Before every single test, `tests/conftest.py` swaps those remembered locations for paths inside a fresh temporary directory. Pytest deletes that directory when the test finishes. The code under test then saves and loads files exactly as it always does. It's simply doing it somewhere disposable.

The same setup step also resets the failed-login counters. Those counters are one dictionary belonging to the `auth` module, shared by everything in the process, so an account locked out by one test would still be locked when the next test started. Clearing them keeps each test independent.

The fixture rewrites those paths in memory rather than setting `SPLAM_DATA_DIR`, and the reason is timing. The modules read that variable at import, which happens before any test runs, so changing it later would arrive too late to matter.


# What the tests cover

The suite is split across six files, one per module. The tables below list all 72 tests and what each one pins down.


## `tests/test_auth.py` (18)

Password hashing, roles, and the failed-login lockout.

| Test | What it pins down |
|----|----|
| `test_add_and_verify` | A password saved by [add_user](../reference/add_user.md#splam.add_user) verifies against the stored hash |
| `test_wrong_password` | A wrong password is rejected for a user that does exist |
| `test_unknown_user` | An unknown username returns `False` rather than raising |
| `test_password_is_not_stored` | The plaintext password appears nowhere in `credentials.json` |
| `test_salt_is_per_user` | Two users with the same password get different salts and different hashes |
| `test_file_and_directory_permissions` | `credentials.json` is written `0600` inside a `0700` directory |
| `test_role_defaults_to_admin` | [add_user](../reference/add_user.md#splam.add_user) with no role produces an admin |
| `test_role_is_stored` | A role passed to [add_user](../reference/add_user.md#splam.add_user) is what [get_role](../reference/get_role.md#splam.get_role) returns |
| `test_legacy_record_without_role_is_admin` | A record with no `role` field reads as admin and still verifies |
| `test_unknown_role_rejected` | An unrecognized role raises `ValueError` and writes nothing at all |
| `test_add_user_replaces_role` | Re-adding a user replaces the previous role instead of keeping it |
| `test_lockout_after_max_failures` | Failures below the threshold report `failed`; the one that hits it locks the account |
| `test_lockout_blocks_correct_password` | Once locked, the correct password is refused too |
| `test_lockout_expires` | Past the lockout window the account works again (the clock is stubbed through `_now`) |
| `test_success_resets_failure_counter` | A success clears accumulated failures, so the next failure counts as the first |
| `test_lockout_is_per_username` | Locking one account leaves every other account usable |
| `test_unknown_user_locks_out_too` | Attempts against a nonexistent username lock as well, so the form leaks no account list |
| `test_credentials_file_is_json` | Each record holds exactly `salt`, `hash`, and `role` |


## `tests/test_audit.py` (10)

The audit trail: what gets written, and what comes back out.

| Test | What it pins down |
|----|----|
| `test_record_action_fields` | Every field is written, with a UTC timestamp |
| `test_record_auth_has_no_service` | Login and logout entries carry `service: null` |
| `test_rejected_actions_are_recorded` | A rejected attempt is recorded like any other, not dropped |
| `test_file_and_directory_permissions` | `audit_log.jsonl` is `0600` inside a `0700` directory |
| `test_empty_log_reads_as_empty_list` | A missing log reads as `[]` instead of raising |
| `test_newest_first` | Entries come back most recent first |
| `test_limit_keeps_most_recent` | `limit` truncates the old end, not the new one |
| `test_service_filter` | Filtering by service excludes other services |
| `test_service_filter_keeps_auth_entries` | Logins stay visible no matter which service is selected |
| `test_appends_rather_than_overwrites` | A second write appends instead of truncating the file |


## `tests/test_app.py` (23)

The `systemctl`, `journalctl`, and `systemd-analyze` wrappers, plus role-dependent UI. Every subprocess call is faked and the unit search path is pointed at a temporary directory, so no test touches real services.

| Test | What it pins down |
|----|----|
| `test_unit_search_paths_asks_systemd` | The search path comes from `systemd-analyze unit-paths`, not a hardcoded list |
| `test_list_services_reads_every_search_path` | Units are collected from all search directories, and non-service units ignored |
| `test_list_services_skips_template_units` | Template units (`getty@.service`) stay out of the dropdown |
| `test_list_services_keeps_documented_units_without_a_unit_file` | A service with notes is selectable even with no unit file on disk |
| `test_list_services_deduplicates_across_paths` | A unit shadowed in two directories appears once |
| `test_list_services_survives_a_missing_search_path` | A search directory that doesn't exist is skipped, not raised on |
| `test_doc_references_splits_quoted_and_bare_uris` | Quoted `man:` entries and bare URLs both parse out of `Documentation=` |
| `test_doc_references_empty_when_none_declared` | A unit with no `Documentation=` yields an empty list |
| `test_get_documentation_runs_systemctl_help` | The Doc tab shells out to `systemctl help <service>` |
| `test_get_documentation_disables_the_pager` | `PAGER` and `MANPAGER` are forced to `cat`, so man never blocks on a pager |
| `test_get_documentation_wraps_at_the_requested_width` | The requested column count reaches man as `MANWIDTH` |
| `test_get_documentation_falls_back_to_eighty_columns` | With no width reported, man gets its own default of 80 |
| `test_doc_columns_grow_with_the_window` | A wider tab asks man for more columns |
| `test_doc_columns_round_to_five` | Column counts snap to a multiple of five, so a resize drag re-renders a few times, not per pixel |
| `test_doc_columns_stay_within_readable_bounds` | A very narrow or very wide tab is clamped to 60 and 220 columns |
| `test_get_documentation_reports_a_unit_without_docs` | systemd's "not known" message reaches the tab instead of being swallowed |
| `test_systemctl_action_command` | The exact `systemctl <action> <service>` argv is built |
| `test_get_status_combines_streams` | Status output is stdout followed by stderr |
| `test_get_logs_requests_reversed_lines` | `journalctl` is called with the line count and `-r` for newest first |
| `test_admin_panel_shows_actions` | An admin panel contains the Actions tab and the Configure button |
| `test_auditor_panel_hides_actions_and_configure` | An auditor panel contains neither, and says the role is read-only |
| `test_auditor_panel_keeps_read_only_tabs` | An auditor keeps Status, Tasks, Doc, Logs, and Audit Trail |
| `test_idle_timeout_default_is_fifteen_minutes` | The shipped idle timeout is 900 seconds |


## `tests/test_highlight.py` (9)

Keyword highlighting, including the escaping that makes it safe to render log lines as HTML.

| Test | What it pins down |
|----|----|
| `test_escapes_html` | Markup in a log line is escaped before anything is wrapped |
| `test_wraps_error_keyword` | `error` is wrapped as `hl-error` |
| `test_matching_is_case_insensitive` | `ERROR` matches the same rule as `error` |
| `test_rejected_is_a_warning` | `rejected` is wrapped as `hl-warn` |
| `test_lockout_and_timeout_are_warnings` | `locked out` and `timeout` are wrapped as `hl-warn` |
| `test_custom_keyword_override` | A keyword saved through Configure gets the class it was given |
| `test_custom_keyword_wins_over_builtin` | Custom patterns are matched ahead of the built-in ones |
| `test_unknown_class_rejected` | An unknown highlight class raises and saves no override file |
| `test_unmatched_text_passes_through` | Text with no keywords is returned unchanged |


## `tests/test_paths.py` (6)

Where app data lands, and the permissions it lands with.

| Test | What it pins down |
|----|----|
| `test_explicit_override_wins` | `SPLAM_DATA_DIR` takes precedence over `XDG_DATA_HOME` |
| `test_follows_xdg_data_home` | With no override, data goes under `$XDG_DATA_HOME/splam` |
| `test_defaults_under_home` | With neither set, data goes under `~/.local/share/splam` |
| `test_ensure_parent_creates_owner_only_directory` | Missing parent directories are created `0700` |
| `test_ensure_parent_is_idempotent` | Calling it against an existing directory leaves the contents alone |
| `test_import_has_no_side_effects` | `import splam` in a fresh subprocess creates no directory |

That last one matters more than it looks. The docs build imports the package to introspect it, and CI runs as a different user in a different home directory. A package that created `~/.local/share/splam/` on import would do it there too.


## `tests/test_service_info.py` (6)

Built-in admin-task notes and the overrides that replace them.

| Test | What it pins down |
|----|----|
| `test_builtin_entry` | The shipped `cron.service` notes are returned |
| `test_unknown_service_falls_back_to_generic` | An undocumented service gets `GENERIC_INFO` |
| `test_override_wins_over_builtin` | A saved override replaces the built-in entry for that service |
| `test_override_adds_new_service` | An override can document a service with no built-in entry |
| `test_overrides_persist_to_the_data_directory` | Overrides are written to the app data directory |
| `test_builtins_survive_an_override` | Saving one override leaves the other built-ins in place |


# What the tests do not cover

The Shiny server function is not unit tested. Login wiring, the idle countdown firing, and the server-side role check on a submitted action all live inside `reactive.effect` closures that need a live session to run, so `tests/test_app.py` covers the pure helpers and the panel markup instead.

Those paths were verified by driving a real browser session over the Shiny websocket protocol: logging in as each role, confirming an auditor gets no Actions tab, watching a session get logged out by the idle timeout, and confirming a locked account is refused its own correct password. If you change the login effect or the idle timeout, that's the check to repeat, because the suite won't catch it.


# What CI runs on every push

`.github/workflows/tests.yml` runs the suite on every push to `main` and on every pull request, against Python 3.11, 3.12, and 3.13. The matrix doesn't stop at the first failure, so a break in one version shows up as exactly that.

Locally, `.venv/bin/pytest` before you push covers the same ground on whichever version your virtual environment is built against, which is the only part CI can't tell you sooner.
