Compare commits
10 Commits
b81e0cdbb4
...
5b7d1a4360
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b7d1a4360 | |||
| e7834a888e | |||
| abb224e01b | |||
| 57cf93b1e5 | |||
| c41165c294 | |||
| cdf73e2d65 | |||
| 21753c4f06 | |||
| eb859af371 | |||
| 5a5c619a34 | |||
| 00119ed68d |
@@ -10,7 +10,7 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: build dependencies ──────────────────────────────
|
||||
FROM python:3.12-slim AS builder
|
||||
FROM docker.io/library/python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -28,7 +28,7 @@ RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
# ── Stage 2: runtime image ───────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM docker.io/library/python:3.12-slim AS runtime
|
||||
|
||||
LABEL maintainer="BanGUI" \
|
||||
description="BanGUI backend — fail2ban web management API"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: install & build ─────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
FROM docker.io/library/node:22-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -23,7 +23,7 @@ COPY frontend/ /build/
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: serve with nginx ────────────────────────────────
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
FROM docker.io/library/nginx:1.27-alpine AS runtime
|
||||
|
||||
LABEL maintainer="BanGUI" \
|
||||
description="BanGUI frontend — fail2ban web management UI"
|
||||
|
||||
1
Docker/VERSION
Normal file
1
Docker/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
v0.9.3
|
||||
@@ -2,7 +2,7 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# check_ban_status.sh
|
||||
#
|
||||
# Queries the bangui-sim jail inside the running fail2ban
|
||||
# Queries the manual-Jail jail inside the running fail2ban
|
||||
# container and optionally unbans a specific IP.
|
||||
#
|
||||
# Usage:
|
||||
@@ -17,7 +17,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
readonly CONTAINER="bangui-fail2ban-dev"
|
||||
readonly JAIL="bangui-sim"
|
||||
readonly JAIL="manual-Jail"
|
||||
|
||||
# ── Helper: run a fail2ban-client command inside the container ─
|
||||
f2b() {
|
||||
|
||||
73
Docker/docker-compose.yml
Normal file
73
Docker/docker-compose.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
fail2ban:
|
||||
image: lscr.io/linuxserver/fail2ban:latest
|
||||
container_name: fail2ban
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
network_mode: host
|
||||
environment:
|
||||
- PUID=1011
|
||||
- PGID=1001
|
||||
- TZ=Etc/UTC
|
||||
- VERBOSITY=-vv #optional
|
||||
|
||||
volumes:
|
||||
- /server/server_fail2ban/config:/config
|
||||
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban
|
||||
- /var/log:/var/log
|
||||
- /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional
|
||||
- /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional
|
||||
- /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional
|
||||
|
||||
|
||||
#- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional
|
||||
#- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional
|
||||
#- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest
|
||||
container_name: bangui-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
fail2ban:
|
||||
condition: service_started
|
||||
environment:
|
||||
- PUID=1011
|
||||
- PGID=1001
|
||||
- BANGUI_DATABASE_PATH=/data/bangui.db
|
||||
- BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
||||
- BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
|
||||
- BANGUI_LOG_LEVEL=info
|
||||
- BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}
|
||||
- BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC}
|
||||
volumes:
|
||||
- /server/server_fail2ban/bangui-data:/data
|
||||
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro
|
||||
- /server/server_fail2ban/config:/config:rw
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
- bangui-net
|
||||
|
||||
# ── Frontend (nginx serving built SPA + API proxy) ──────────
|
||||
frontend:
|
||||
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest
|
||||
container_name: bangui-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PUID=1011
|
||||
- PGID=1001
|
||||
ports:
|
||||
- "${BANGUI_PORT:-8080}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
networks:
|
||||
- bangui-net
|
||||
|
||||
networks:
|
||||
bangui-net:
|
||||
name: bangui-net
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This directory contains the fail2ban configuration and supporting scripts for a
|
||||
self-contained development test environment. A simulation script writes fake
|
||||
authentication-failure log lines, fail2ban detects them via the `bangui-sim`
|
||||
authentication-failure log lines, fail2ban detects them via the `manual-Jail`
|
||||
jail, and bans the offending IP — giving a fully reproducible ban/unban cycle
|
||||
without a real service.
|
||||
|
||||
@@ -71,14 +71,14 @@ Chains steps 1–3 automatically with appropriate sleep intervals.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `fail2ban/filter.d/bangui-sim.conf` | Defines the `failregex` that matches simulation log lines |
|
||||
| `fail2ban/jail.d/bangui-sim.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
|
||||
| `fail2ban/filter.d/manual-Jail.conf` | Defines the `failregex` that matches simulation log lines |
|
||||
| `fail2ban/jail.d/manual-Jail.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
|
||||
| `Docker/logs/auth.log` | Log file written by the simulation script (host path) |
|
||||
|
||||
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
||||
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
||||
|
||||
To change sensitivity, edit `fail2ban/jail.d/bangui-sim.conf`:
|
||||
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
|
||||
|
||||
```ini
|
||||
maxretry = 3 # failures before a ban
|
||||
@@ -108,14 +108,14 @@ Test the regex manually:
|
||||
|
||||
```bash
|
||||
docker exec bangui-fail2ban-dev \
|
||||
fail2ban-regex /remotelogs/bangui/auth.log bangui-sim
|
||||
fail2ban-regex /remotelogs/bangui/auth.log manual-Jail
|
||||
```
|
||||
|
||||
The output should show matched lines. If nothing matches, check that the log
|
||||
lines match the corresponding `failregex` pattern:
|
||||
|
||||
```
|
||||
# bangui-sim (auth log):
|
||||
# manual-Jail (auth log):
|
||||
YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
||||
```
|
||||
|
||||
@@ -132,7 +132,7 @@ sudo modprobe ip_tables
|
||||
### IP not banned despite enough failures
|
||||
|
||||
Check whether the source IP falls inside the `ignoreip` range defined in
|
||||
`fail2ban/jail.d/bangui-sim.conf`:
|
||||
`fail2ban/jail.d/manual-Jail.conf`:
|
||||
|
||||
```ini
|
||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
# Matches lines written by Docker/simulate_failed_logins.sh
|
||||
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
|
||||
# Jail: manual-Jail
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Definition]
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
# for lines produced by Docker/simulate_failed_logins.sh.
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
[bangui-sim]
|
||||
[manual-Jail]
|
||||
|
||||
enabled = true
|
||||
filter = bangui-sim
|
||||
filter = manual-Jail
|
||||
logpath = /remotelogs/bangui/auth.log
|
||||
backend = polling
|
||||
maxretry = 3
|
||||
|
||||
75
Docker/release.sh
Normal file
75
Docker/release.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Bump the project version and push images to the registry.
|
||||
#
|
||||
# Usage:
|
||||
# ./release.sh
|
||||
#
|
||||
# The current version is stored in VERSION (next to this script).
|
||||
# You will be asked whether to bump major, minor, or patch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION_FILE="${SCRIPT_DIR}/VERSION"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read current version
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ ! -f "${VERSION_FILE}" ]]; then
|
||||
echo "0.0.0" > "${VERSION_FILE}"
|
||||
fi
|
||||
|
||||
CURRENT="$(cat "${VERSION_FILE}")"
|
||||
# Strip leading 'v' for arithmetic
|
||||
VERSION="${CURRENT#v}"
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
||||
|
||||
echo "============================================"
|
||||
echo " BanGUI — Release"
|
||||
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "How would you like to bump the version?"
|
||||
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||
echo ""
|
||||
read -rp "Enter choice [1/2/3]: " CHOICE
|
||||
|
||||
case "${CHOICE}" in
|
||||
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
||||
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||
*)
|
||||
echo "Invalid choice. Aborting." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "New version: ${NEW_TAG}"
|
||||
read -rp "Confirm? [y/N]: " CONFIRM
|
||||
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write new version
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "${NEW_TAG}" > "${VERSION_FILE}"
|
||||
echo "Version file updated → ${VERSION_FILE}"
|
||||
|
||||
# Keep frontend/package.json in sync so __APP_VERSION__ matches Docker/VERSION.
|
||||
FRONT_VERSION="${NEW_TAG#v}"
|
||||
FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||
echo "frontend/package.json version updated → ${FRONT_VERSION}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push
|
||||
# ---------------------------------------------------------------------------
|
||||
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||
bash "${SCRIPT_DIR}/push.sh"
|
||||
@@ -3,7 +3,7 @@
|
||||
# simulate_failed_logins.sh
|
||||
#
|
||||
# Writes synthetic authentication-failure log lines to a file
|
||||
# that matches the bangui-sim fail2ban filter.
|
||||
# that matches the manual-Jail fail2ban filter.
|
||||
#
|
||||
# Usage:
|
||||
# bash Docker/simulate_failed_logins.sh [COUNT] [SOURCE_IP] [LOG_FILE]
|
||||
@@ -13,7 +13,7 @@
|
||||
# SOURCE_IP: 192.168.100.99
|
||||
# LOG_FILE : Docker/logs/auth.log (relative to repo root)
|
||||
#
|
||||
# Log line format (must match bangui-sim failregex exactly):
|
||||
# Log line format (must match manual-Jail failregex exactly):
|
||||
# YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
144
Docs/Tasks.md
144
Docs/Tasks.md
@@ -4,142 +4,56 @@ This document breaks the entire BanGUI project into development stages, ordered
|
||||
|
||||
---
|
||||
|
||||
## Bug Fix: "Raw Action Configuration" always empty — DONE
|
||||
## Open Issues
|
||||
|
||||
**Summary:** Renamed `GET /actions/{name}` and `PUT /actions/{name}` in `file_config.py` to `GET /actions/{name}/raw` and `PUT /actions/{name}/raw` to eliminate the route-shadowing conflict with `config.py`. Added `configActionRaw` endpoint helper in `endpoints.ts` and updated `fetchActionFile` / `updateActionFile` in `config.ts` to call it. Added `TestGetActionFileRaw` and `TestUpdateActionFileRaw` test classes.
|
||||
### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done
|
||||
|
||||
**Problem**
|
||||
When a user opens the *Actions* tab in the Config screen, selects any action, and expands the "Raw Action Configuration" accordion, the text area is always blank. The `fetchContent` callback makes a `GET /api/config/actions/{name}` request expecting a `ConfFileContent` response (`{ content: string, name: string, filename: string }`), but the backend returns an `ActionConfig` (the fully-parsed structured model) instead. The `content` field is therefore `undefined` in the browser, which the `RawConfigSection` component renders as an empty string.
|
||||
**Implemented:**
|
||||
- `frontend/vite.config.ts`: reads `package.json#version` at build time and injects it as the global `__APP_VERSION__` via Vite `define`.
|
||||
- `frontend/src/vite-env.d.ts`: adds `declare const __APP_VERSION__: string` so TypeScript knows about the global.
|
||||
- `frontend/src/layouts/MainLayout.tsx`: renders `BanGUI v{__APP_VERSION__}` in the sidebar footer when expanded (hidden when collapsed).
|
||||
- `frontend/src/components/ServerStatusBar.tsx`: tooltip changed from `"fail2ban version"` to `"fail2ban daemon version"`.
|
||||
- `Docker/release.sh`: after bumping `VERSION`, also updates `frontend/package.json#version` via `sed` to keep them in sync.
|
||||
- `frontend/package.json`: version bumped from `0.9.0` to `0.9.3` to match `Docker/VERSION`.
|
||||
- Tests added: `src/components/__tests__/ServerStatusBar.test.tsx`, `src/layouts/__tests__/MainLayout.test.tsx`.
|
||||
|
||||
**Root cause**
|
||||
Both `backend/app/routers/config.py` and `backend/app/routers/file_config.py` are mounted with the prefix `/api/config` (see lines 107 and 63 respectively). Both define a `GET /actions/{name}` route:
|
||||
**Problem:** The `ServerStatusBar` component on the Dashboard displays `v{status.version}`, which is the **fail2ban daemon version** (e.g. `v1.1.0`). The BanGUI application version lives in `Docker/VERSION` (e.g. `v0.9.3`) and is unrelated to the fail2ban version. Users see a version number they don't recognise and assume it reflects the BanGUI release.
|
||||
|
||||
- `config.py` → returns `ActionConfig` (parsed detail)
|
||||
- `file_config.py` → returns `ConfFileContent` (raw file text)
|
||||
**Goal:** Make the distinction clear and expose the BanGUI application version.
|
||||
|
||||
In `backend/app/main.py`, `config.router` is registered on line 402 and `file_config.router` on line 403. FastAPI matches the first registered route, so the raw-content endpoint is permanently shadowed.
|
||||
**Suggested approach:**
|
||||
1. Inject the BanGUI app version at build time — add a `define` entry in `frontend/vite.config.ts` that reads the `version` field from `frontend/package.json` (e.g. `__APP_VERSION__`). Keep `frontend/package.json` and `Docker/VERSION` in sync (update the release script `Docker/release.sh` or `Makefile` to write `package.json#version` from `VERSION`).
|
||||
2. Show the BanGUI version in the sidebar footer inside `MainLayout.tsx` (collapsed view: show only when expanded, or via tooltip). This is the natural place for an "about" version tag.
|
||||
3. Update the fail2ban version tooltip in `ServerStatusBar.tsx` from the generic `"fail2ban version"` to something like `"fail2ban daemon version"` so the two are no longer visually indistinguishable.
|
||||
|
||||
The filters feature already solved the same conflict by using distinct paths (`/filters/{name}/raw` for raw and `/filters/{name}` for parsed). Actions must follow the same pattern.
|
||||
|
||||
**Fix — backend (`backend/app/routers/file_config.py`)**
|
||||
Rename the two action raw-file routes:
|
||||
|
||||
| Old path | New path |
|
||||
|---|---|
|
||||
| `GET /actions/{name}` | `GET /actions/{name}/raw` |
|
||||
| `PUT /actions/{name}` | `PUT /actions/{name}/raw` |
|
||||
|
||||
Update the module-level docstring comment block at the top of `file_config.py` to reflect the new paths.
|
||||
|
||||
**Fix — frontend (`frontend/src/api/endpoints.ts`)**
|
||||
Add a new helper alongside the existing `configAction` entry:
|
||||
|
||||
```ts
|
||||
configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`,
|
||||
```
|
||||
|
||||
**Fix — frontend (`frontend/src/api/config.ts`)**
|
||||
Change `fetchActionFile` and `updateActionFile` to call `ENDPOINTS.configActionRaw(name)` instead of `ENDPOINTS.configAction(name)`.
|
||||
|
||||
**No changes needed elsewhere.** `ActionsTab.tsx` already passes `fetchActionFile` / `updateActionFile` into `RawConfigSection` via `fetchRaw` / `saveRaw`; the resolved URL is the only thing that needs to change.
|
||||
**Files:** `frontend/vite.config.ts`, `frontend/package.json`, `Docker/VERSION`, `Docker/release.sh`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/components/ServerStatusBar.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Rename dev jail `bangui-sim` → `manual-Jail` — DONE
|
||||
### ~~2. Dashboard — Improve "Failures" Tooltip~~ ✅ Done
|
||||
|
||||
**Summary:** Renamed `jail.d/bangui-sim.conf` → `manual-Jail.conf` and `filter.d/bangui-sim.conf` → `manual-Jail.conf` (via `git mv`), updated all internal references. Updated `check_ban_status.sh`, `simulate_failed_logins.sh`, and `fail2ban-dev-config/README.md` to replace all `bangui-sim` references with `manual-Jail`.
|
||||
**Implemented:** In `frontend/src/components/ServerStatusBar.tsx`, changed the `Failures:` label to `Failed Attempts:` and updated the tooltip from `"Currently failing IPs"` to `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Updated `ServerStatusBar.test.tsx` to assert the new label text.
|
||||
|
||||
**Scope**
|
||||
This is purely a Docker development-environment change. The frontend never hardcodes jail names; it reads them dynamically from the API. Only the files listed below need editing.
|
||||
**Problem:** The `ServerStatusBar` shows a "Failures: 42" counter with the tooltip `"Currently failing IPs"`. In fail2ban terminology *failures* are individual **failed authentication attempts** tracked in the fail2ban DB, not the number of unique IPs that failed. The current wording is ambiguous and misleading — users may think it means broken connections or error states.
|
||||
|
||||
**Files to update**
|
||||
**Goal:** Replace the tooltip with accurate, self-explanatory wording.
|
||||
|
||||
1. **`Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf`**
|
||||
- Rename the file to `manual-Jail.conf`.
|
||||
- Change the section header from `[bangui-sim]` to `[manual-Jail]`.
|
||||
- Change `filter = bangui-sim` to `filter = manual-Jail`.
|
||||
- Update the file-header comment ("BanGUI — Simulated authentication failure jail" line and any other references to `bangui-sim`).
|
||||
**Suggested fix:** Change the `Tooltip` content for the Failures stat in `ServerStatusBar.tsx` from `"Currently failing IPs"` to something like `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Additionally, consider renaming the label from `"Failures:"` to `"Failed Attempts:"` to match the tooltip language.
|
||||
|
||||
2. **`Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf`**
|
||||
- Rename the file to `manual-Jail.conf`.
|
||||
- Update any internal comments that mention `bangui-sim`.
|
||||
|
||||
3. **`Docker/check_ban_status.sh`**
|
||||
- Change `readonly JAIL="bangui-sim"` to `readonly JAIL="manual-Jail"`.
|
||||
- Update the file-header comment block that references `bangui-sim`.
|
||||
|
||||
4. **`Docker/simulate_failed_logins.sh`**
|
||||
- Update all comments that mention `bangui-sim` or `bangui-auth` to refer to `manual-Jail` instead.
|
||||
- Do **not** change the log-line format string (`bangui-auth: authentication failure from <IP>`) unless the filter's `failregex` in the renamed `manual-Jail.conf` is also updated to match the new prefix; keep them in sync.
|
||||
|
||||
5. **`Docker/fail2ban-dev-config/README.md`**
|
||||
- Replace every occurrence of `bangui-sim` with `manual-Jail`.
|
||||
|
||||
After renaming, run `docker compose -f Docker/compose.debug.yml restart fail2ban` and verify with `bash Docker/check_ban_status.sh` that the jail is active under its new name.
|
||||
**Files:** `frontend/src/components/ServerStatusBar.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## Bug Fix: Config screen content pane does not update when switching jails — DONE
|
||||
### ~~3. Config → Server Tab — Move "Service Health" to Top~~ ✅ Done
|
||||
|
||||
**Summary:** Added `key={selectedActiveJail.name}` to `JailConfigDetail` and `key={selectedInactiveJail.name}` to `InactiveJailDetail` in `JailsTab.tsx`, forcing React to unmount and remount the detail component on jail selection changes.
|
||||
**Implemented:** In `frontend/src/components/config/ServerTab.tsx`, moved `<ServerHealthSection />` from the end of the JSX return to be the first element rendered inside the tab container, before all settings fields.
|
||||
|
||||
**Problem**
|
||||
In the *Jails* tab of the Config screen, clicking a jail name in the left-hand list correctly highlights the new selection, but the right-hand content pane continues to show the previously selected jail (e.g. selecting `blocklist-import` after `manual-Jail` still displays `manual-Jail`'s configuration).
|
||||
**Problem:** In the Config page → Server tab, the `Service Health` panel (`ServerHealthSection`) is rendered at the bottom of the tab, after all settings sections (log level, log target, DB purge settings, map thresholds, reload/restart buttons). This means users must scroll past all editable fields to check service connectivity status, even though the health status is the most critical piece of context — it indicates whether the server is reachable at all.
|
||||
|
||||
**Root cause**
|
||||
In `frontend/src/components/config/JailsTab.tsx`, the child components rendered by `ConfigListDetail` are not given a `key` prop:
|
||||
**Goal:** Move the `<ServerHealthSection />` block to the **top** of the `ServerTab` render output, before any settings fields.
|
||||
|
||||
```tsx
|
||||
{selectedActiveJail !== undefined ? (
|
||||
<JailConfigDetail
|
||||
jail={selectedActiveJail} // no key prop
|
||||
...
|
||||
/>
|
||||
) : selectedInactiveJail !== undefined ? (
|
||||
<InactiveJailDetail
|
||||
jail={selectedInactiveJail} // no key prop
|
||||
...
|
||||
/>
|
||||
) : null}
|
||||
```
|
||||
**Suggested fix:** In `frontend/src/components/config/ServerTab.tsx`, move the `{/* Service Health & Log Viewer section */}` block (currently at the end of the JSX return around line 415) to be the first section rendered inside the tab container.
|
||||
|
||||
When the user switches between two jails of the same type (both active or both inactive), React reuses the existing component instance and only updates its props. Any internal state derived from the previous jail — including the `loadedRef` guard inside every nested `RawConfigSection` — is never reset. As a result, forms still show the old jail's values and the raw-config section refuses to re-fetch because `loadedRef.current` is already `true`.
|
||||
|
||||
Compare with `ActionsTab.tsx`, where `ActionDetail` correctly uses `key={selectedAction.name}`:
|
||||
|
||||
```tsx
|
||||
<ActionDetail
|
||||
key={selectedAction.name} // ← forces remount on action change
|
||||
action={selectedAction}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
**Fix — `frontend/src/components/config/JailsTab.tsx`**
|
||||
Add `key` props to both detail components so React unmounts and remounts them whenever the selected jail changes:
|
||||
|
||||
```tsx
|
||||
{selectedActiveJail !== undefined ? (
|
||||
<JailConfigDetail
|
||||
key={selectedActiveJail.name}
|
||||
jail={selectedActiveJail}
|
||||
onSave={updateJail}
|
||||
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
||||
/>
|
||||
) : selectedInactiveJail !== undefined ? (
|
||||
<InactiveJailDetail
|
||||
key={selectedInactiveJail.name}
|
||||
jail={selectedInactiveJail}
|
||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||
onDeactivate={
|
||||
selectedInactiveJail.has_local_override
|
||||
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
```
|
||||
|
||||
No other files need changing. The `key` change is the minimal, isolated fix.
|
||||
**Files:** `frontend/src/components/config/ServerTab.tsx`.
|
||||
|
||||
---
|
||||
|
||||
@@ -85,4 +85,4 @@ def get_settings() -> Settings:
|
||||
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
|
||||
if required keys are absent or values fail validation.
|
||||
"""
|
||||
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars
|
||||
return Settings() # pydantic-settings populates required fields from env vars
|
||||
|
||||
@@ -49,6 +49,7 @@ from app.routers import (
|
||||
)
|
||||
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.jail_config import ensure_jail_configs
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
||||
@@ -137,7 +138,13 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
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 ---
|
||||
db_path: Path = Path(settings.database_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.debug("database_directory_ensured", directory=str(db_path.parent))
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
@@ -320,17 +327,15 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
||||
if path.startswith("/api") and not getattr(
|
||||
request.app.state, "_setup_complete_cached", False
|
||||
):
|
||||
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
|
||||
if db is not None:
|
||||
from app.services import setup_service # noqa: PLC0415
|
||||
from app.services import setup_service # noqa: PLC0415
|
||||
|
||||
if await setup_service.is_setup_complete(db):
|
||||
request.app.state._setup_complete_cached = True
|
||||
else:
|
||||
return RedirectResponse(
|
||||
url="/api/setup",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
|
||||
if db is None or not await setup_service.is_setup_complete(db):
|
||||
return RedirectResponse(
|
||||
url="/api/setup",
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
request.app.state._setup_complete_cached = True
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
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))
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bangui-backend"
|
||||
version = "0.1.0"
|
||||
version = "0.9.0"
|
||||
description = "BanGUI backend — fail2ban web management interface"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.main import _lifespan, create_app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared setup payload
|
||||
@@ -286,3 +286,151 @@ class TestSetupCompleteCaching:
|
||||
# Cache was warm — is_setup_complete must not have been called.
|
||||
assert call_count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 0.1 — Lifespan creates the database parent directory (Task 0.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLifespanDatabaseDirectoryCreation:
|
||||
"""App lifespan creates the database parent directory when it does not exist."""
|
||||
|
||||
async def test_creates_nested_database_directory(self, tmp_path: Path) -> None:
|
||||
"""Lifespan creates intermediate directories for the database path.
|
||||
|
||||
Verifies that a deeply-nested database path is handled correctly —
|
||||
the parent directories are created before ``aiosqlite.connect`` is
|
||||
called so the app does not crash on a fresh volume.
|
||||
"""
|
||||
nested_db = tmp_path / "deep" / "nested" / "bangui.db"
|
||||
assert not nested_db.parent.exists()
|
||||
|
||||
settings = Settings(
|
||||
database_path=str(nested_db),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-lifespan-mkdir-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.start = MagicMock()
|
||||
mock_scheduler.shutdown = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.services.geo_service.init_geoip"),
|
||||
patch(
|
||||
"app.services.geo_service.load_cache_from_db",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.tasks.health_check.register"),
|
||||
patch("app.tasks.blocklist_import.register"),
|
||||
patch("app.tasks.geo_cache_flush.register"),
|
||||
patch("app.tasks.geo_re_resolve.register"),
|
||||
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
|
||||
patch("app.main.ensure_jail_configs"),
|
||||
):
|
||||
async with _lifespan(app):
|
||||
assert nested_db.parent.exists(), (
|
||||
"Expected lifespan to create database parent directory"
|
||||
)
|
||||
|
||||
async def test_existing_database_directory_is_not_an_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Lifespan does not raise when the database directory already exists.
|
||||
|
||||
``mkdir(exist_ok=True)`` must be used so that re-starts on an existing
|
||||
volume do not fail.
|
||||
"""
|
||||
db_path = tmp_path / "bangui.db"
|
||||
# tmp_path already exists — this simulates a pre-existing volume.
|
||||
|
||||
settings = Settings(
|
||||
database_path=str(db_path),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-lifespan-exist-ok-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.start = MagicMock()
|
||||
mock_scheduler.shutdown = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.services.geo_service.init_geoip"),
|
||||
patch(
|
||||
"app.services.geo_service.load_cache_from_db",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.tasks.health_check.register"),
|
||||
patch("app.tasks.blocklist_import.register"),
|
||||
patch("app.tasks.geo_cache_flush.register"),
|
||||
patch("app.tasks.geo_re_resolve.register"),
|
||||
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
|
||||
patch("app.main.ensure_jail_configs"),
|
||||
):
|
||||
# Should not raise FileExistsError or similar.
|
||||
async with _lifespan(app):
|
||||
assert tmp_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 0.2 — Middleware redirects when app.state.db is None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetupRedirectMiddlewareDbNone:
|
||||
"""SetupRedirectMiddleware redirects when the database is not yet available."""
|
||||
|
||||
async def test_redirects_to_setup_when_db_not_set(self, tmp_path: Path) -> None:
|
||||
"""A ``None`` db on app.state causes a 307 redirect to ``/api/setup``.
|
||||
|
||||
Simulates the race window where a request arrives before the lifespan
|
||||
has finished initialising the database connection.
|
||||
"""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "bangui.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
session_secret="test-db-none-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
# Deliberately do NOT set app.state.db to simulate startup not complete.
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as ac:
|
||||
response = await ac.get("/api/auth/login", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "/api/setup"
|
||||
|
||||
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
|
||||
"""Health endpoint is always reachable even when db is not initialised."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "bangui.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
session_secret="test-db-none-health-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as ac:
|
||||
response = await ac.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.3",
|
||||
"description": "BanGUI frontend — fail2ban web management interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,22 +7,16 @@
|
||||
|
||||
import { api } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
import type { LoginResponse, LogoutResponse } from "../types/auth";
|
||||
|
||||
/**
|
||||
* Authenticate with the master password.
|
||||
*
|
||||
* The password is SHA-256 hashed client-side before transmission so that
|
||||
* the plaintext never leaves the browser. The backend bcrypt-verifies the
|
||||
* received hash against the stored bcrypt(sha256) digest.
|
||||
*
|
||||
* @param password - The master password entered by the user.
|
||||
* @returns The login response containing the session token.
|
||||
*/
|
||||
export async function login(password: string): Promise<LoginResponse> {
|
||||
const body: LoginRequest = { password: await sha256Hex(password) };
|
||||
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
|
||||
return api.post<LoginResponse>(ENDPOINTS.authLogin, { password });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,7 +109,7 @@ export function ServerStatusBar(): React.JSX.Element {
|
||||
{/* Version */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{status?.version != null && (
|
||||
<Tooltip content="fail2ban version" relationship="description">
|
||||
<Tooltip content="fail2ban daemon version" relationship="description">
|
||||
<Text size={200} className={styles.statValue}>
|
||||
v{status.version}
|
||||
</Text>
|
||||
@@ -139,9 +139,9 @@ export function ServerStatusBar(): React.JSX.Element {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Currently failing IPs" relationship="description">
|
||||
<Tooltip content="Total failed authentication attempts currently tracked by fail2ban across all active jails" relationship="description">
|
||||
<div className={styles.statGroup}>
|
||||
<Text size={200}>Failures:</Text>
|
||||
<Text size={200}>Failed Attempts:</Text>
|
||||
<Text size={200} className={styles.statValue}>
|
||||
{status.total_failures}
|
||||
</Text>
|
||||
|
||||
@@ -33,9 +33,9 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||
if (!cancelled) setStatus(res.completed ? "done" : "pending");
|
||||
})
|
||||
.catch((): void => {
|
||||
// If the check fails, optimistically allow through — the backend will
|
||||
// redirect API calls to /api/setup anyway.
|
||||
if (!cancelled) setStatus("done");
|
||||
// A failed check conservatively redirects to /setup — a crashed
|
||||
// backend cannot serve protected routes anyway.
|
||||
if (!cancelled) setStatus("pending");
|
||||
});
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
|
||||
153
frontend/src/components/__tests__/ServerStatusBar.test.tsx
Normal file
153
frontend/src/components/__tests__/ServerStatusBar.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Tests for the ServerStatusBar component.
|
||||
*
|
||||
* Covers loading state, online / offline rendering, and correct tooltip
|
||||
* wording that distinguishes the fail2ban daemon version from the BanGUI
|
||||
* application version.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { ServerStatusBar } from "../ServerStatusBar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock useServerStatus so tests never touch the network.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../hooks/useServerStatus");
|
||||
|
||||
import { useServerStatus } from "../../hooks/useServerStatus";
|
||||
|
||||
const mockedUseServerStatus = vi.mocked(useServerStatus);
|
||||
|
||||
function renderBar(): void {
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ServerStatusBar />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ServerStatusBar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a spinner while the initial load is in progress", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
// The status-area spinner is labelled "Checking\u2026".
|
||||
expect(screen.getByText("Checking\u2026")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an Online badge when the server is reachable", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
online: true,
|
||||
version: "1.1.0",
|
||||
active_jails: 3,
|
||||
total_bans: 10,
|
||||
total_failures: 5,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
expect(screen.getByText("Online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an Offline badge when the server is unreachable", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
online: false,
|
||||
version: null,
|
||||
active_jails: 0,
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
expect(screen.getByText("Offline")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the daemon version string when available", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
online: true,
|
||||
version: "1.2.3",
|
||||
active_jails: 1,
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the version element when version is null", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
online: false,
|
||||
version: null,
|
||||
active_jails: 0,
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
// No version string should appear in the document.
|
||||
expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows jail / ban / failure counts when the server is online", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
online: true,
|
||||
version: "1.0.0",
|
||||
active_jails: 4,
|
||||
total_bans: 21,
|
||||
total_failures: 99,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("21")).toBeInTheDocument();
|
||||
expect(screen.getByText("99")).toBeInTheDocument();
|
||||
// Verify the "Failed Attempts:" label (renamed from "Failures:").
|
||||
expect(screen.getByText("Failed Attempts:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an error message when the status fetch fails", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
77
frontend/src/components/__tests__/SetupGuard.test.tsx
Normal file
77
frontend/src/components/__tests__/SetupGuard.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { SetupGuard } from "../SetupGuard";
|
||||
|
||||
// Mock the setup API module so tests never hit a real network.
|
||||
vi.mock("../../api/setup", () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSetupStatus } from "../../api/setup";
|
||||
|
||||
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
|
||||
|
||||
function renderGuard() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<SetupGuard>
|
||||
<div data-testid="protected-content">Protected</div>
|
||||
</SetupGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/setup"
|
||||
element={<div data-testid="setup-page">Setup Page</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SetupGuard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a spinner while the setup status is loading", () => {
|
||||
// getSetupStatus resolves eventually — spinner should show immediately.
|
||||
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
|
||||
renderGuard();
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children when setup is complete", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: true });
|
||||
renderGuard();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("protected-content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to /setup when setup is not complete", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
renderGuard();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /setup when the API call fails", async () => {
|
||||
// Task 0.3: a failed check must redirect to /setup, not allow through.
|
||||
mockedGetSetupStatus.mockRejectedValue(new Error("Network error"));
|
||||
renderGuard();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -898,12 +898,14 @@ export function JailsTab(): React.JSX.Element {
|
||||
>
|
||||
{selectedActiveJail !== undefined ? (
|
||||
<JailConfigDetail
|
||||
key={selectedActiveJail.name}
|
||||
jail={selectedActiveJail}
|
||||
onSave={updateJail}
|
||||
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
||||
/>
|
||||
) : selectedInactiveJail !== undefined ? (
|
||||
<InactiveJailDetail
|
||||
key={selectedInactiveJail.name}
|
||||
jail={selectedInactiveJail}
|
||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||
onDeactivate={
|
||||
|
||||
@@ -219,6 +219,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Service Health & Log Viewer section — shown first so users can
|
||||
immediately see whether fail2ban is reachable before editing settings. */}
|
||||
<ServerHealthSection />
|
||||
|
||||
<div className={styles.sectionCard}>
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
@@ -412,8 +416,6 @@ export function ServerTab(): React.JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Service Health & Log Viewer section */}
|
||||
<ServerHealthSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,6 +145,16 @@ const useStyles = makeStyles({
|
||||
padding: tokens.spacingVerticalS,
|
||||
flexShrink: 0,
|
||||
},
|
||||
versionText: {
|
||||
display: "block",
|
||||
color: tokens.colorNeutralForeground4,
|
||||
fontSize: "11px",
|
||||
paddingLeft: tokens.spacingHorizontalS,
|
||||
paddingRight: tokens.spacingHorizontalS,
|
||||
paddingBottom: tokens.spacingVerticalXS,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
// Main content
|
||||
main: {
|
||||
@@ -301,6 +311,11 @@ export function MainLayout(): React.JSX.Element {
|
||||
|
||||
{/* Footer — Logout */}
|
||||
<div className={styles.sidebarFooter}>
|
||||
{!collapsed && (
|
||||
<Text className={styles.versionText}>
|
||||
BanGUI v{__APP_VERSION__}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip
|
||||
content={collapsed ? "Sign out" : ""}
|
||||
relationship="label"
|
||||
|
||||
78
frontend/src/layouts/__tests__/MainLayout.test.tsx
Normal file
78
frontend/src/layouts/__tests__/MainLayout.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Tests for the MainLayout component.
|
||||
*
|
||||
* Covers:
|
||||
* - BanGUI application version displayed in the footer when the sidebar is expanded.
|
||||
* - Version text hidden when the sidebar is collapsed.
|
||||
* - Navigation items rendered correctly.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MainLayout } from "../../layouts/MainLayout";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../providers/AuthProvider", () => ({
|
||||
useAuth: () => ({ logout: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServerStatus", () => ({
|
||||
useServerStatus: () => ({
|
||||
status: { online: true, version: "1.0.0", active_jails: 1, total_bans: 0, total_failures: 0 },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useBlocklist", () => ({
|
||||
useBlocklistStatus: () => ({ hasErrors: false }),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderLayout(): void {
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<MainLayout />
|
||||
</MemoryRouter>
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MainLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the navigation sidebar", () => {
|
||||
renderLayout();
|
||||
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the BanGUI version in the sidebar footer when expanded", () => {
|
||||
renderLayout();
|
||||
// __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define.
|
||||
expect(screen.getByText("BanGUI v0.0.0-test")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the BanGUI version text when the sidebar is collapsed", async () => {
|
||||
renderLayout();
|
||||
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
|
||||
await userEvent.click(toggleButton);
|
||||
expect(screen.queryByText("BanGUI v0.0.0-test")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import { useNavigate } from "react-router-dom";
|
||||
import type { ChangeEvent, FormEvent } from "react";
|
||||
import { ApiError } from "../api/client";
|
||||
import { getSetupStatus, submitSetup } from "../api/setup";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
@@ -101,20 +100,36 @@ export function SetupPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [values, setValues] = useState<FormValues>(DEFAULT_VALUES);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Redirect to /login if setup has already been completed.
|
||||
// Show a full-screen spinner while the check is in flight to prevent
|
||||
// the form from flashing before the redirect fires.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getSetupStatus()
|
||||
.then((res) => {
|
||||
if (res.completed) navigate("/login", { replace: true });
|
||||
if (!cancelled) {
|
||||
if (res.completed) {
|
||||
navigate("/login", { replace: true });
|
||||
} else {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore — stay on setup page */
|
||||
// Failed check: the backend may still be starting up. Stay on this
|
||||
// page so the user can attempt setup once the backend is ready.
|
||||
console.warn("SetupPage: setup status check failed — rendering setup form");
|
||||
if (!cancelled) setChecking(false);
|
||||
});
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -161,11 +176,8 @@ export function SetupPage(): React.JSX.Element {
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Hash the password client-side before transmission — the plaintext
|
||||
// never leaves the browser. The backend bcrypt-hashes the received hash.
|
||||
const hashedPassword = await sha256Hex(values.masterPassword);
|
||||
await submitSetup({
|
||||
master_password: hashedPassword,
|
||||
master_password: values.masterPassword,
|
||||
database_path: values.databasePath,
|
||||
fail2ban_socket: values.fail2banSocket,
|
||||
timezone: values.timezone,
|
||||
@@ -187,6 +199,21 @@ export function SetupPage(): React.JSX.Element {
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" label="Loading…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.card}>
|
||||
|
||||
83
frontend/src/pages/__tests__/SetupPage.test.tsx
Normal file
83
frontend/src/pages/__tests__/SetupPage.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { SetupPage } from "../SetupPage";
|
||||
|
||||
// Mock the setup API so tests never hit a real network.
|
||||
vi.mock("../../api/setup", () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
submitSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSetupStatus } from "../../api/setup";
|
||||
|
||||
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<MemoryRouter initialEntries={["/setup"]}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={<div data-testid="login-page">Login</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SetupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a full-screen spinner while the setup status check is in flight", () => {
|
||||
// getSetupStatus never resolves — spinner should be visible immediately.
|
||||
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
|
||||
renderPage();
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
// Form should NOT be visible yet.
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: /bangui setup/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the setup form once the status check resolves (not complete)", async () => {
|
||||
// Task 0.4: form must not flash before the check resolves.
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /bangui setup/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
// Spinner should be gone.
|
||||
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to /login when setup is already complete", async () => {
|
||||
mockedGetSetupStatus.mockResolvedValue({ completed: true });
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the form and logs a warning when the status check fails", async () => {
|
||||
// Task 0.4: catch block must log a warning and keep the form visible.
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
mockedGetSetupStatus.mockRejectedValue(new Error("Connection refused"));
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /bangui setup/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Client-side cryptography utilities.
|
||||
*
|
||||
* Uses the browser-native SubtleCrypto API so no third-party bundle is required.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return the SHA-256 hex digest of `input`.
|
||||
*
|
||||
* Hashing passwords before transmission means the plaintext never leaves the
|
||||
* browser, even when HTTPS is not enforced in a development environment.
|
||||
* The backend then applies bcrypt on top of the received hash.
|
||||
*
|
||||
* @param input - The string to hash (e.g. the master password).
|
||||
* @returns Lowercase hex-encoded SHA-256 digest.
|
||||
*/
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
3
frontend/src/vite-env.d.ts
vendored
3
frontend/src/vite-env.d.ts
vendored
@@ -7,3 +7,6 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/** BanGUI application version — injected at build time via Vite define. */
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(__dirname, "package.json"), "utf-8"),
|
||||
) as { version: string };
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
/** BanGUI application version injected at build time from package.json. */
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
|
||||
@@ -4,6 +4,10 @@ import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
/** Stub app version for tests — mirrors the vite.config.ts define. */
|
||||
__APP_VERSION__: JSON.stringify("0.0.0-test"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
|
||||
Reference in New Issue
Block a user