refactor: remove Cerberus toggle from Security page and move feature flags to System Settings

- Removed the Cerberus toggle functionality from the Security page.
- Introduced a new feature flags section in the System Settings page to manage Cerberus and Uptime Monitoring features.
- Updated tests to reflect the changes in the Security and System Settings components.
- Added loading overlays for feature toggling actions.
This commit is contained in:
GitHub Actions
2025-12-08 15:41:18 +00:00
parent 83e6cbb848
commit 856903b21d
7 changed files with 256 additions and 949 deletions

View File

@@ -1,456 +1,9 @@
# VS Code Go Troubleshooting Guide & Automations — Current Spec
# Current Spec & Implementation Plan
## Overview
## Goal
Clean up Security/CrowdSec frontend test warnings and ensure backend tests run via VS Code task.
This document defines a focused implementation plan to add a concise VS Code Go troubleshooting guide and small automations to the Charon repository to address persistent Go compiler errors caused by gopls or workspace misconfiguration. The scope is limited to developer tooling, diagnostics, and safe automation changes that live in `docs/`, `scripts/`, and `.vscode/` (no production code changes). Implementation must respect the repository's architecture rules in .github/copilot-instructions.md (backend in `backend/`, frontend in `frontend/`, no Python).
## Goals / Acceptance Criteria
- Provide reproducible steps that make `go build ./...` succeed in the repo for contributors.
- Provide easy-to-run tasks and scripts that surface common misconfigurations (missing modules, GOPATH issues, gopls misbehavior).
- Provide VS Code settings and tasks so the `Go` extension/gopls behaves reliably for this repo layout.
- Provide CI and pre-commit recommendations to prevent regressions.
- Provide QA checklist that verifies build, language-server behavior, and CI integration.
Acceptance Criteria (testable):
- Running `./scripts/check_go_build.sh` from repo root in a clean dev environment returns exit code 0 and prints "BUILD_OK".
- `cd backend && go build ./...` returns exit code 0 locally on a standard Linux environment with Go installed.
- VS Code: running the `Go: Restart Language Server` command after applying `.vscode/settings.json` and `.vscode/tasks.json` clears gopls editor errors (no stale compiler errors remain in Problems panel for valid code).
- A dedicated GH Actions job runs `cd backend && go test ./...` and `go build ./...` and returns success in CI.
## Files to Inspect & Modify (exact paths)
- docs/plans/current_spec.md (this file)
- docs/troubleshooting/go-gopls.md (new — guidance + logs collection)
- .vscode/tasks.json (new)
- .vscode/settings.json (new)
- scripts/check_go_build.sh (new)
- scripts/gopls_collect.sh (new)
- Makefile (suggested additions at root)
- .github/workflows/ci-go.yml (suggested CI job snippet — add or integrate into existing CI)
- .pre-commit-config.yaml (suggested update to add a hook calling `scripts/check_go_build.sh`)
- backend/go.mod, backend/go.work, backend/** (inspect for module path and replace directives)
- backend/cmd/api (inspect build entrypoint)
- backend/internal/server (inspect server mount and attachFrontend logic)
- backend/internal/config (inspect env handling for CHARON_* variables)
- backend/internal/models (inspect for heavy imports that may cause build issues)
- frontend/.vscode (ensure no conflicting workspace settings in frontend)
- go.work (workspace-level module directives)
If function-level inspection is needed, likely candidates:
- `/projects/Charon/backend/cmd/api/main.go` or `/projects/Charon/backend/cmd/api/*.go` (entrypoint)
- `/projects/Charon/backend/internal/api/routes/routes.go` (AutoMigrate, router mounting)
Do NOT change production code unless the cause is strictly workspace/config related and low risk. Prefer documenting and instrumenting.
## Proposed Implementation (step-by-step)
1. Create `docs/troubleshooting/go-gopls.md` describing how to reproduce, collect logs, and file upstream issues. (Minimal doc — see Templates below.)
2. Add `.vscode/settings.json` and `.vscode/tasks.json` to the repo root to standardize developer tools. These settings will scope to the workspace and will not affect CI.
3. Create `scripts/check_go_build.sh` that runs reproducible checks: `go version`, `go env`, `go list -mod=mod`, `go build ./...` in `backend/`, and prints diagnostic info if the build fails.
4. Create `scripts/gopls_collect.sh` to collect `gopls` logs with `-rpc.trace` and instruct developers how to attach those logs when filing upstream issues.
5. Add a small Makefile target for dev convenience: `make go-check` -> runs `scripts/check_go_build.sh` and `make gopls-logs` -> runs `scripts/gopls_collect.sh`.
6. Add a recommended GH Actions job snippet to run `go test` and `go build` for `backend/`. Add this to `.github/workflows/ci-go.yml` or integrate into the existing CI workflow.
7. Add a sample `pre-commit` hook entry invoking `scripts/check_go_build.sh` (optional, manual-stage hook recommended rather than blocking commit for every contributor).
8. Add `docs/troubleshooting/go-gopls.md` acceptance checklist and QA test cases.
9. Communicate rollout plan and document how to revert the automation files (simple revert PR).
## VS Code Tasks and Settings
Place these files under `.vscode/` in the repository root. They are workspace recommendations a developer can accept when opening the workspace.
1) .vscode/tasks.json
```json
{
"version": "2.0.0",
"tasks": [
{
"label": "Go: Build Backend",
"type": "shell",
"command": "bash",
"args": ["-lc", "cd backend && go build ./..."],
"group": { "kind": "build", "isDefault": true },
"presentation": { "reveal": "always", "panel": "shared" },
"problemMatcher": ["$go"]
},
{
"label": "Go: Test Backend",
"type": "shell",
"command": "bash",
"args": ["-lc", "cd backend && go test ./... -v"],
"group": "test",
"presentation": { "reveal": "always", "panel": "shared" }
},
{
"label": "Go: Mod Tidy (Backend)",
"type": "shell",
"command": "bash",
"args": ["-lc", "cd backend && go mod tidy"],
"presentation": { "reveal": "silent", "panel": "shared" }
},
{
"label": "Gather gopls logs",
"type": "shell",
"command": "bash",
"args": ["-lc", "./scripts/gopls_collect.sh"],
"presentation": { "reveal": "always", "panel": "new" }
}
]
}
```
2) .vscode/settings.json
```json
{
"go.useLanguageServer": true,
"gopls": {
"staticcheck": true,
"analyses": {
"unusedparams": true,
"nilness": true
},
"completeUnimported": true,
"matcher": "Fuzzy",
"verboseOutput": true
},
"go.toolsEnvVars": {
"GOMODCACHE": "${workspaceFolder}/.cache/go/pkg/mod"
},
"go.buildOnSave": "workspace",
"go.lintOnSave": "package",
"go.formatTool": "gofmt",
"files.watcherExclude": {
"**/backend/data/**": true,
"**/frontend/dist/**": true
}
}
```
Notes on settings:
- `gopls.verboseOutput` will allow VS Code Output panel to show richer logs for triage.
- `GOMODCACHE` is set to workspace-local to prevent unexpected GOPATH/GOMOD cache interference on some dev machines; change if undesired.
## Scripts to Add
Create `scripts/check_go_build.sh` and `scripts/gopls_collect.sh` with the contents below. Make both executable (`chmod +x scripts/*.sh`).
1) scripts/check_go_build.sh
```sh
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "[charon] repo root: $ROOT_DIR"
echo "-- go version --"
go version || true
echo "-- go env --"
go env || true
echo "-- go list (backend) --"
cd "$ROOT_DIR/backend"
echo "module: $(cat go.mod | sed -n '1p')"
go list -deps ./... | wc -l || true
echo "-- go build backend ./... --"
if go build ./...; then
echo "BUILD_OK"
exit 0
else
echo "BUILD_FAIL"
echo "Run 'cd backend && go build -v ./...' for verbose output"
exit 2
fi
```
2) scripts/gopls_collect.sh
```sh
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
OUT_DIR="/tmp/charon-gopls-logs-$(date +%s)"
mkdir -p "$OUT_DIR"
echo "Collecting gopls debug output to $OUT_DIR"
if ! command -v gopls >/dev/null 2>&1; then
echo "gopls not found in PATH. Install with: go install golang.org/x/tools/gopls@latest"
exit 2
fi
cd "$ROOT_DIR/backend"
echo "Running: gopls -rpc.trace -v check ./... > $OUT_DIR/gopls.log 2>&1"
gopls -rpc.trace -v check ./... > "$OUT_DIR/gopls.log" 2>&1 || true
echo "Also collecting 'go env' and 'go version'"
go version > "$OUT_DIR/go-version.txt" 2>&1 || true
go env > "$OUT_DIR/go-env.txt" 2>&1 || true
echo "Logs collected at: $OUT_DIR"
echo "Attach the $OUT_DIR contents when filing issues against golang/vscode-go or gopls."
```
Optional: `Makefile` additions (root `Makefile`):
```makefile
.PHONY: go-check gopls-logs
go-check:
./scripts/check_go_build.sh
gopls-logs:
./scripts/gopls_collect.sh
```
## CI or Pre-commit Hooks to Run
CI: Add or update a GitHub Actions job in `.github/workflows/ci-go.yml` (or combine with existing CI):
```yaml
name: Go CI (backend)
on: [push, pull_request]
jobs:
go-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
- name: Build backend
run: |
cd backend
go version
go env
go test ./... -v
go build ./...
```
Pre-commit (optional): add this to `.pre-commit-config.yaml` under `repos:` as a local hook or add a simple script that developers can opt into. Example local hook entry:
```yaml
- repo: local
hooks:
- id: go-check
name: go-check
entry: ./scripts/check_go_build.sh
language: system
stages: [manual]
```
Rationale: run as a `manual` hook to avoid blocking every commit but available for maintainers to run pre-merge.
## Tests & Validation Steps
Run these commands locally and in CI as part of acceptance testing.
1) Basic local verification (developer machine):
```bash
# from repo root
./scripts/check_go_build.sh
# expected output contains: BUILD_OK and exit code 0
cd backend
go test ./... -v
# expected: all tests PASS and exit code 0
go build ./...
# expected: no errors, exit code 0
# In VS Code: open workspace, accept recommended workspace settings, then
# Run Command Palette -> "Go: Restart Language Server"
# Open Problems panel: expect no stale gopls errors for otherwise-valid code
```
2) Gather gopls logs (if developer still sees errors):
```bash
./scripts/gopls_collect.sh
# expected: prints path to logs and files: gopls.log, go-version.txt, go-env.txt
```
3) CI validation (after adding `ci-go.yml`):
Push branch, create PR. GitHub Actions should run `Go CI (backend)` job and show green check on success. Expected steps: `go test` and `go build` both pass.
4) QA Acceptance Checklist (for QA_Security):
- `./scripts/check_go_build.sh` returns `BUILD_OK` on a clean environment.
- `cd backend && go test ./... -v` yields only PASS/ok lines.
- Running `./scripts/gopls_collect.sh` produces a non-empty `gopls.log` file when gopls invoked.
- VS Code `Go: Restart Language Server` clears stale errors in Problems panel.
- CI job `Go CI (backend)` passes on PR.
## How to Collect gopls Logs and File an upstream Issue
1. Reproduce the problem in VS Code.
2. Run `./scripts/gopls_collect.sh` and attach the `$OUT_DIR` results.
3. If `gopls` is not available, install it locally: `go install golang.org/x/tools/gopls@latest`.
4. Include these items in the issue:
- A minimal reproduction steps list.
- The `gopls.log` produced by `gopls -rpc.trace -v check ./...`.
- Output of `go version` and `go env` (from `go-version.txt` and `go-env.txt`).
5. File the issue on the `golang/vscode-go` issue tracker (preferred), include logs and reproduction steps.
Suggested issue title template: "gopls: persistent compiler errors in Charon workspace — [short symptom]"
## Rollout & Backout Plan
Rollout steps:
1. Implement the changes in a feature branch `docs/gopls-troubleshoot`.
2. Open a PR describing changes and link to this plan.
3. Run CI and verify `Go CI (backend)` passes.
4. Merge after 2 approvals.
5. Notify contributors in README or developer onboarding that workspace settings and scripts are available.
Backout steps:
1. Revert the merge commit or open a PR that removes `.vscode/*`, `scripts/*`, and `docs/troubleshooting/*` files.
2. If CI changes were added to `.github/workflows/*`, revert those files as well.
Notes on safety: these files are developer-tooling only. They do not alter production code or binaries.
## Estimated Effort (time / complexity)
- Create docs and scripts: 12 hours (low complexity)
- Add .vscode tasks/settings and Makefile snippet: 3060 minutes
- Add CI job and test in GH Actions: 1 hour
- QA validation and follow-ups: 12 hours
Total: 36 hours for a single engineer to implement, test, and land.
## Notes for Roles
- Backend_Dev:
- Inspect `/projects/Charon/backend/go.mod`, `/projects/Charon/backend/go.work`, `/projects/Charon/backend/cmd/api` and `/projects/Charon/backend/internal/*` for any non-module-safe imports, cgo usage, or platform-specific build tags that might confuse `gopls` or `go build`.
- If `go build` fails locally but passes in CI, inspect `go env` differences (GOMODCACHE, GOPATH, GOFLAGS, GO111MODULE). Use `./scripts/check_go_build.sh` to capture environment.
- Frontend_Dev:
- Ensure there are no conflicting workspace `.vscode` files inside `frontend/` that override root workspace settings. If present, move per-project overrides to `frontend/.vscode` and keep common settings in root `.vscode`.
- QA_Security:
- Use the acceptance checklist above. Validate that `go test` and `go build` run cleanly in CI and locally.
- Confirm that scripts do not leak secrets (they won't — they only run `go` commands). Confirm scripts are shell-only and do not pull remote binaries without explicit developer action.
- Docs_Writer:
- Create `docs/troubleshooting/go-gopls.md` with background, step-by-step reproduction, and minimal triage guidance. Link to scripts and how to attach logs when filing upstream issues.
## Example docs/troubleshooting/go-gopls.md (template)
Create `docs/troubleshooting/go-gopls.md` with this content (starter):
```
# Troubleshooting gopls / VS Code Go errors in Charon
This page documents how to triage and collect logs for persistent Go errors shown by gopls or VS Code in the Charon repository.
Steps:
1. Open the Charon workspace in VS Code (project root).
2. Accept the workspace settings prompt to apply `.vscode/settings.json`.
3. Run the workspace task: `Go: Build Backend` (or run `./scripts/check_go_build.sh`).
4. If errors persist, run `./scripts/gopls_collect.sh` and attach the output directory to an issue.
When filing upstream issues, include `gopls.log`, `go-version.txt`, `go-env.txt`, and a short reproduction.
```
## Checklist (QA)
- [ ] `./scripts/check_go_build.sh` exits 0 and prints `BUILD_OK`.
- [ ] `cd backend && go test ./... -v` returns all `PASS` results.
- [ ] `go build ./...` returns exit code 0.
- [ ] VS Code Problems panel shows no stale gopls errors after `Go: Restart Language Server`.
- [ ] `./scripts/gopls_collect.sh` produces `gopls.log` containing `rpc.trace` sections.
- [ ] CI job `Go CI (backend)` passes on PR.
## Final Notes
All proposed files are restricted to developer experience and documentation. Do not modify production source files unless a concrete code-level bug (not tooling) is found and approved by the backend owner.
If you'd like, I can also open a PR implementing the `.vscode/`, `scripts/`, `docs/troubleshooting/` additions and the CI job snippet. If approved, I will run the repo-level `make go-check` and iterate on any failures.
# Plan: Refactor Feature Flags to Optional Features
## Overview
Refactor the existing "Feature Flags" system into a user-friendly "Optional Features" section in System Settings. This involves renaming, consolidating toggles (Cerberus, Uptime), and enforcing behavior (hiding sidebar items, stopping background jobs) when features are disabled.
## User Requirements
1. **Rename**: 'Feature Flags' -> 'Optional Features'.
2. **Cerberus**: Move global toggle to 'Optional Features'.
3. **Uptime**: Add toggle to 'Optional Features'.
4. **Cleanup**: Remove unused flags (`feature.global.enabled`, `feature.notifications.enabled`, `feature.docker.enabled`).
5. **Behavior**:
- **Default**: Cerberus and Uptime ON.
- **OFF State**: Hide from Sidebar, stop background jobs, block notifications.
- **Persistence**: Do NOT delete data when disabled.
## Implementation Details
### 1. Backend Changes
#### `backend/internal/api/handlers/feature_flags_handler.go`
- Update `defaultFlags` list:
- Keep: `feature.cerberus.enabled`, `feature.uptime.enabled`
- Remove: `feature.global.enabled`, `feature.notifications.enabled`, `feature.docker.enabled`
- Ensure defaults are `true` if not set in DB or Env.
#### `backend/internal/cerberus/cerberus.go`
- Update `IsEnabled()` to check `feature.cerberus.enabled` instead of `security.cerberus.enabled`.
- Maintain backward compatibility or migrate existing setting if necessary (or just switch to the new key).
#### `backend/internal/api/routes/routes.go`
- **Uptime Background Job**:
- In the `go func()` that runs the ticker:
- Check `feature.uptime.enabled` before running `uptimeService.CheckAll()`.
- If disabled, skip the check.
- **Cerberus Middleware**:
- The middleware already calls `IsEnabled()`, so updating `cerberus.go` is sufficient.
### 2. Frontend Changes
#### `frontend/src/pages/SystemSettings.tsx`
- **Rename Card**: Change "Feature Flags" to "Optional Features".
- **Consolidate Toggles**:
- Remove "Enable Cerberus Security" from "General Configuration".
- Render specific toggles for "Cerberus Security" and "Uptime Monitoring" in the "Optional Features" card.
- Use `feature.cerberus.enabled` and `feature.uptime.enabled` keys.
- Add user-friendly descriptions for each.
- **Remove Generic List**: Instead of iterating over all keys, explicitly render the supported optional features to control order and presentation.
#### `frontend/src/components/Layout.tsx`
- **Fetch Flags**: Use `getFeatureFlags` (or a new hook) to get current state.
- **Conditional Rendering**:
- Hide "Uptime" nav item if `feature.uptime.enabled` is false.
- Hide "Security" nav group if `feature.cerberus.enabled` is false.
### 3. Migration / Data Integrity
- Existing `security.cerberus.enabled` setting in DB should be migrated to `feature.cerberus.enabled` or the code should handle the transition.
- **Action**: We will switch to `feature.cerberus.enabled`. The user can re-enable it if it defaults to off, but we'll try to default it to ON in the handler.
## Step-by-Step Execution
1. **Backend**: Update `feature_flags_handler.go` to clean up flags and set defaults.
2. **Backend**: Update `cerberus.go` to use new flag key.
3. **Backend**: Update `routes.go` to gate Uptime background job.
4. **Frontend**: Update `SystemSettings.tsx` UI.
5. **Frontend**: Update `Layout.tsx` sidebar logic.
6. **Verify**: Test toggling features and checking sidebar/background behavior.
## Steps
1) Frontend tests: Update `frontend/src/pages/__tests__/Security*.tsx` to wrap React state updates in `act(...)` and ensure React Query mocks return defined data shapes (provide default objects/arrays instead of `undefined`).
2) Mock defaults: Where tests mock query responses for Security/CrowdSec pages, supply minimal valid data (e.g., empty arrays/objects) to avoid undefined access warnings.
3) Backend tests: Run task "Go: Test Backend" (`shell: Go: Test Backend`) to execute `cd backend && go test ./... -v`.

View File

@@ -1,309 +1,53 @@
# QA Report: Optional Features Implementation
# QA Report: System Settings & Security (Feature Flags OFF)
**Date:** December 7, 2025
**Date:** December 8, 2025
**QA Agent:** QA_Security
**Feature:** Optional Features (Feature Flags Refactor)
**Specification:** `docs/plans/current_spec.md`
**Scope:** System Settings features card, Security page (no Cerberus master toggle), and backend/UX behavior when `feature.cerberus.enabled` and `feature.uptime.enabled` are `false`.
**Specification:** Feature flag controls and UI expectations per product notes
## Executive Summary
**Final Verdict:****PASS**
**Final Verdict:** ✅ PASS WITH WARNINGS
The Optional Features implementation successfully meets all requirements specified in the plan. All tests pass, security checks are validated, and the implementation follows the project's quality guidelines. One pre-existing test was updated to align with the new default-enabled specification.
- Frontend checks: `npm run type-check` and `npm run test:ci` pass; vitest emitted non-blocking act()/query warnings in security suites.
- Feature flags verified in DB as `false` for Cerberus and Uptime; backend logs show only proxy/NZBget traffic, no uptime or cerberus activity.
- Security page presents per-service toggles only (no global Cerberus switch) and respects disabled state in tests; System Settings Features card remains at top with two-column layout and tooltip text.
## Test Results
| Area | Status | Notes |
| --- | --- | --- |
| Frontend TypeScript | ✅ PASS | `npm run type-check` via task (Dockerized run) |
| Frontend Unit Tests | ✅ PASS* | `npm run test:ci` (584 tests). Warnings: act() needed in Security audit tests; several React Query data undefined warnings in security/crowdsec specs; jsdom navigation not implemented (expected). |
| Backend Tests | ⏭️ Not Run | Not requested for this cycle. |
| UI Sanity (headless) | ✅ PASS | Layout verified via tests/code: Features card top, 2-col grid, tooltips via `title`, overlay during mutations; Security page shows per-service toggles, banner when Cerberus disabled. |
| Backend Sanity | ✅ PASS | DB flags false; no uptime/cerberus activity visible in recent logs; services remain disabled. |
## Validation Details
- Feature flags state: `feature.cerberus.enabled=false`, `feature.uptime.enabled=false` (queried `/app/data/charon.db` inside `charon-debug`).
- System Settings Features card: two switches (Cerberus, Uptime), `title` tooltips present, `ConfigReloadOverlay` shows during pending mutations; card positioned at top of page grid.
- Security page: no global Cerberus toggle; per-service toggles disabled when Cerberus flag is false; disabled banner shown; overlay used during config mutations.
- Backend behavior: recent `charon-debug` logs contain only NZBget/Caddy access entries; no uptime monitor or cerberus job traces while flags are off.
## Issues / Warnings
- Vitest warnings (non-failing):
- act() wrapping needed in Security audit tests (double-click prevention) and Security loading test.
- React Query “query data cannot be undefined” warnings in security and crowdsec specs; jsdom “navigation to another Document” warnings in security/crowdsec specs.
- These did not fail the suite but add noise; consider tightening test setup/mocks.
## Follow-ups / Recommendations
1. Clean up Security/CrowdSec tests to wrap pending state updates in `act()` and ensure query mocks return non-undefined defaults to silence warnings.
2. If deeper backend verification is needed, run `Go: Test Backend` or integration suite; not run in this cycle.
## Evidence
- Frontend tests: `npm run test:ci` (all green, warnings noted above).
- Feature flags: queried SQLite in `charon-debug` container showing both flags `false`.
- Logs: `docker logs charon-debug --tail` showed only NZBget access traffic, no uptime/cerberus actions.
---
## Test Results Summary
### Backend Tests
| Test Category | Status | Details |
|--------------|--------|---------|
| Unit Tests | ✅ PASS | All tests passing (excluding 1 updated test) |
| Race Detector | ✅ PASS | No race conditions detected |
| GolangCI-Lint | ⚠️ PASS* | 12 pre-existing issues unrelated to Optional Features |
| Coverage | ✅ PASS | 85.3% (meets 85% minimum requirement) |
**Note:** Golangci-lint found 12 pre-existing issues (5 errcheck, 1 gocritic, 1 gosec, 1 staticcheck, 4 unused) that are not related to the Optional Features implementation.
### Frontend Tests
| Test Category | Status | Details |
|--------------|--------|---------|
| Unit Tests | ✅ PASS | 586/586 tests passing |
| TypeScript | ✅ PASS | No type errors |
| ESLint | ✅ PASS | No linting errors |
### Pre-commit Checks
| Check | Status | Details |
|-------|--------|---------|
| Go Vet | ✅ PASS | No issues |
| Go Tests | ✅ PASS | Coverage requirement met (85.3% ≥ 85%) |
| Version Check | ✅ PASS | Version matches git tag |
| Frontend TypeScript | ✅ PASS | No type errors |
| Frontend Lint | ✅ PASS | No linting errors |
---
## Implementation Verification
### 1. Backend Implementation
#### ✅ Feature Flags Handler (`feature_flags_handler.go`)
- **Default Flags**: Correctly limited to `feature.cerberus.enabled` and `feature.uptime.enabled`
- **Default Behavior**: Both features default to `true` when no DB setting exists ✓
- **Environment Variables**: Proper fallback support ✓
- **Authorization**: Update endpoint properly protected ✓
#### ✅ Cerberus Integration (`cerberus.go`)
- **Feature Flag Check**: Uses `feature.cerberus.enabled` as primary key ✓
- **Legacy Support**: Falls back to `security.cerberus.enabled` for backward compatibility ✓
- **Default Behavior**: Defaults to enabled (true) when no setting exists ✓
- **Middleware Integration**: Properly gates security checks based on feature state ✓
#### ✅ Uptime Background Job (`routes.go`)
- **Feature Check**: Checks `feature.uptime.enabled` before running background tasks ✓
- **Ticker Logic**: Feature flag is checked on each tick (every 1 minute) ✓
- **Initial Sync**: Respects feature flag during initial sync ✓
- **Manual Trigger**: `/system/uptime/check` endpoint still available (feature check should be added) ⚠️
**Recommendation:** Add feature flag check to manual uptime check endpoint for consistency.
### 2. Frontend Implementation
#### ✅ System Settings Page (`SystemSettings.tsx`)
- **Card Renamed**: "Feature Flags" → "Optional Features" ✓
- **Cerberus Toggle**: Properly rendered with descriptive text ✓
- **Uptime Toggle**: Properly rendered with descriptive text ✓
- **API Integration**: Uses `updateFeatureFlags` mutation correctly ✓
- **User Feedback**: Toast notifications on success/error ✓
#### ✅ Layout/Sidebar (`Layout.tsx`)
- **Feature Flags Query**: Fetches flags with 5-minute stale time ✓
- **Conditional Rendering**:
- Uptime nav item hidden when `feature.uptime.enabled` is false ✓
- Security nav group hidden when `feature.cerberus.enabled` is false ✓
- **Default Behavior**: Both items visible when flags are loading (defaults to enabled) ✓
- **Tests**: Comprehensive tests for sidebar hiding behavior ✓
### 3. API Endpoints
| Endpoint | Method | Protected | Tested |
|----------|--------|-----------|--------|
| `/api/feature-flags` | GET | ✅ | ✅ |
| `/api/feature-flags` | PUT | ✅ | ✅ |
---
## Security Assessment
### Authentication & Authorization ✅
- All feature flag endpoints require authentication
- Update operations properly restricted to authenticated users
- No privilege escalation vulnerabilities identified
### Input Validation ✅
- Feature flag keys validated against whitelist (`defaultFlags`)
- Only allowed keys (`feature.cerberus.enabled`, `feature.uptime.enabled`) can be modified
- Invalid keys silently ignored (secure fail-closed behavior)
### Data Integrity ✅
- **Disabling features does NOT delete configuration data** ✓
- Database records preserved when features are toggled off
- Configuration can be safely re-enabled without data loss
### Background Jobs ✅
- Uptime monitoring stops when feature is disabled
- Cerberus middleware respects feature state
- No resource leaks or zombie processes identified
---
## Regression Testing
### Existing Functionality ✅
- ✅ All existing tests continue to pass
- ✅ No breaking changes to API contracts
- ✅ Backward compatibility maintained (legacy `security.cerberus.enabled` supported)
- ✅ Performance benchmarks within acceptable range
### Default Behavior ✅
- ✅ Both Cerberus and Uptime default to **enabled**
- ✅ Users must explicitly disable features
- ✅ Conservative fail-safe approach
### Sidebar Behavior ✅
- ✅ Security menu hidden when Cerberus disabled
- ✅ Uptime menu hidden when Uptime disabled
- ✅ Menu items reappear when features re-enabled
- ✅ No UI glitches or race conditions
---
## Test Coverage Analysis
### Backend Coverage: 85.3%
**Feature Flag Handler:**
- `GetFlags()`: 100% covered
- `UpdateFlags()`: 100% covered
- Environment variable fallback: Tested ✓
- Database upsert logic: Tested ✓
**Cerberus Integration:**
- `IsEnabled()`: 100% covered
- Feature flag precedence: Tested ✓
- Legacy fallback: Tested ✓
- Default behavior: Tested ✓
**Uptime Background Job:**
- Feature flag gating: Implicitly tested via integration tests
- Recommendation: Add explicit unit test for background job feature gating
### Frontend Coverage: 100% of New Code
- SystemSettings toggles: Tested ✓
- Layout conditional rendering: Tested ✓
- Feature flag loading states: Tested ✓
- API integration: Tested ✓
---
## Issues Found & Resolved
### Issue #1: Test Alignment with Specification ✅ **RESOLVED**
**Test:** `TestCerberus_IsEnabled_Disabled`
**Problem:** Test expected Cerberus to be disabled when `CerberusEnabled: false` in config and no DB setting exists, but specification requires default to **enabled**.
**Resolution:** Updated test to set DB flag to `false` to properly test disabled state.
**Status:** Fixed and verified
### Issue #2: Pre-existing Linter Warnings ⚠️ **NOT BLOCKING**
**Findings:** 12 golangci-lint issues in unrelated files:
- 5 unchecked error returns in `mail_service.go` (deferred Close() calls)
- 1 regex pattern warning in `mail_service.go`
- 1 weak random number usage in test helper
- 1 deprecated API usage in test helper
- 4 unused functions/types in test files
**Impact:** None of these are related to Optional Features implementation
**Status:** Documented for future cleanup, not blocking this feature
---
## Recommendations
### High Priority
None
### Medium Priority
1. **Add Feature Flag Check to Manual Uptime Endpoint**
- File: `backend/internal/api/routes/routes.go`
- Endpoint: `POST /system/uptime/check`
- Add check for `feature.uptime.enabled` before running `uptimeService.CheckAll()`
- Consistency with background job behavior
### Low Priority
1. **Add Explicit Unit Test for Uptime Background Job Feature Gating**
- Create test that verifies background job respects feature flag
- Current coverage is implicit via integration tests
2. **Address Pre-existing Linter Warnings**
- Fix unchecked error returns in mail service
- Update deprecated `rand.Seed` usage in test helpers
- Clean up unused test helper functions
3. **Consider Feature Flag Logging**
- Add structured logging when features are toggled on/off
- Helps with debugging and audit trails
---
## Compliance & Standards
### Code Quality Guidelines ✅
- DRY principle applied (handlers reuse common patterns)
- No dead code introduced
- Battle-tested packages used (GORM, Gin)
- Clear naming and comments maintained
- Conventional commit messages used
### Architecture Rules ✅
- Frontend code exclusively in `frontend/` directory
- Backend code exclusively in `backend/` directory
- No Python introduced (Go + React/TypeScript stack maintained)
- Single binary + static assets deployment preserved
### Security Best Practices ✅
- Input sanitization implemented
- Authentication required for all mutations
- Safe fail-closed behavior (invalid keys ignored)
- Data persistence ensured (no data loss on feature toggle)
---
## Performance Impact
### Backend
- **API Response Time:** No measurable impact (<1ms overhead for feature flag checks)
- **Background Jobs:** Properly gated, no unnecessary resource consumption
- **Database Queries:** Minimal overhead (1 additional query per feature check, properly cached)
### Frontend
- **Bundle Size:** Negligible increase (<2KB)
- **Render Performance:** No impact on page load times
- **API Calls:** Efficient query caching (5-minute stale time)
---
## Conclusion
The Optional Features implementation successfully refactors the Feature Flags system according to specification. All core requirements are met:
✅ Renamed to "Optional Features"
✅ Cerberus toggle integrated
✅ Uptime toggle implemented
✅ Unused flags removed
✅ Default behavior: both features enabled
✅ Sidebar items conditionally rendered
✅ Background jobs respect feature state
✅ Data persistence maintained
✅ Comprehensive test coverage
✅ Security validated
✅ No regressions introduced
The implementation is **production-ready** and recommended for merge.
---
## Sign-off
**QA Agent:** QA_Security
**Date:** December 7, 2025
**Status:****APPROVED FOR PRODUCTION**
---
## Appendix: Test Execution Summary
### Backend
```
Total Packages: 13
Total Tests: 400+
Passed: 100% (after fix)
Duration: ~53 seconds
Coverage: 85.3%
```
### Frontend
```
Total Test Files: 67
Total Tests: 586
Passed: 100%
Duration: ~52 seconds
```
### Pre-commit
```
Total Checks: 5
Passed: 100%
Duration: ~3 minutes (includes full test suite)
```
**Status:** ✅ Approved with warnings logged above.

View File

@@ -65,36 +65,9 @@ export default function Security() {
},
})
const toggleCerberusMutation = useMutation({
mutationFn: async (enabled: boolean) => {
await updateSetting('security.cerberus.enabled', enabled ? 'true' : 'false', 'security', 'bool')
},
onMutate: async (enabled: boolean) => {
await queryClient.cancelQueries({ queryKey: ['security-status'] })
const previous = queryClient.getQueryData(['security-status'])
if (previous) {
queryClient.setQueryData(['security-status'], (old: unknown) => {
const copy = JSON.parse(JSON.stringify(old)) as SecurityStatus
if (!copy.cerberus) copy.cerberus = { enabled: false }
copy.cerberus.enabled = enabled
return copy
})
}
return { previous }
},
onError: (_err, _vars, context: unknown) => {
if (context && typeof context === 'object' && 'previous' in context) {
queryClient.setQueryData(['security-status'], context.previous)
}
},
// onSuccess: already set below
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
queryClient.invalidateQueries({ queryKey: ['security-status'] })
},
})
const fetchCrowdsecStatus = async () => {
try {
const s = await statusCrowdsec()
setCrowdsecStatus(s)
@@ -110,7 +83,6 @@ export default function Security() {
// Determine if any security operation is in progress
const isApplyingConfig =
toggleCerberusMutation.isPending ||
toggleServiceMutation.isPending ||
updateSecurityConfigMutation.isPending ||
generateBreakGlassMutation.isPending ||
@@ -119,9 +91,6 @@ export default function Security() {
// Determine contextual message
const getMessage = () => {
if (toggleCerberusMutation.isPending) {
return { message: 'Cerberus awakens...', submessage: 'Guardian of the gates stands watch' }
}
if (toggleServiceMutation.isPending) {
return { message: 'Three heads turn...', submessage: 'Security configuration updating' }
}
@@ -186,14 +155,6 @@ export default function Security() {
<ShieldCheck className="w-8 h-8 text-green-500" />
Security Dashboard
</h1>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-500 dark:text-gray-400">Enable Cerberus</label>
<Switch
checked={status?.cerberus?.enabled ?? false}
onChange={(e) => toggleCerberusMutation.mutate(e.target.checked)}
data-testid="toggle-cerberus"
/>
</div>
<div/>
<Button
variant="secondary"

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
@@ -10,6 +10,7 @@ import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags'
import client from '../api/client'
// CrowdSec runtime control is now in the Security page
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
import { ConfigReloadOverlay } from '../components/LoadingStates'
interface HealthResponse {
status: string
@@ -96,6 +97,22 @@ export default function SystemSettings() {
queryFn: getFeatureFlags,
})
const featureToggles = useMemo(
() => [
{
key: 'feature.cerberus.enabled',
label: 'Cerberus Security Suite',
tooltip: 'Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.',
},
{
key: 'feature.uptime.enabled',
label: 'Uptime Monitoring',
tooltip: 'Monitor the availability of your proxy hosts and remote servers.',
},
],
[]
)
const updateFlagMutation = useMutation({
mutationFn: async (payload: Record<string, boolean>) => updateFeatureFlags(payload),
onSuccess: () => {
@@ -110,16 +127,54 @@ export default function SystemSettings() {
// CrowdSec control
// Determine loading message
const { message, submessage } = updateFlagMutation.isPending
? { message: 'Updating features...', submessage: 'Applying configuration changes' }
: { message: 'Loading...', submessage: 'Please wait' }
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Server className="w-8 h-8" />
System Settings
</h1>
<>
{updateFlagMutation.isPending && (
<ConfigReloadOverlay
message={message}
submessage={submessage}
type="charon"
/>
)}
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Server className="w-8 h-8" />
System Settings
</h1>
{/* General Configuration */}
<Card className="p-6">
{/* Features */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Features</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{featureFlags ? (
featureToggles.map(({ key, label, tooltip }) => (
<div
key={key}
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-800"
title={tooltip}
>
<p className="text-sm font-medium text-gray-900 dark:text-white cursor-help">{label}</p>
<Switch
aria-label={`${label} toggle`}
checked={!!featureFlags[key]}
disabled={updateFlagMutation.isPending}
onChange={(e) => updateFlagMutation.mutate({ [key]: e.target.checked })}
/>
</div>
))
) : (
<p className="text-sm text-gray-500 col-span-2">Loading features...</p>
)}
</div>
</Card>
{/* General Configuration */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">General Configuration</h2>
<div className="space-y-4">
<Input
@@ -182,45 +237,8 @@ export default function SystemSettings() {
</div>
</Card>
{/* Optional Features */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Optional Features</h2>
<div className="space-y-6">
{featureFlags ? (
<>
{/* Cerberus */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Cerberus Security Suite</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.
</p>
</div>
<Switch
checked={!!featureFlags['feature.cerberus.enabled']}
onChange={(e) => updateFlagMutation.mutate({ 'feature.cerberus.enabled': e.target.checked })}
/>
</div>
{/* Optional Features - Removed (Moved to top) */}
{/* Uptime */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Uptime Monitoring</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Monitor the availability of your proxy hosts and remote servers.
</p>
</div>
<Switch
checked={!!featureFlags['feature.uptime.enabled']}
onChange={(e) => updateFlagMutation.mutate({ 'feature.uptime.enabled': e.target.checked })}
/>
</div>
</>
) : (
<p className="text-sm text-gray-500">Loading features...</p>
)}
</div>
</Card>
{/* System Status */}
<Card className="p-6">
@@ -325,5 +343,6 @@ export default function SystemSettings() {
</div>
</>
)
}

View File

@@ -5,7 +5,7 @@
* for the Security Dashboard implementation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
@@ -15,6 +15,14 @@ import * as crowdsecApi from '../../api/crowdsec'
import * as settingsApi from '../../api/settings'
import { toast } from '../../utils/toast'
const mockSecurityStatus = {
cerberus: { enabled: true },
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
waf: { mode: 'enabled' as const, enabled: true },
rate_limit: { enabled: true },
acl: { enabled: true },
}
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/settings')
@@ -46,6 +54,10 @@ describe('Security Page - QA Security Audit', () => {
},
})
vi.clearAllMocks()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
})
const wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -54,12 +66,10 @@ describe('Security Page - QA Security Audit', () => {
</QueryClientProvider>
)
const mockSecurityStatus = {
cerberus: { enabled: true },
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
waf: { mode: 'enabled' as const, enabled: true },
rate_limit: { enabled: true },
acl: { enabled: true }
const renderSecurityPage = async () => {
await act(async () => {
render(<Security />, { wrapper })
})
}
describe('Input Validation', () => {
@@ -68,7 +78,7 @@ describe('Security Page - QA Security Audit', () => {
// won't execute. This test verifies that property.
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -82,7 +92,7 @@ describe('Security Page - QA Security Audit', () => {
it('handles empty admin whitelist gracefully', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -98,7 +108,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network error'))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
@@ -115,7 +125,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Failed to start'))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
@@ -132,7 +142,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Failed to stop'))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-stop'))
const stopButton = screen.getByTestId('crowdsec-stop')
@@ -148,7 +158,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockRejectedValue(new Error('Export failed'))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
@@ -163,7 +173,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockRejectedValue(new Error('Status check failed'))
render(<Security />, { wrapper })
await renderSecurityPage()
// Page should still render even if status check fails
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
@@ -177,14 +187,14 @@ describe('Security Page - QA Security Audit', () => {
// Never resolving promise to simulate pending state
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-cerberus'))
const toggle = screen.getByTestId('toggle-cerberus')
await waitFor(() => screen.getByTestId('toggle-waf'))
const toggle = screen.getByTestId('toggle-waf')
await user.click(toggle)
// Overlay should appear indicating operation in progress
await waitFor(() => expect(screen.getByText(/Cerberus awakens/i)).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
})
it('prevents double-click on CrowdSec start button', async () => {
@@ -198,7 +208,7 @@ describe('Security Page - QA Security Audit', () => {
return { success: true }
})
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
@@ -208,7 +218,9 @@ describe('Security Page - QA Security Audit', () => {
await user.click(startButton)
// Wait for potential multiple calls
await new Promise(resolve => setTimeout(resolve, 150))
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 150))
})
// Should only be called once due to disabled state
expect(callCount).toBe(1)
@@ -221,7 +233,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -246,7 +258,7 @@ describe('Security Page - QA Security Audit', () => {
it('shows correct layer indicator icons', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -267,7 +279,7 @@ describe('Security Page - QA Security Audit', () => {
}
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -283,11 +295,10 @@ describe('Security Page - QA Security Audit', () => {
it('all toggles have proper test IDs for automation', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
expect(screen.getByTestId('toggle-cerberus')).toBeInTheDocument()
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
expect(screen.getByTestId('toggle-waf')).toBeInTheDocument()
@@ -297,7 +308,7 @@ describe('Security Page - QA Security Audit', () => {
it('WAF controls have proper test IDs when enabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -309,7 +320,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -322,7 +333,7 @@ describe('Security Page - QA Security Audit', () => {
it('pipeline order matches spec: CrowdSec → ACL → WAF → Rate Limiting', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -336,7 +347,7 @@ describe('Security Page - QA Security Audit', () => {
it('layer indicators match spec descriptions', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -350,7 +361,7 @@ describe('Security Page - QA Security Audit', () => {
it('threat summaries match spec when services enabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
@@ -374,7 +385,7 @@ describe('Security Page - QA Security Audit', () => {
() => new Promise(resolve => setTimeout(resolve, 50))
)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-waf'))
@@ -393,7 +404,7 @@ describe('Security Page - QA Security Audit', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue(null as never)
render(<Security />, { wrapper })
await renderSecurityPage()
// Should not crash
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
@@ -46,6 +46,12 @@ describe('Security', () => {
},
})
vi.clearAllMocks()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
vi.spyOn(window, 'open').mockImplementation(() => null)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
})
const wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -54,6 +60,12 @@ describe('Security', () => {
</QueryClientProvider>
)
const renderSecurityPage = async () => {
await act(async () => {
render(<Security />, { wrapper })
})
}
const mockSecurityStatus = {
cerberus: { enabled: true },
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
@@ -63,68 +75,40 @@ describe('Security', () => {
}
describe('Rendering', () => {
it('should show loading state initially', () => {
it('should show loading state initially', async () => {
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
render(<Security />, { wrapper })
await renderSecurityPage()
expect(screen.getByText(/Loading security status/i)).toBeInTheDocument()
})
it('should show error if security status fails to load', async () => {
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Failed'))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Failed to load security status/i)).toBeInTheDocument())
})
it('should render Security Dashboard when status loads', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
})
it('should show banner when Cerberus is disabled', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument())
})
})
describe('Cerberus Toggle', () => {
it('should toggle Cerberus on', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
render(<Security />, { wrapper })
await waitFor(() => screen.getByTestId('toggle-cerberus'))
const toggle = screen.getByTestId('toggle-cerberus')
await user.click(toggle)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'true', 'security', 'bool'))
})
it('should toggle Cerberus off', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
render(<Security />, { wrapper })
await waitFor(() => screen.getByTestId('toggle-cerberus'))
const toggle = screen.getByTestId('toggle-cerberus')
await user.click(toggle)
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'false', 'security', 'bool'))
})
})
describe('Service Toggles', () => {
it('should toggle CrowdSec on', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
const toggle = screen.getByTestId('toggle-crowdsec')
@@ -138,11 +122,13 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, waf: { mode: 'enabled', enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-waf'))
const toggle = screen.getByTestId('toggle-waf')
await user.click(toggle)
await act(async () => {
await user.click(toggle)
})
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
})
@@ -152,7 +138,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, acl: { enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-acl'))
const toggle = screen.getByTestId('toggle-acl')
@@ -166,7 +152,7 @@ describe('Security', () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, rate_limit: { enabled: false } })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
const toggle = screen.getByTestId('toggle-rate-limit')
@@ -179,8 +165,8 @@ describe('Security', () => {
describe('Admin Whitelist', () => {
it('should load admin whitelist from config', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
})
@@ -192,7 +178,7 @@ describe('Security', () => {
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
@@ -212,11 +198,13 @@ describe('Security', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ success: true })
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
await user.click(startButton)
await act(async () => {
await user.click(startButton)
})
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
})
@@ -227,11 +215,13 @@ describe('Security', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-stop'))
const stopButton = screen.getByTestId('crowdsec-stop')
await user.click(stopButton)
await act(async () => {
await user.click(stopButton)
})
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
})
@@ -243,7 +233,7 @@ describe('Security', () => {
window.URL.createObjectURL = vi.fn(() => 'blob:url')
window.URL.revokeObjectURL = vi.fn()
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
const exportButton = screen.getByRole('button', { name: /Export/i })
@@ -264,7 +254,7 @@ describe('Security', () => {
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('waf-mode-select'))
const select = screen.getByTestId('waf-mode-select')
@@ -280,7 +270,7 @@ describe('Security', () => {
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('waf-ruleset-select'))
const select = screen.getByTestId('waf-ruleset-select')
@@ -293,8 +283,8 @@ describe('Security', () => {
describe('Card Order (Pipeline Sequence)', () => {
it('should render cards in correct pipeline order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
// Get all card headings
@@ -307,8 +297,8 @@ describe('Security', () => {
it('should display layer indicators on each card', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
// Verify each layer indicator is present
@@ -320,8 +310,8 @@ describe('Security', () => {
it('should display threat protection summaries', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByText(/Security Dashboard/i))
// Verify threat protection descriptions
@@ -333,26 +323,12 @@ describe('Security', () => {
})
describe('Loading Overlay', () => {
it('should show Cerberus overlay when Cerberus is toggling', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
render(<Security />, { wrapper })
await waitFor(() => screen.getByTestId('toggle-cerberus'))
const toggle = screen.getByTestId('toggle-cerberus')
await user.click(toggle)
await waitFor(() => expect(screen.getByText(/Cerberus awakens/i)).toBeInTheDocument())
})
it('should show overlay when service is toggling', async () => {
const user = userEvent.setup()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('toggle-waf'))
const toggle = screen.getByTestId('toggle-waf')
@@ -367,7 +343,7 @@ describe('Security', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-start'))
const startButton = screen.getByTestId('crowdsec-start')
@@ -382,7 +358,7 @@ describe('Security', () => {
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
render(<Security />, { wrapper })
await renderSecurityPage()
await waitFor(() => screen.getByTestId('crowdsec-stop'))
const stopButton = screen.getByTestId('crowdsec-stop')

View File

@@ -63,7 +63,10 @@ describe('SystemSettings', () => {
'security.cerberus.enabled': 'false',
})
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({})
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': false,
})
vi.mocked(client.get).mockResolvedValue({
data: {
@@ -382,44 +385,53 @@ describe('SystemSettings', () => {
})
})
describe('Optional Features', () => {
it('renders the Optional Features section', async () => {
describe('Features', () => {
it('renders the Features section', async () => {
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('Optional Features')).toBeTruthy()
expect(screen.getByText('Features')).toBeTruthy()
})
})
it('displays Cerberus Security Suite toggle', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': false,
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
expect(screen.getByText('Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.')).toBeTruthy()
})
const cerberusLabel = screen.getByText('Cerberus Security Suite')
const tooltipParent = cerberusLabel.closest('[title]') as HTMLElement
expect(tooltipParent?.getAttribute('title')).toContain('Advanced security features')
})
it('displays Uptime Monitoring toggle', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.uptime.enabled': true,
'feature.cerberus.enabled': false,
})
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
expect(screen.getByText('Monitor the availability of your proxy hosts and remote servers.')).toBeTruthy()
})
const uptimeLabel = screen.getByText('Uptime Monitoring')
const tooltipParent = uptimeLabel.closest('[title]') as HTMLElement
expect(tooltipParent?.getAttribute('title')).toContain('Monitor the availability')
})
it('shows Cerberus toggle as checked when enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
'feature.uptime.enabled': false,
})
renderWithProviders(<SystemSettings />)
@@ -438,6 +450,7 @@ describe('SystemSettings', () => {
it('shows Uptime toggle as checked when enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.uptime.enabled': true,
'feature.cerberus.enabled': false,
})
renderWithProviders(<SystemSettings />)
@@ -455,6 +468,7 @@ describe('SystemSettings', () => {
it('shows Cerberus toggle as unchecked when disabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': false,
})
renderWithProviders(<SystemSettings />)
@@ -472,6 +486,7 @@ describe('SystemSettings', () => {
it('toggles Cerberus feature flag when switch is clicked', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': false,
})
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
@@ -498,6 +513,7 @@ describe('SystemSettings', () => {
it('toggles Uptime feature flag when switch is clicked', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.uptime.enabled': true,
'feature.cerberus.enabled': false,
})
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
@@ -527,10 +543,37 @@ describe('SystemSettings', () => {
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('Optional Features')).toBeTruthy()
expect(screen.getByText('Features')).toBeTruthy()
})
expect(screen.getByText('Loading features...')).toBeTruthy()
})
it('shows loading overlay while toggling a feature flag', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': false,
'feature.uptime.enabled': false,
})
vi.mocked(featureFlagsApi.updateFeatureFlags).mockImplementation(
() => new Promise(() => {})
)
renderWithProviders(<SystemSettings />)
await waitFor(() => {
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
})
const user = userEvent.setup()
const cerberusText = screen.getByText('Cerberus Security Suite')
const parentDiv = cerberusText.closest('.flex')
const switchInput = parentDiv?.querySelector('input[type="checkbox"]') as HTMLInputElement
await user.click(switchInput)
await waitFor(() => {
expect(screen.getByText('Updating features...')).toBeInTheDocument()
})
})
})
})