Add ensure_jail_configs startup check for required jail config files
On startup BanGUI now verifies that the four fail2ban jail config files required by its two custom jails (manual-Jail and blocklist-import) are present in `$fail2ban_config_dir/jail.d`. Any missing file is created with the correct default content; existing files are never overwritten. Files managed: - manual-Jail.conf (enabled=false template) - manual-Jail.local (enabled=true override) - blocklist-import.conf (enabled=false template) - blocklist-import.local (enabled=true override) The check runs in the lifespan hook immediately after logging is configured, before the database is opened.
This commit is contained in:
@@ -4,43 +4,82 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 0 — First-Run Bootstrap & Startup Fix
|
## Task: Ensure Required fail2ban Jail Config Files Exist ✅ DONE
|
||||||
|
|
||||||
These tasks fix a crash-on-first-boot regression and make the setup/login redirect flow reliable. They must be completed before any other feature work because the application cannot start without them.
|
**Implemented:** `backend/app/utils/jail_config.py` — `ensure_jail_configs(jail_d_path)` creates missing `manual-Jail.conf`, `manual-Jail.local`, `blocklist-import.conf`, and `blocklist-import.local` files with correct default content. Called from the lifespan hook in `main.py` using `Path(settings.fail2ban_config_dir) / "jail.d"`. Tests in `backend/tests/test_utils/test_jail_config.py` (6 cases: all missing, all present, only locals missing, directory creation, idempotency, correct content).
|
||||||
|
|
||||||
|
The backend must guarantee that two specific jail configuration files are present inside the fail2ban jail directory before the application starts (or on first use). If either file is missing it must be created with the correct default content. The files live inside the directory that fail2ban uses for per-jail drop-in configs (e.g. `jail.d/`). The path to that directory should be read from the application settings (config key `fail2ban_jail_d_path` or equivalent); do not hard-code it.
|
||||||
|
|
||||||
|
### Files to create if missing
|
||||||
|
|
||||||
|
**`manual-Jail.conf`**
|
||||||
|
The file must contain a `[manual-Jail]` section with `enabled = false` and all other jail parameters (filter, logpath, backend, maxretry, findtime, bantime, ignoreip) set to the same defaults already documented in `Docker/fail2ban-dev-config/fail2ban/jail.d/manual-Jail.conf`. Only `enabled` must be forced to `false` in this template — it is the `.local` override (see below) that activates the jail.
|
||||||
|
|
||||||
|
**`blocklist-import.conf`**
|
||||||
|
The file must contain a `[blocklist-import]` section with `enabled = false` and all other jail parameters (filter, logpath, backend, maxretry, findtime, bantime, ignoreip) set to the same defaults already documented in `Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf`. Same rule: `enabled = false` here; the `.local` file enables it.
|
||||||
|
|
||||||
|
### Local override files
|
||||||
|
|
||||||
|
For each `.conf` file above there must also be a corresponding `.local` file checked — and created if missing. The `.local` files must contain **only** the section header and the single `enabled = true` line. Nothing else. fail2ban merges `.local` on top of `.conf` at startup, so all other settings come from the `.conf`.
|
||||||
|
|
||||||
|
```
|
||||||
|
[manual-Jail]
|
||||||
|
enabled = true
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[blocklist-import]
|
||||||
|
enabled = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation notes
|
||||||
|
|
||||||
|
- Perform the check in a backend startup routine (e.g. in a `lifespan` hook or a dedicated `ensure_jail_configs()` function called from `main.py`).
|
||||||
|
- Only create a file if it does **not** already exist. Never overwrite an existing file.
|
||||||
|
- Log an `INFO` message for each file that is created and a `DEBUG` message when a file already exists.
|
||||||
|
- Add unit tests that exercise: (a) all four files missing → all four created with correct content, (b) all four files present → nothing is overwritten, (c) only the `.local` files missing → only they are created.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 0.1 — Fix: Create the database parent directory before connecting ✅ DONE
|
## Task: World Map — Country Tooltip on Hover
|
||||||
|
|
||||||
**File:** `backend/app/main.py`
|
Currently the world map (`WorldMap.tsx`) shows a ban-count label painted directly onto each country's SVG path and reacts to click events. Add a floating tooltip that appears when the user hovers over a country.
|
||||||
|
|
||||||
**Implemented:** In `_lifespan`, resolve `settings.database_path` to a `Path`, call `.parent.mkdir(parents=True, exist_ok=True)` before `aiosqlite.connect()`. Added `debug`-level structured log line after mkdir. Tests added in `TestLifespanDatabaseDirectoryCreation`.
|
### Required behaviour
|
||||||
|
|
||||||
|
- When the mouse enters a country geography, display a small floating tooltip near the cursor that shows:
|
||||||
|
- The country's full name (already available via `country_names` from `useMapData()`).
|
||||||
|
- The ban count for that country (from the `countries` map; show `0` if the country has no entry).
|
||||||
|
- The tooltip must follow the mouse while inside the country (or at minimum appear near the cursor when it first enters).
|
||||||
|
- When the mouse leaves the country the tooltip must disappear.
|
||||||
|
- Countries with zero bans must also show the tooltip (name + "0 bans").
|
||||||
|
|
||||||
|
### Implementation notes
|
||||||
|
|
||||||
|
- Store tooltip state (visible, content, x, y) in a `useState` hook local to `WorldMap.tsx`.
|
||||||
|
- Use `onMouseEnter`, `onMouseMove`, and `onMouseLeave` props on the `<Geography>` element (react-simple-maps already forwards these as standard SVG mouse events).
|
||||||
|
- Render the tooltip as an absolutely-positioned `<div>` overlaid on the map container. Apply a `pointer-events: none` style so it does not interfere with hover detection on the map itself.
|
||||||
|
- Reuse the existing Fluent UI design tokens (background, border, shadow, typography) so the tooltip matches the rest of the UI. Do not introduce a new third-party tooltip library.
|
||||||
|
- Add a Vitest / React Testing Library test that mounts `WorldMap` with mock data, fires a `mouseenter` event on a geography, and asserts the tooltip text is visible.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 0.2 — Fix: SetupRedirectMiddleware must treat a missing database as "setup not complete" ✅ DONE
|
## Task: Main Menu — Tooltips on Navigation Items
|
||||||
|
|
||||||
**File:** `backend/app/main.py`
|
The main navigation sidebar (or top bar, whichever is used) currently has no tooltips. Add a tooltip to each navigation item so that users who are unfamiliar with icon-only or collapsed menus can see the destination name without navigating.
|
||||||
|
|
||||||
**Implemented:** Changed the guard in `SetupRedirectMiddleware.dispatch` so that `db is None` also triggers the redirect to `/api/setup`. The condition is now `if db is None or not await setup_service.is_setup_complete(db)`. The `_setup_complete_cached` flag is only set after a successful `is_setup_complete(db)` call with a live `db`. Tests added in `TestSetupRedirectMiddlewareDbNone`.
|
### Required behaviour
|
||||||
|
|
||||||
---
|
- Each navigation item must show a tooltip containing the item's label (e.g. "Dashboard", "Map", "Blocklist", "Settings") when the user hovers over it.
|
||||||
|
- Tooltips should appear after a short delay (≈ 300 ms) to avoid flickering during fast cursor movement past the menu.
|
||||||
### Task 0.3 — Fix: SetupGuard must redirect to /setup on API errors, not allow through ✅ DONE
|
- The tooltip must be dismissed when the cursor leaves the item.
|
||||||
|
- If the menu is already showing a full text label next to the icon, the tooltip is still added (it reinforces accessibility); but consider hiding it when the sidebar is expanded and the label is already visible, to avoid redundancy.
|
||||||
**File:** `frontend/src/components/SetupGuard.tsx`
|
|
||||||
|
### Implementation notes
|
||||||
**Implemented:** Changed the `.catch()` handler to set `status` to `"pending"` instead of `"done"`. Updated the comment to explain the conservative fallback. Tests added in `SetupGuard.test.tsx`.
|
|
||||||
|
- Use the Fluent UI `<Tooltip>` component (from `@fluentui/react-components`) which is already a project dependency. Wrap each navigation `<NavLink>` (or equivalent element) with `<Tooltip content="…" relationship="label">`.
|
||||||
---
|
- Keep the tooltip content string co-located with the route definition so that if a label changes in one place it changes everywhere.
|
||||||
|
- Do not introduce any new npm dependencies.
|
||||||
### Task 0.4 — Fix: SetupPage must redirect to /login when setup is already complete ✅ DONE
|
- Add a Vitest / React Testing Library test that renders the navigation component, triggers a hover on each item, and asserts the correct tooltip text is present in the DOM.
|
||||||
|
|
||||||
**File:** `frontend/src/pages/SetupPage.tsx`
|
|
||||||
|
|
||||||
**Implemented:**
|
|
||||||
1. Added `checking` boolean state (initialised to `true`). While `checking` is true, a full-screen `<Spinner>` is rendered instead of the form, preventing the form from flashing.
|
|
||||||
2. The `useEffect` sets `checking` to `false` in both the `.then()` (when setup is not complete) and the `.catch()` branch. Added a `console.warn` in the catch block. Added a `cancelled` flag and cleanup return to the effect.
|
|
||||||
Tests added in `SetupPage.test.tsx`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from app.routers import (
|
|||||||
)
|
)
|
||||||
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check
|
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check
|
||||||
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
|
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
|
||||||
|
from app.utils.jail_config import ensure_jail_configs
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
||||||
@@ -137,6 +138,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
log.info("bangui_starting_up", database_path=settings.database_path)
|
log.info("bangui_starting_up", database_path=settings.database_path)
|
||||||
|
|
||||||
|
# --- Ensure required jail config files are present ---
|
||||||
|
ensure_jail_configs(Path(settings.fail2ban_config_dir) / "jail.d")
|
||||||
|
|
||||||
# --- Application database ---
|
# --- Application database ---
|
||||||
db_path: Path = Path(settings.database_path)
|
db_path: Path = Path(settings.database_path)
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
93
backend/app/utils/jail_config.py
Normal file
93
backend/app/utils/jail_config.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Utilities for ensuring required fail2ban jail configuration files exist.
|
||||||
|
|
||||||
|
BanGUI requires two custom jails — ``manual-Jail`` and ``blocklist-import``
|
||||||
|
— to be present in the fail2ban ``jail.d`` directory. This module provides
|
||||||
|
:func:`ensure_jail_configs` which checks each of the four files
|
||||||
|
(``*.conf`` template + ``*.local`` override) and creates any that are missing
|
||||||
|
with the correct default content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default file contents
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANUAL_JAIL_CONF = """\
|
||||||
|
[manual-Jail]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter = manual-Jail
|
||||||
|
logpath = /remotelogs/bangui/auth.log
|
||||||
|
backend = polling
|
||||||
|
maxretry = 3
|
||||||
|
findtime = 120
|
||||||
|
bantime = 60
|
||||||
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MANUAL_JAIL_LOCAL = """\
|
||||||
|
[manual-Jail]
|
||||||
|
enabled = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCKLIST_IMPORT_CONF = """\
|
||||||
|
[blocklist-import]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter =
|
||||||
|
logpath = /dev/null
|
||||||
|
backend = auto
|
||||||
|
maxretry = 1
|
||||||
|
findtime = 1d
|
||||||
|
bantime = 1w
|
||||||
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCKLIST_IMPORT_LOCAL = """\
|
||||||
|
[blocklist-import]
|
||||||
|
enabled = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File registry: (filename, default_content)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_JAIL_FILES: list[tuple[str, str]] = [
|
||||||
|
("manual-Jail.conf", _MANUAL_JAIL_CONF),
|
||||||
|
("manual-Jail.local", _MANUAL_JAIL_LOCAL),
|
||||||
|
("blocklist-import.conf", _BLOCKLIST_IMPORT_CONF),
|
||||||
|
("blocklist-import.local", _BLOCKLIST_IMPORT_LOCAL),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_jail_configs(jail_d_path: Path) -> None:
|
||||||
|
"""Ensure the required fail2ban jail configuration files exist.
|
||||||
|
|
||||||
|
Checks for ``manual-Jail.conf``, ``manual-Jail.local``,
|
||||||
|
``blocklist-import.conf``, and ``blocklist-import.local`` inside
|
||||||
|
*jail_d_path*. Any file that is missing is created with its default
|
||||||
|
content. Existing files are **never** overwritten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jail_d_path: Path to the fail2ban ``jail.d`` directory. Will be
|
||||||
|
created (including all parents) if it does not already exist.
|
||||||
|
"""
|
||||||
|
jail_d_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for filename, default_content in _JAIL_FILES:
|
||||||
|
file_path = jail_d_path / filename
|
||||||
|
if file_path.exists():
|
||||||
|
log.debug("jail_config_already_exists", path=str(file_path))
|
||||||
|
else:
|
||||||
|
file_path.write_text(default_content, encoding="utf-8")
|
||||||
|
log.info("jail_config_created", path=str(file_path))
|
||||||
@@ -330,6 +330,7 @@ class TestLifespanDatabaseDirectoryCreation:
|
|||||||
patch("app.tasks.geo_cache_flush.register"),
|
patch("app.tasks.geo_cache_flush.register"),
|
||||||
patch("app.tasks.geo_re_resolve.register"),
|
patch("app.tasks.geo_re_resolve.register"),
|
||||||
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
|
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
|
||||||
|
patch("app.main.ensure_jail_configs"),
|
||||||
):
|
):
|
||||||
async with _lifespan(app):
|
async with _lifespan(app):
|
||||||
assert nested_db.parent.exists(), (
|
assert nested_db.parent.exists(), (
|
||||||
@@ -372,6 +373,7 @@ class TestLifespanDatabaseDirectoryCreation:
|
|||||||
patch("app.tasks.geo_cache_flush.register"),
|
patch("app.tasks.geo_cache_flush.register"),
|
||||||
patch("app.tasks.geo_re_resolve.register"),
|
patch("app.tasks.geo_re_resolve.register"),
|
||||||
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
|
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
|
||||||
|
patch("app.main.ensure_jail_configs"),
|
||||||
):
|
):
|
||||||
# Should not raise FileExistsError or similar.
|
# Should not raise FileExistsError or similar.
|
||||||
async with _lifespan(app):
|
async with _lifespan(app):
|
||||||
|
|||||||
134
backend/tests/test_utils/test_jail_config.py
Normal file
134
backend/tests/test_utils/test_jail_config.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Tests for app.utils.jail_config.ensure_jail_configs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.utils.jail_config import (
|
||||||
|
_BLOCKLIST_IMPORT_CONF,
|
||||||
|
_BLOCKLIST_IMPORT_LOCAL,
|
||||||
|
_MANUAL_JAIL_CONF,
|
||||||
|
_MANUAL_JAIL_LOCAL,
|
||||||
|
ensure_jail_configs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expected filenames
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANUAL_CONF = "manual-Jail.conf"
|
||||||
|
_MANUAL_LOCAL = "manual-Jail.local"
|
||||||
|
_BLOCKLIST_CONF = "blocklist-import.conf"
|
||||||
|
_BLOCKLIST_LOCAL = "blocklist-import.local"
|
||||||
|
|
||||||
|
_ALL_FILES = [_MANUAL_CONF, _MANUAL_LOCAL, _BLOCKLIST_CONF, _BLOCKLIST_LOCAL]
|
||||||
|
|
||||||
|
_CONTENT_MAP: dict[str, str] = {
|
||||||
|
_MANUAL_CONF: _MANUAL_JAIL_CONF,
|
||||||
|
_MANUAL_LOCAL: _MANUAL_JAIL_LOCAL,
|
||||||
|
_BLOCKLIST_CONF: _BLOCKLIST_IMPORT_CONF,
|
||||||
|
_BLOCKLIST_LOCAL: _BLOCKLIST_IMPORT_LOCAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read(jail_d: Path, filename: str) -> str:
|
||||||
|
return (jail_d / filename).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: ensure_jail_configs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureJailConfigs:
|
||||||
|
def test_all_missing_creates_all_four(self, tmp_path: Path) -> None:
|
||||||
|
"""All four files are created when the directory is empty."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert (jail_d / name).exists(), f"{name} should have been created"
|
||||||
|
assert _read(jail_d, name) == _CONTENT_MAP[name]
|
||||||
|
|
||||||
|
def test_all_missing_creates_correct_content(self, tmp_path: Path) -> None:
|
||||||
|
"""Each created file has exactly the expected default content."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# .conf files must set enabled = false
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
content = _read(jail_d, conf_file)
|
||||||
|
assert "enabled = false" in content
|
||||||
|
|
||||||
|
# .local files must set enabled = true and nothing else
|
||||||
|
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
|
||||||
|
content = _read(jail_d, local_file)
|
||||||
|
assert "enabled = true" in content
|
||||||
|
|
||||||
|
def test_all_present_overwrites_nothing(self, tmp_path: Path) -> None:
|
||||||
|
"""Existing files are never overwritten."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
jail_d.mkdir()
|
||||||
|
|
||||||
|
sentinel = "# EXISTING CONTENT — must not be replaced\n"
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
(jail_d / name).write_text(sentinel, encoding="utf-8")
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert _read(jail_d, name) == sentinel, (
|
||||||
|
f"{name} should not have been overwritten"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_local_files_missing_creates_only_locals(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Only the .local files are created when the .conf files already exist."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
jail_d.mkdir()
|
||||||
|
|
||||||
|
sentinel = "# pre-existing conf\n"
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
(jail_d / conf_file).write_text(sentinel, encoding="utf-8")
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# .conf files must remain unchanged
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
assert _read(jail_d, conf_file) == sentinel
|
||||||
|
|
||||||
|
# .local files must have been created with correct content
|
||||||
|
for local_file, expected in (
|
||||||
|
(_MANUAL_LOCAL, _MANUAL_JAIL_LOCAL),
|
||||||
|
(_BLOCKLIST_LOCAL, _BLOCKLIST_IMPORT_LOCAL),
|
||||||
|
):
|
||||||
|
assert (jail_d / local_file).exists(), f"{local_file} should have been created"
|
||||||
|
assert _read(jail_d, local_file) == expected
|
||||||
|
|
||||||
|
def test_creates_jail_d_directory_if_missing(self, tmp_path: Path) -> None:
|
||||||
|
"""The jail.d directory is created automatically when absent."""
|
||||||
|
jail_d = tmp_path / "nested" / "jail.d"
|
||||||
|
assert not jail_d.exists()
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
assert jail_d.is_dir()
|
||||||
|
|
||||||
|
def test_idempotent_on_repeated_calls(self, tmp_path: Path) -> None:
|
||||||
|
"""Calling ensure_jail_configs twice does not alter any file."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# Record content after first call
|
||||||
|
first_pass = {name: _read(jail_d, name) for name in _ALL_FILES}
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert _read(jail_d, name) == first_pass[name], (
|
||||||
|
f"{name} changed on second call"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user