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 if you don’t have one yet.

Run the suite

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

.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:

.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.

%%{init: {'theme': 'dark', 'themeVariables': { 'fontFamily': 'monospace', "fontSize":"20px", "darkMode":true}}}%%

flowchart TD
    subgraph S1["<strong>1. At import time</strong>"]
        Env[/"SPLAM_DATA_DIR<br>XDG_DATA_HOME"/]
        Resolve[["data_dir()"]]
        Const["four file locations,<br>remembered"]
        Env --> Resolve
        Resolve --> Const
    end

    subgraph S2["<strong>2. Before every test</strong>"]
        Patch["swap in temporary paths"]
        Clear["clear the lockout counters"]
    end

    subgraph S3["<strong>3. During the test</strong>"]
        Code[["add_user()<br>record_action()<br>save_service_info()"]]
    end

    Tmp[("temporary directory<br>deleted afterwards")]
    Real[("~/.local/share/splam/<br>your real data")]

    Const --> Patch
    Patch --> Code
    Clear --> Code
    Code ==> Tmp
    Code --x Real

    style S1 fill:#FFFFFF,color:#000000,stroke:#333,stroke-width:1px,rx:10,ry:10
    style S2 fill:#FFFFFF,color:#000000,stroke:#333,stroke-width:1px,rx:10,ry:10
    style S3 fill:#FFFFFF,color:#000000,stroke:#333,stroke-width:1px,rx:10,ry:10
    style Env fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Resolve fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Const fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Patch fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Clear fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Code fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Tmp fill:#4CBB9D,color:#FFFFFF,rx:5,ry:5
    style Real fill:#FFFFFF,color:#000000,stroke:#333,stroke-width:1px,rx:5,ry:5

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 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 with no role produces an admin
test_role_is_stored A role passed to add_user is what 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.