%%{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
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/pytesttestpaths 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 lockoutHow the suite stays out of your real data
Running the tests never touches your real accounts or audit trail. Here’s why.
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.