From 0d5c5083c8142c5dd6efb4a96017d2dac5571497 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 14:14:18 +0000 Subject: [PATCH 01/14] fix: Clarify delegation roles in Management agent documentation --- .github/agents/Managment.agent.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/agents/Managment.agent.md b/.github/agents/Managment.agent.md index adfcad78..28fa85cd 100644 --- a/.github/agents/Managment.agent.md +++ b/.github/agents/Managment.agent.md @@ -14,8 +14,9 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can - `Planning`: The Architect. (Delegate research & planning here). - `Backend_Dev`: The Engineer. (Delegate Go implementation here). - `Frontend_Dev`: The Designer. (Delegate React implementation here). - - `QA_Security`: The Auditor. (Delegate verification here). + - `QA_Security`: The Auditor. (Delegate verification and testing here). - `Docs_Writer`: The Scribe. (Delegate docs here). + - `DevOps`: The Packager. (Delegate CI/CD and infrastructure here). From b9a1cd21e320217963f44486001d869705dd1032 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 14:18:20 +0000 Subject: [PATCH 02/14] fix: Update QA and Security agent documentation for clarity on roles and testing procedures --- .github/agents/QA_Security.agent.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/agents/QA_Security.agent.md b/.github/agents/QA_Security.agent.md index 503f762c..62910888 100644 --- a/.github/agents/QA_Security.agent.md +++ b/.github/agents/QA_Security.agent.md @@ -11,6 +11,7 @@ Your job is to act as an ADVERSARY. The Developer says "it works"; your job is t - **Project**: Charon (Reverse Proxy) - **Priority**: Security, Input Validation, Error Handling. - **Tools**: `go test`, `trivy` (if available), pre-commit, manual edge-case analysis. +- **Role**: You are the final gatekeeper before code reaches production. Your goal is to find flaws, vulnerabilities, and edge cases that the developers missed. You write tests to prove these issues exist. Do not trust developer claims of "it works" and do not fix issues yourself; instead, write tests that expose them. If code needs to be fixed, report back to the Management agent for rework or directly to the appropriate subagent (Backend_Dev or Frontend_Dev) @@ -27,7 +28,8 @@ Your job is to act as an ADVERSARY. The Developer says "it works"; your job is t - **Path Verification**: Run `list_dir internal/api` to verify where tests should go. - **Creation**: Write a new test file (e.g., `internal/api/tests/audit_test.go`) to test the *flow*. - **Run**: Execute `go test ./internal/api/tests/...` (or specific path). Run local CodeQL and Trivy scans (they are built as VS Code Tasks so they just need to be triggered to run), pre-commit all files, and triage any findings. - - Always run run GolangCI-Lint in docker to ensure consistent linting. + - When running golangci-lint, always run it in docker to ensure consistent linting. + - When creating tests, if there are folders that don't require testing make sure to update `codecove.yml` to exclude them from coverage reports or this throws off the difference betwoeen local and CI coverage. - **Cleanup**: If the test was temporary, delete it. If it's valuable, keep it. From 31936906bf7bf7280fc6fa09e8af778167f3d1db Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 14:35:28 +0000 Subject: [PATCH 03/14] fix: Enhance delegation prompt in Management agent documentation for improved planning and file review --- .github/agents/Managment.agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/agents/Managment.agent.md b/.github/agents/Managment.agent.md index 28fa85cd..f01e659e 100644 --- a/.github/agents/Managment.agent.md +++ b/.github/agents/Managment.agent.md @@ -25,7 +25,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can - **Identify Goal**: Understand the user's request. - **STOP**: Do not look at the code. Do not run `list_dir`. - **Action**: Immediately call `Planning` subagent. - - *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible." + - *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Review and suggest updaetes to `.gitignore`, `codecove.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete." 2. **Phase 2: Approval Gate**: - **Read Plan**: Read `docs/plans/current_spec.md` (You are allowed to read Markdown). From 9c6912fc85d8da82de3a4f9bcc98575cef7058e5 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 14:38:14 +0000 Subject: [PATCH 04/14] fix: Clarify delegation process in Management agent documentation to ensure user approval before code changes --- .github/agents/Managment.agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/agents/Managment.agent.md b/.github/agents/Managment.agent.md index f01e659e..6201ef3f 100644 --- a/.github/agents/Managment.agent.md +++ b/.github/agents/Managment.agent.md @@ -23,7 +23,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can 1. **Phase 1: Assessment & Delegation (NO RESEARCH)**: - **Read Instructions**: Read `.github/copilot-instructions.md`. - **Identify Goal**: Understand the user's request. - - **STOP**: Do not look at the code. Do not run `list_dir`. + - **STOP**: Do not look at the code. Do not run `list_dir`. No code is to be changed or implemented until there is a fundamentally sound plan of action that has been approved by the user. - **Action**: Immediately call `Planning` subagent. - *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Review and suggest updaetes to `.gitignore`, `codecove.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete." From bd520be64eca84da6592422864c19e5efe8f6491 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 15:08:01 +0000 Subject: [PATCH 05/14] fix: spelling error in Agent name --- .github/agents/{Managment.agent.md => Manegment.agent.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/agents/{Managment.agent.md => Manegment.agent.md} (98%) diff --git a/.github/agents/Managment.agent.md b/.github/agents/Manegment.agent.md similarity index 98% rename from .github/agents/Managment.agent.md rename to .github/agents/Manegment.agent.md index 6201ef3f..5314f7e6 100644 --- a/.github/agents/Managment.agent.md +++ b/.github/agents/Manegment.agent.md @@ -20,7 +20,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can -1. **Phase 1: Assessment & Delegation (NO RESEARCH)**: +1. **Phase 1: Assessment and Delegation**: - **Read Instructions**: Read `.github/copilot-instructions.md`. - **Identify Goal**: Understand the user's request. - **STOP**: Do not look at the code. Do not run `list_dir`. No code is to be changed or implemented until there is a fundamentally sound plan of action that has been approved by the user. From 83e6cbb84897f36b2ab9ade792111fde0e7e736a Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 15:24:01 +0000 Subject: [PATCH 06/14] fix: Add task specifics for direct audits and tests in Management agent documentation --- .github/agents/Manegment.agent.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/agents/Manegment.agent.md b/.github/agents/Manegment.agent.md index 5314f7e6..8479f50b 100644 --- a/.github/agents/Manegment.agent.md +++ b/.github/agents/Manegment.agent.md @@ -26,7 +26,8 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can - **STOP**: Do not look at the code. Do not run `list_dir`. No code is to be changed or implemented until there is a fundamentally sound plan of action that has been approved by the user. - **Action**: Immediately call `Planning` subagent. - *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Review and suggest updaetes to `.gitignore`, `codecove.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete." - + - **Task Specifics**: + - If the task is to just run tests or audits, there is no need for a plan. Directly call `QA_Security` to perform the tests and write the report. If issues are found, return to `Planning` for a remediation plan and delegate the fixes to the corresponding subagents. 2. **Phase 2: Approval Gate**: - **Read Plan**: Read `docs/plans/current_spec.md` (You are allowed to read Markdown). - **Present**: Summarize the plan to the user. From 856903b21dc85d5e4673256474913c99da0ee07e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 15:41:18 +0000 Subject: [PATCH 07/14] 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. --- docs/plans/current_spec.md | 461 +----------------- docs/reports/qa_report.md | 344 ++----------- frontend/src/pages/Security.tsx | 41 +- frontend/src/pages/SystemSettings.tsx | 111 +++-- .../pages/__tests__/Security.audit.test.tsx | 75 +-- .../src/pages/__tests__/Security.test.tsx | 116 ++--- .../pages/__tests__/SystemSettings.test.tsx | 57 ++- 7 files changed, 256 insertions(+), 949 deletions(-) diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index d50a1956..8c612967 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -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: 1–2 hours (low complexity) -- Add .vscode tasks/settings and Makefile snippet: 30–60 minutes -- Add CI job and test in GH Actions: 1 hour -- QA validation and follow-ups: 1–2 hours - -Total: 3–6 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`. diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 895986b0..c4de668b 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -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. diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index e48130c5..dafe5e72 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -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() { Security Dashboard -
- - toggleCerberusMutation.mutate(e.target.checked)} - data-testid="toggle-cerberus" - /> -
+
+ Disabled + handleModeToggle(e.target.checked)} + disabled={updateModeMutation.isPending} + data-testid="crowdsec-mode-toggle" + /> + Local
-

Import Configuration

- setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" /> -
- +
+

Configuration Packages

+
+ + +
+

Import or export CrowdSec configuration packages. A backup is created before imports.

+ setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" /> +
+ + + +
+
+
+

CrowdSec Presets

+

Select a curated preset, preview it, then apply with an automatic backup.

+
+
+ + + +
+
+ + {validationError && ( +

{validationError}

+ )} + + {presetStatusMessage && ( +

{presetStatusMessage}

+ )} + + {hubUnavailable && ( +
+ Hub unreachable. Retry pull or load cached copy if available. + + {selectedPreset?.cached && ( + + )} +
+ )} + + {selectedPreset && ( +
+
+

{selectedPreset.title}

+

{selectedPreset.description}

+ {selectedPreset.warning && ( +

{selectedPreset.warning}

+ )} +

Target file: {selectedPath ?? 'Select a file below (used for local fallback)'}

+
+ {presetMeta && ( +
+ Cache key: {presetMeta.cacheKey || '—'} + Etag: {presetMeta.etag || '—'} + Source: {presetMeta.source || selectedPreset.source || '—'} + Fetched: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'} +
+ )} +
+

Preset preview (YAML)

+
+                  {presetPreview || selectedPreset.content || 'Preview unavailable. Pull from hub or use cached copy.'}
+                
+
+ + {applyInfo && ( +
+

Status: {applyInfo.status || 'applied'}

+ {applyInfo.backup &&

Backup: {applyInfo.backup}

} + {applyInfo.reloadHint &&

Reload: {applyInfo.reloadHint}

} + {applyInfo.usedCscli !== undefined &&

Method: {applyInfo.usedCscli ? 'cscli' : 'filesystem'}

} +
+ )} + +
+ {selectedPreset.cached && ( + + )} + {selectedPresetRequiresHub && hubUnavailable && ( + Apply disabled while hub is offline. + )} +
+
+ )} + + {presetCatalog.length === 0 && ( +

No presets available. Ensure Cerberus is enabled.

+ )}
@@ -206,7 +570,12 @@ export default function CrowdSecConfig() {

Edit Configuration Files

- handleReadFile(e.target.value)} + data-testid="crowdsec-file-select" + > {listMutation.data?.files?.map((f) => ( diff --git a/frontend/src/pages/ImportCrowdSec.tsx b/frontend/src/pages/ImportCrowdSec.tsx index be733970..b6c34cbf 100644 --- a/frontend/src/pages/ImportCrowdSec.tsx +++ b/frontend/src/pages/ImportCrowdSec.tsx @@ -47,11 +47,11 @@ export default function ImportCrowdSec() { return (
-

Import CrowdSec

+

CrowdSec Configuration Packages

-

Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.

- +

Upload a tar.gz or zip package. A backup is created before importing so you can roll back if needed. Export the current package from the Cerberus dashboard or CrowdSec config page.

+
diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index dafe5e72..9c74c89e 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -11,6 +11,7 @@ import { toast } from '../utils/toast' import { Card } from '../components/ui/Card' import { Button } from '../components/ui/Button' import { ConfigReloadOverlay } from '../components/LoadingStates' +import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport' export default function Security() { const navigate = useNavigate() @@ -78,27 +79,76 @@ export default function Security() { useEffect(() => { fetchCrowdsecStatus() }, []) - const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) }) - const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) }) + const handleCrowdsecExport = async () => { + const defaultName = buildCrowdsecExportFilename() + const filename = promptCrowdsecFilename(defaultName) + if (!filename) return + + try { + const resp = await exportCrowdsecConfig() + downloadCrowdsecExport(resp, filename) + toast.success('CrowdSec configuration exported') + } catch { + toast.error('Failed to export CrowdSec configuration') + } + } + + const crowdsecPowerMutation = useMutation({ + mutationFn: async (enabled: boolean) => { + await updateSetting('security.crowdsec.enabled', enabled ? 'true' : 'false', 'security', 'bool') + if (enabled) { + await startCrowdsec() + } else { + await stopCrowdsec() + } + return enabled + }, + onMutate: async (enabled: boolean) => { + await queryClient.cancelQueries({ queryKey: ['security-status'] }) + const previous = queryClient.getQueryData(['security-status']) + queryClient.setQueryData(['security-status'], (old: unknown) => { + if (!old || typeof old !== 'object') return old + const copy = { ...(old as SecurityStatus) } + if (copy.crowdsec && typeof copy.crowdsec === 'object') { + copy.crowdsec = { ...copy.crowdsec, enabled } as never + } + return copy + }) + setCrowdsecStatus(prev => prev ? { ...prev, running: enabled } : prev) + return { previous } + }, + onError: (err: unknown, enabled: boolean, context: unknown) => { + if (context && typeof context === 'object' && 'previous' in context) { + queryClient.setQueryData(['security-status'], context.previous) + } + const msg = err instanceof Error ? err.message : String(err) + toast.error(enabled ? `Failed to start CrowdSec: ${msg}` : `Failed to stop CrowdSec: ${msg}`) + fetchCrowdsecStatus() + }, + onSuccess: async (enabled: boolean) => { + await fetchCrowdsecStatus() + queryClient.invalidateQueries({ queryKey: ['security-status'] }) + queryClient.invalidateQueries({ queryKey: ['settings'] }) + toast.success(enabled ? 'CrowdSec started' : 'CrowdSec stopped') + }, + }) // Determine if any security operation is in progress const isApplyingConfig = toggleServiceMutation.isPending || updateSecurityConfigMutation.isPending || generateBreakGlassMutation.isPending || - startMutation.isPending || - stopMutation.isPending + crowdsecPowerMutation.isPending // Determine contextual message const getMessage = () => { if (toggleServiceMutation.isPending) { - return { message: 'Three heads turn...', submessage: 'Security configuration updating' } + return { message: 'Three heads turn...', submessage: 'Cerberus configuration updating' } } - if (startMutation.isPending) { - return { message: 'Summoning the guardian...', submessage: 'Intrusion prevention rising' } - } - if (stopMutation.isPending) { - return { message: 'Guardian rests...', submessage: 'Intrusion prevention pausing' } + if (crowdsecPowerMutation.isPending) { + return crowdsecPowerMutation.variables + ? { message: 'Summoning the guardian...', submessage: 'CrowdSec is starting' } + : { message: 'Guardian rests...', submessage: 'CrowdSec is stopping' } } return { message: 'Strengthening the guard...', submessage: 'Protective wards activating' } } @@ -113,6 +163,10 @@ export default function Security() { return
Failed to load security status
} + const cerberusDisabled = !status.cerberus?.enabled + const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending + const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending + // const suiteDisabled = !(status?.cerberus?.enabled ?? false) // Replace the previous early-return that instructed enabling via env vars. @@ -121,10 +175,10 @@ export default function Security() {
-

Security Suite Disabled

+

Cerberus Disabled

- Charon supports advanced security features (CrowdSec, WAF, ACLs, Rate Limiting). Enable the global Cerberus toggle in System Settings and activate individual services below. + Cerberus powers CrowdSec, WAF, ACLs, and Rate Limiting. Enable the Cerberus toggle in System Settings to awaken the guardian, then configure each head below.

- - - - -
- )} +
+ + + +
diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx index bfef1290..373b22ab 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { AxiosError } from 'axios' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -8,11 +9,14 @@ import * as api from '../../api/security' import * as crowdsecApi from '../../api/crowdsec' import * as backupsApi from '../../api/backups' import * as settingsApi from '../../api/settings' +import * as presetsApi from '../../api/presets' +import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets' vi.mock('../../api/security') vi.mock('../../api/crowdsec') vi.mock('../../api/backups') vi.mock('../../api/settings') +vi.mock('../../api/presets') const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) const renderWithProviders = (ui: React.ReactNode) => { @@ -27,13 +31,46 @@ const renderWithProviders = (ui: React.ReactNode) => { } describe('CrowdSecConfig', () => { - beforeEach(() => vi.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ + presets: CROWDSEC_PRESETS.map((preset) => ({ + slug: preset.slug, + title: preset.title, + summary: preset.description, + source: 'charon', + requires_hub: false, + available: true, + cached: false, + })), + }) + vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({ + status: 'pulled', + slug: 'bot-mitigation-essentials', + preview: CROWDSEC_PRESETS[0].content, + cache_key: 'cache-123', + etag: 'etag-123', + retrieved_at: '2024-01-01T00:00:00Z', + source: 'hub', + }) + vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ + status: 'applied', + backup: '/tmp/backup.tar.gz', + reload_hint: 'CrowdSec reloaded', + used_cscli: true, + cache_key: 'cache-123', + slug: 'bot-mitigation-essentials', + }) + vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' }) + vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] }) + }) it('exports config when clicking Export', async () => { vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } }) vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) const blob = new Blob(['dummy']) vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob) + vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export') renderWithProviders() await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument()) const exportBtn = screen.getByText('Export') @@ -69,8 +106,7 @@ describe('CrowdSecConfig', () => { await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument()) // wait for file list await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument()) - const selects = screen.getAllByRole('combobox') - const select = selects[1] + const select = screen.getByTestId('crowdsec-file-select') await userEvent.selectOptions(select, 'conf.d/a.conf') await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf')) // ensure textarea populated @@ -93,9 +129,123 @@ describe('CrowdSecConfig', () => { renderWithProviders() await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument()) - const selects = screen.getAllByRole('combobox') - const modeSelect = selects[0] - await userEvent.selectOptions(modeSelect, 'local') + const modeToggle = screen.getByTestId('crowdsec-mode-toggle') + await userEvent.click(modeToggle) await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string')) }) + + it('renders preset preview and applies with backup when backend apply is unavailable', async () => { + const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } } + const presetContent = CROWDSEC_PRESETS.find((preset) => preset.slug === 'bot-mitigation-essentials')?.content || '' + vi.mocked(api.getSecurityStatus).mockResolvedValue(status) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] }) + vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' }) + vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' }) + vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' }) + const axiosError = new AxiosError('not implemented', undefined, undefined, undefined, { + status: 501, + statusText: 'Not Implemented', + headers: {}, + config: {}, + data: {}, + } as any) + vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue(axiosError) + + renderWithProviders() + await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('configs:')) + const fileSelect = screen.getByTestId('crowdsec-file-select') + await userEvent.selectOptions(fileSelect, 'acquis.yaml') + const applyBtn = screen.getByTestId('apply-preset-btn') + await userEvent.click(applyBtn) + + await waitFor(() => expect(presetsApi.applyCrowdsecPreset).toHaveBeenCalledWith({ slug: 'bot-mitigation-essentials', cache_key: 'cache-123' })) + await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled()) + await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', presetContent)) + }) + + it('surfaces validation error when slug is invalid', async () => { + const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } } + vi.mocked(api.getSecurityStatus).mockResolvedValue(status) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + const validationError = new AxiosError('invalid', undefined, undefined, undefined, { + status: 400, + statusText: 'Bad Request', + headers: {}, + config: {}, + data: { error: 'slug invalid' }, + } as any) + vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(validationError) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('slug invalid')) + }) + + it('disables apply and offers cached preview when hub is unavailable', async () => { + const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } } + vi.mocked(api.getSecurityStatus).mockResolvedValue(status) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({ + presets: [ + { + slug: 'hub-only', + title: 'Hub Only', + summary: 'Needs hub', + source: 'hub', + requires_hub: true, + available: true, + cached: true, + cache_key: 'cache-hub', + etag: 'etag-hub', + }, + ], + }) + const hubError = new AxiosError('unavailable', undefined, undefined, undefined, { + status: 503, + statusText: 'Service Unavailable', + headers: {}, + config: {}, + data: { error: 'hub service unavailable' }, + } as any) + vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValue(hubError) + vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-hub', etag: 'etag-hub' }) + + renderWithProviders() + + const select = await screen.findByTestId('preset-select') + await waitFor(() => expect(screen.getByText('Hub Only')).toBeInTheDocument()) + await userEvent.selectOptions(select, 'hub-only') + + await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument()) + + const applyBtn = screen.getByTestId('apply-preset-btn') as HTMLButtonElement + expect(applyBtn.disabled).toBe(true) + + await userEvent.click(screen.getByText('Use cached preview')) + await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview')) + }) + + it('shows apply response metadata including backup path', async () => { + const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } } + vi.mocked(api.getSecurityStatus).mockResolvedValue(status) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] }) + vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' }) + vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({ + status: 'applied', + backup: '/tmp/crowdsec-backup', + reload_hint: 'crowdsec reloaded', + used_cscli: true, + cache_key: 'cache-123', + slug: 'bot-mitigation-essentials', + }) + + renderWithProviders() + + const applyBtn = await screen.findByTestId('apply-preset-btn') + await userEvent.click(applyBtn) + + await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup')) + expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('crowdsec reloaded') + }) }) diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx new file mode 100644 index 00000000..a3bbb7de --- /dev/null +++ b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import CrowdSecConfig from '../CrowdSecConfig' +import * as securityApi from '../../api/security' +import * as crowdsecApi from '../../api/crowdsec' +import * as backupsApi from '../../api/backups' +import * as settingsApi from '../../api/settings' +import * as presetsApi from '../../api/presets' +import { toast } from '../../utils/toast' + +vi.mock('../../api/security') +vi.mock('../../api/crowdsec') +vi.mock('../../api/backups') +vi.mock('../../api/settings') +vi.mock('../../api/presets') +vi.mock('../../utils/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +describe('CrowdSecConfig', () => { + const createClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + const renderWithProviders = () => { + const queryClient = createClient() + return render( + + + + + + ) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ + cerberus: { enabled: true }, + crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true }, + waf: { mode: 'enabled', enabled: true }, + rate_limit: { enabled: true }, + acl: { enabled: true }, + }) + vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] }) + vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' }) + vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({}) + vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] }) + vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data'])) + vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({}) + vi.mocked(settingsApi.updateSetting).mockResolvedValue() + vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' }) + vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] }) + vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({ + status: 'pulled', + slug: 'bot-mitigation-essentials', + preview: 'configs: {}', + cache_key: 'cache-123', + }) + vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', backup: '/tmp/backup.tar.gz', cache_key: 'cache-123' }) + vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'configs: {}', cache_key: 'cache-123' }) + vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz') + window.URL.createObjectURL = vi.fn(() => 'blob:url') + window.URL.revokeObjectURL = vi.fn() + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + it('toggles mode between local and disabled', async () => { + renderWithProviders() + + await waitFor(() => screen.getByTestId('crowdsec-mode-toggle')) + const toggle = screen.getByTestId('crowdsec-mode-toggle') + + await userEvent.click(toggle) + + await waitFor(() => { + expect(settingsApi.updateSetting).toHaveBeenCalledWith( + 'security.crowdsec.mode', + 'disabled', + 'security', + 'string' + ) + expect(toast.success).toHaveBeenCalledWith('CrowdSec disabled') + }) + }) + + it('exports configuration packages with prompted filename', async () => { + renderWithProviders() + + await waitFor(() => screen.getByRole('button', { name: /Export/i })) + const exportButton = screen.getByRole('button', { name: /Export/i }) + + await userEvent.click(exportButton) + + await waitFor(() => { + expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported') + }) + }) + + it('shows Configuration Packages heading', async () => { + renderWithProviders() + + await waitFor(() => screen.getByText('Configuration Packages')) + + expect(screen.getByText('Configuration Packages')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx new file mode 100644 index 00000000..db4308c3 --- /dev/null +++ b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import ImportCrowdSec from '../ImportCrowdSec' +import * as crowdsecApi from '../../api/crowdsec' +import * as backupsApi from '../../api/backups' +import { toast } from 'react-hot-toast' + +vi.mock('../../api/crowdsec') +vi.mock('../../api/backups') +vi.mock('react-hot-toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})) + +describe('ImportCrowdSec', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' }) + vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({}) + }) + + const renderPage = () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + + + ) + } + + it('renders configuration packages heading', async () => { + renderPage() + + await waitFor(() => screen.getByText('CrowdSec Configuration Packages')) + expect(screen.getByText('CrowdSec Configuration Packages')).toBeInTheDocument() + }) + + it('creates a backup before importing selected package', async () => { + renderPage() + + const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement + const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' }) + + await userEvent.upload(fileInput, file) + + const importButton = screen.getByRole('button', { name: /Import/i }) + await userEvent.click(importButton) + + await waitFor(() => { + expect(backupsApi.createBackup).toHaveBeenCalled() + expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalledWith(file) + expect(toast.success).toHaveBeenCalledWith('CrowdSec config imported') + }) + }) +}) diff --git a/frontend/src/pages/__tests__/Security.audit.test.tsx b/frontend/src/pages/__tests__/Security.audit.test.tsx index af79c009..5e1719df 100644 --- a/frontend/src/pages/__tests__/Security.audit.test.tsx +++ b/frontend/src/pages/__tests__/Security.audit.test.tsx @@ -2,7 +2,7 @@ * Security Page - QA Security Audit Tests * * Tests edge cases, input validation, error states, and security concerns - * for the Security Dashboard implementation. + * for the Cerberus Dashboard implementation. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { act, render, screen, waitFor } from '@testing-library/react' @@ -58,6 +58,8 @@ describe('Security Page - QA Security Audit', () => { vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false }) vi.mocked(settingsApi.updateSetting).mockResolvedValue() vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob()) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz') }) const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -80,7 +82,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // DOM should not contain any actual script elements from user input expect(document.querySelectorAll('script[src*="alert"]').length).toBe(0) @@ -94,7 +96,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // Empty whitelist input should exist and be empty const whitelistInput = screen.getByDisplayValue('') @@ -115,21 +117,24 @@ describe('Security Page - QA Security Audit', () => { await user.click(toggle) await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting')) + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec')) }) }) it('handles CrowdSec start failure gracefully', async () => { const user = userEvent.setup() - vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ + ...mockSecurityStatus, + crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false }, + }) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false }) vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Failed to start')) await renderSecurityPage() - await waitFor(() => screen.getByTestId('crowdsec-start')) - const startButton = screen.getByTestId('crowdsec-start') - await user.click(startButton) + await waitFor(() => screen.getByTestId('toggle-crowdsec')) + const toggle = screen.getByTestId('toggle-crowdsec') + await user.click(toggle) await waitFor(() => { expect(toast.error).toHaveBeenCalled() @@ -144,9 +149,9 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByTestId('crowdsec-stop')) - const stopButton = screen.getByTestId('crowdsec-stop') - await user.click(stopButton) + await waitFor(() => screen.getByTestId('toggle-crowdsec')) + const toggle = screen.getByTestId('toggle-crowdsec') + await user.click(toggle) await waitFor(() => { expect(toast.error).toHaveBeenCalled() @@ -176,7 +181,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() // Page should still render even if status check fails - await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()) }) }) @@ -197,9 +202,12 @@ describe('Security Page - QA Security Audit', () => { await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument()) }) - it('prevents double-click on CrowdSec start button', async () => { + it('prevents double toggle when starting CrowdSec', async () => { const user = userEvent.setup() - vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ + ...mockSecurityStatus, + crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false }, + }) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false }) let callCount = 0 vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(async () => { @@ -210,12 +218,12 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByTestId('crowdsec-start')) - const startButton = screen.getByTestId('crowdsec-start') + await waitFor(() => screen.getByTestId('toggle-crowdsec')) + const toggle = screen.getByTestId('toggle-crowdsec') // Double click - await user.click(startButton) - await user.click(startButton) + await user.click(toggle) + await user.click(toggle) // Wait for potential multiple calls await act(async () => { @@ -235,7 +243,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // Get initial card order const initialCards = screen.getAllByRole('heading', { level: 3 }) @@ -260,7 +268,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // Each layer should have correct emoji expect(screen.getByText(/🛡️ Layer 1/)).toBeInTheDocument() @@ -281,7 +289,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // All 4 cards should be present expect(screen.getByText('CrowdSec')).toBeInTheDocument() @@ -297,7 +305,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument() expect(screen.getByTestId('toggle-acl')).toBeInTheDocument() @@ -310,22 +318,25 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) expect(screen.getByTestId('waf-mode-select')).toBeInTheDocument() expect(screen.getByTestId('waf-ruleset-select')).toBeInTheDocument() }) - it('CrowdSec buttons have proper test IDs when enabled', async () => { + it('CrowdSec controls surface primary actions when enabled', async () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false }) await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) - expect(screen.getByTestId('crowdsec-start')).toBeInTheDocument() - expect(screen.getByTestId('crowdsec-stop')).toBeInTheDocument() + expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Logs/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument() + const configButtons = screen.getAllByRole('button', { name: /Config/i }) + expect(configButtons.some(btn => btn.textContent === 'Config')).toBe(true) }) }) @@ -335,7 +346,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) const cards = screen.getAllByRole('heading', { level: 3 }) const cardNames = cards.map(card => card.textContent) @@ -349,7 +360,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // From spec: Layer 1: IP Reputation, Layer 2: Access Control, Layer 3: Request Inspection, Layer 4: Volume Control expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument() @@ -363,7 +374,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // From spec: // CrowdSec: "Known attackers, botnets, brute-force attempts" @@ -397,7 +408,7 @@ describe('Security Page - QA Security Audit', () => { } // Page should still be functional - await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()) }) it('handles undefined crowdsec status gracefully', async () => { @@ -407,7 +418,7 @@ describe('Security Page - QA Security Audit', () => { await renderSecurityPage() // Should not crash - await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()) }) }) }) diff --git a/frontend/src/pages/__tests__/Security.spec.tsx b/frontend/src/pages/__tests__/Security.spec.tsx index aba8b842..30c6acdb 100644 --- a/frontend/src/pages/__tests__/Security.spec.tsx +++ b/frontend/src/pages/__tests__/Security.spec.tsx @@ -63,7 +63,7 @@ describe('Security page', () => { } as SecurityStatus) renderWithProviders() - expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument() + expect(await screen.findByText('Cerberus Disabled')).toBeInTheDocument() const docBtns = screen.getAllByText('Documentation') expect(docBtns.length).toBeGreaterThan(0) }) @@ -80,14 +80,9 @@ describe('Security page', () => { vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined) renderWithProviders() - await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument()) - const crowdsecToggle = screen.getByTestId('toggle-crowdsec') - // debug: ensure element state - console.log('crowdsecToggle disabled:', (crowdsecToggle as HTMLInputElement).disabled) - expect(crowdsecToggle).toBeTruthy() - // Ensure the toggle exists and is not disabled - expect(crowdsecToggle).toBeTruthy() - expect((crowdsecToggle as HTMLInputElement).disabled).toBe(false) + await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument()) + const crowdsecToggle = screen.getByTestId('toggle-crowdsec') as HTMLInputElement + expect(crowdsecToggle.disabled).toBe(false) // Ensure enable-all controls were removed expect(screen.queryByTestId('enable-all-btn')).toBeNull() }) @@ -103,7 +98,7 @@ describe('Security page', () => { vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus) const updateSpy = vi.mocked(settingsApi.updateSetting) renderWithProviders() - await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument()) const aclToggle = screen.getByTestId('toggle-acl') await userEvent.click(aclToggle) await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool')) @@ -120,42 +115,47 @@ describe('Security page', () => { vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus) const blob = new Blob(['dummy']) vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob) + vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export') + renderWithProviders() - await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument()) const exportBtn = screen.getByText('Export') await userEvent.click(exportBtn) await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()) }) - it('calls start/stop endpoints for CrowdSec', async () => { - const status: SecurityStatus = { + it('calls start/stop endpoints for CrowdSec via toggle', async () => { + const user = userEvent.setup() + const baseStatus: SecurityStatus = { cerberus: { enabled: true }, - crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, + crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false }, } - vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus) - // Test start - vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined) + + vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false }) + vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined) + vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined) + renderWithProviders() - await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument()) - const startBtn = screen.getByText('Start') - await userEvent.click(startBtn) + await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument()) + const toggle = screen.getByTestId('toggle-crowdsec') + await user.click(toggle) await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()) - // Cleanup before re-render to avoid multiple DOM instances + cleanup() - // Test stop: render with running state and click stop - vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined) + const enabledStatus: SecurityStatus = { ...baseStatus, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' } } + vi.mocked(api.getSecurityStatus).mockResolvedValue(enabledStatus as SecurityStatus) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123 }) + vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined) + renderWithProviders() - await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument()) - await waitFor(() => expect(screen.getByText('Stop')).toBeInTheDocument()) - const stopBtn = screen.getAllByText('Stop').find(b => !b.hasAttribute('disabled')) - if (!stopBtn) throw new Error('No enabled Stop button found') - await userEvent.click(stopBtn) + await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument()) + const stopToggle = screen.getByTestId('toggle-crowdsec') + await user.click(stopToggle) await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()) }) @@ -169,7 +169,7 @@ describe('Security page', () => { } vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus) renderWithProviders() - await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Cerberus Disabled')).toBeInTheDocument()) const crowdsecToggle = screen.getByTestId('toggle-crowdsec') expect(crowdsecToggle).toBeDisabled() }) @@ -325,7 +325,7 @@ describe('Security page', () => { vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets) renderWithProviders() - await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument()) // Mode selector and ruleset selector should not be visible expect(screen.queryByTestId('waf-mode-select')).not.toBeInTheDocument() diff --git a/frontend/src/pages/__tests__/Security.test.tsx b/frontend/src/pages/__tests__/Security.test.tsx index 4b2fe7f3..4e2baddf 100644 --- a/frontend/src/pages/__tests__/Security.test.tsx +++ b/frontend/src/pages/__tests__/Security.test.tsx @@ -52,6 +52,7 @@ describe('Security', () => { vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob()) vi.spyOn(window, 'open').mockImplementation(() => null) vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz') }) const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -89,16 +90,16 @@ describe('Security', () => { await waitFor(() => expect(screen.getByText(/Failed to load security status/i)).toBeInTheDocument()) }) - it('should render Security Dashboard when status loads', async () => { + it('should render Cerberus Dashboard when status loads', async () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) await renderSecurityPage() - await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()) }) it('should show banner when Cerberus is disabled', async () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } }) await renderSecurityPage() - await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText(/Cerberus Disabled/i)).toBeInTheDocument()) }) }) @@ -192,24 +193,30 @@ describe('Security', () => { }) describe('CrowdSec Controls', () => { - it('should start CrowdSec', async () => { + it('should start CrowdSec when toggling on', async () => { const user = userEvent.setup() - vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ + ...mockSecurityStatus, + crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false }, + }) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false }) vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ success: true }) await renderSecurityPage() - await waitFor(() => screen.getByTestId('crowdsec-start')) - const startButton = screen.getByTestId('crowdsec-start') + await waitFor(() => screen.getByTestId('toggle-crowdsec')) + const toggle = screen.getByTestId('toggle-crowdsec') await act(async () => { - await user.click(startButton) + await user.click(toggle) }) - await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()) + await waitFor(() => { + expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool') + expect(crowdsecApi.startCrowdsec).toHaveBeenCalled() + }) }) - it('should stop CrowdSec', async () => { + it('should stop CrowdSec when toggling off', async () => { const user = userEvent.setup() vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 }) @@ -217,13 +224,16 @@ describe('Security', () => { await renderSecurityPage() - await waitFor(() => screen.getByTestId('crowdsec-stop')) - const stopButton = screen.getByTestId('crowdsec-stop') + await waitFor(() => screen.getByTestId('toggle-crowdsec')) + const toggle = screen.getByTestId('toggle-crowdsec') await act(async () => { - await user.click(stopButton) + await user.click(toggle) }) - await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()) + await waitFor(() => { + expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'false', 'security', 'bool') + expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled() + }) }) it('should export CrowdSec config', async () => { @@ -285,7 +295,7 @@ describe('Security', () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // Get all card headings const cards = screen.getAllByRole('heading', { level: 3 }) @@ -299,7 +309,7 @@ describe('Security', () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // Verify each layer indicator is present expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument() @@ -312,7 +322,7 @@ describe('Security', () => { vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) await renderSecurityPage() - await waitFor(() => screen.getByText(/Security Dashboard/i)) + await waitFor(() => screen.getByText(/Cerberus Dashboard/i)) // Verify threat protection descriptions expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument() @@ -339,15 +349,18 @@ describe('Security', () => { it('should show overlay when starting CrowdSec', async () => { const user = userEvent.setup() - vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus) + vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ + ...mockSecurityStatus, + crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false }, + }) vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false }) vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {})) await renderSecurityPage() - await waitFor(() => screen.getByTestId('crowdsec-start')) - const startButton = screen.getByTestId('crowdsec-start') - await user.click(startButton) + await waitFor(() => screen.getByTestId('toggle-crowdsec')) + const toggle = screen.getByTestId('toggle-crowdsec') + await user.click(toggle) await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument()) }) @@ -360,9 +373,9 @@ describe('Security', () => { await renderSecurityPage() - await waitFor(() => screen.getByTestId('crowdsec-stop')) - const stopButton = screen.getByTestId('crowdsec-stop') - await user.click(stopButton) + await waitFor(() => screen.getByTestId('toggle-crowdsec')) + const toggle = screen.getByTestId('toggle-crowdsec') + await user.click(toggle) await waitFor(() => expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument()) }) diff --git a/frontend/src/utils/crowdsecExport.ts b/frontend/src/utils/crowdsecExport.ts new file mode 100644 index 00000000..b07fcfdf --- /dev/null +++ b/frontend/src/utils/crowdsecExport.ts @@ -0,0 +1,24 @@ +export const buildCrowdsecExportFilename = (): string => { + const timestamp = new Date().toISOString().replace(/:/g, '-') + return `crowdsec-export-${timestamp}.tar.gz` +} + +export const promptCrowdsecFilename = (defaultName = buildCrowdsecExportFilename()): string | null => { + const input = window.prompt('Name your CrowdSec export archive', defaultName) + if (input === null || typeof input === 'undefined') return null + const trimmed = typeof input === 'string' ? input.trim() : '' + const candidate = trimmed || defaultName + const sanitized = candidate.replace(/[\\/]+/g, '-').replace(/\s+/g, '-') + return sanitized.toLowerCase().endsWith('.tar.gz') ? sanitized : `${sanitized}.tar.gz` +} + +export const downloadCrowdsecExport = (blob: Blob, filename: string) => { + const url = window.URL.createObjectURL(new Blob([blob])) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(url) +} From 9e846bc1dda6ef39d02f07924ed1e0176339b789 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 21:03:35 +0000 Subject: [PATCH 11/14] fix: update definition of done to include frontend coverage tests in completion criteria --- .github/agents/Manegment.agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/agents/Manegment.agent.md b/.github/agents/Manegment.agent.md index 24d19283..435a71a6 100644 --- a/.github/agents/Manegment.agent.md +++ b/.github/agents/Manegment.agent.md @@ -45,7 +45,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can ## DEFENITION OF DONE ## - - The Task is not complete until pre-commit and security scans pass with zero issues. Leaving this unfinished prevents commit and push. All issues must be fixed regardless if they are unrelated to the original task and severity. This rule must never be skipped. It is non-negotiable anytime any bit of code is added or changed. + - The Task is not complete until pre-commit, frontend coverage tests, all linting, and security scans pass with zero issues. Leaving this unfinished prevents commit and push. All issues must be fixed regardless if they are unrelated to the original task and severity. This rule must never be skipped. It is non-negotiable anytime any bit of code is added or changed. - **SOURCE CODE BAN**: You are FORBIDDEN from reading `.go`, `.tsx`, `.ts`, or `.css` files. You may ONLY read `.md` (Markdown) files. From 4c21e977f34032aa5383c70cc48c336c1d6980d2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:51:49 +0000 Subject: [PATCH 12/14] chore(deps): update npm minor/patch to ^8.49.0 (#333) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- frontend/package-lock.json | 135 ++++++++++++++++++------------------- frontend/package.json | 6 +- 2 files changed, 67 insertions(+), 74 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 642aaead..6e44bac1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,8 +29,8 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.48.1", - "@typescript-eslint/parser": "^8.48.1", + "@typescript-eslint/eslint-plugin": "^8.49.0", + "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-istanbul": "^4.0.15", "@vitest/coverage-v8": "^4.0.15", @@ -44,7 +44,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", - "typescript-eslint": "^8.48.1", + "typescript-eslint": "^8.49.0", "vite": "^7.2.7", "vitest": "^4.0.15" } @@ -2544,18 +2544,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", - "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/type-utils": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -2568,23 +2567,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.1", + "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", - "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "engines": { @@ -2600,14 +2599,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -2622,14 +2621,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2640,9 +2639,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -2657,15 +2656,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", - "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2682,9 +2681,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -2696,16 +2695,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -2724,16 +2723,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2748,13 +2747,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -4230,12 +4229,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6062,16 +6055,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", - "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1" + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/frontend/package.json b/frontend/package.json index 08c69d9e..517138b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,8 +47,8 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.48.1", - "@typescript-eslint/parser": "^8.48.1", + "@typescript-eslint/eslint-plugin": "^8.49.0", + "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.15", "@vitest/coverage-istanbul": "^4.0.15", @@ -63,7 +63,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", - "typescript-eslint": "^8.48.1", + "typescript-eslint": "^8.49.0", "vite": "^7.2.7", "vitest": "^4.0.15" } From be2900bc5d7575295be4ca816d34cb66d168bc6b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 22:57:32 +0000 Subject: [PATCH 13/14] feat: add HUB_BASE_URL configuration and enhance CrowdSec hub sync functionality with error handling and tests --- backend/.env.example | 2 + .../handlers/crowdsec_presets_handler_test.go | 3 +- .../internal/api/middleware/sanitize_test.go | 55 +++++++++ backend/internal/crowdsec/hub_sync.go | 107 ++++++++++++++---- backend/internal/crowdsec/hub_sync_test.go | 48 +++++++- .../internal/crowdsec/testdata/hub_index.json | 1 + .../crowdsec/testdata/hub_index_html.html | 5 + docs/plans/current_spec.md | 8 ++ docs/reports/qa_report.md | 4 +- 9 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 backend/internal/api/middleware/sanitize_test.go create mode 100644 backend/internal/crowdsec/testdata/hub_index.json create mode 100644 backend/internal/crowdsec/testdata/hub_index_html.html diff --git a/backend/.env.example b/backend/.env.example index 7b6f098e..a2559f92 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,6 +3,8 @@ CHARON_HTTP_PORT=8080 CHARON_DB_PATH=./data/charon.db CHARON_CADDY_ADMIN_API=http://localhost:2019 CHARON_CADDY_CONFIG_DIR=./data/caddy +# HUB_BASE_URL overrides the CrowdSec hub endpoint used when cscli is unavailable (defaults to https://hub-data.crowdsec.net) +# HUB_BASE_URL=https://hub-data.crowdsec.net CERBERUS_SECURITY_CERBERUS_ENABLED=false CHARON_SECURITY_CERBERUS_ENABLED=false CPM_SECURITY_CERBERUS_ENABLED=false diff --git a/backend/internal/api/handlers/crowdsec_presets_handler_test.go b/backend/internal/api/handlers/crowdsec_presets_handler_test.go index 754fc68e..d7ecc2fd 100644 --- a/backend/internal/api/handlers/crowdsec_presets_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_presets_handler_test.go @@ -273,12 +273,13 @@ func TestApplyPresetHandlerBackupFailure(t *testing.T) { r.ServeHTTP(w, req) require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "cscli unavailable") var events []models.CrowdsecPresetEvent require.NoError(t, db.Find(&events).Error) require.Len(t, events, 1) require.Equal(t, "failed", events[0].Status) - require.NotEmpty(t, events[0].BackupPath) + require.Empty(t, events[0].BackupPath) content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt")) require.NoError(t, readErr) diff --git a/backend/internal/api/middleware/sanitize_test.go b/backend/internal/api/middleware/sanitize_test.go new file mode 100644 index 00000000..dc581479 --- /dev/null +++ b/backend/internal/api/middleware/sanitize_test.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeHeaders(t *testing.T) { + t.Run("nil headers", func(t *testing.T) { + require.Nil(t, SanitizeHeaders(nil)) + }) + + t.Run("redacts sensitive headers", func(t *testing.T) { + headers := http.Header{} + headers.Set("Authorization", "secret") + headers.Set("X-Api-Key", "token") + headers.Set("Cookie", "sessionid=abc") + + sanitized := SanitizeHeaders(headers) + + require.Equal(t, []string{""}, sanitized["Authorization"]) + require.Equal(t, []string{""}, sanitized["X-Api-Key"]) + require.Equal(t, []string{""}, sanitized["Cookie"]) + }) + + t.Run("sanitizes and truncates values", func(t *testing.T) { + headers := http.Header{} + headers.Add("X-Trace", "line1\nline2\r\t") + headers.Add("X-Custom", strings.Repeat("a", 210)) + + sanitized := SanitizeHeaders(headers) + + traceValue := sanitized["X-Trace"][0] + require.NotContains(t, traceValue, "\n") + require.NotContains(t, traceValue, "\r") + require.NotContains(t, traceValue, "\t") + + customValue := sanitized["X-Custom"][0] + require.Equal(t, 200, len(customValue)) + require.True(t, strings.HasPrefix(customValue, strings.Repeat("a", 200))) + }) +} + +func TestSanitizePath(t *testing.T) { + paddedPath := "/api/v1/resource/" + strings.Repeat("x", 210) + "?token=secret" + + sanitized := SanitizePath(paddedPath) + + require.NotContains(t, sanitized, "?") + require.False(t, strings.ContainsAny(sanitized, "\n\r\t")) + require.Equal(t, 200, len(sanitized)) +} diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go index effbc466..61e0667b 100644 --- a/backend/internal/crowdsec/hub_sync.go +++ b/backend/internal/crowdsec/hub_sync.go @@ -24,6 +24,7 @@ type CommandExecutor interface { } const ( + defaultHubBaseURL = "https://hub-data.crowdsec.net" defaultHubIndexPath = "/api/index.json" defaultHubArchivePath = "/%s.tgz" defaultHubPreviewPath = "/%s.yaml" @@ -77,17 +78,35 @@ type HubService struct { // NewHubService constructs a HubService with sane defaults. func NewHubService(exec CommandExecutor, cache *HubCache, dataDir string) *HubService { + clientTimeout := 10 * time.Second return &HubService{ Exec: exec, Cache: cache, DataDir: dataDir, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, - HubBaseURL: "https://hub.crowdsec.net", - PullTimeout: 10 * time.Second, + HTTPClient: newHubHTTPClient(clientTimeout), + HubBaseURL: normalizeHubBaseURL(os.Getenv("HUB_BASE_URL")), + PullTimeout: clientTimeout, ApplyTimeout: 15 * time.Second, } } +func newHubHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func normalizeHubBaseURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return defaultHubBaseURL + } + return strings.TrimRight(trimmed, "/") +} + // FetchIndex downloads the hub index. If the hub is unreachable, returns ErrCacheMiss. func (s *HubService) FetchIndex(ctx context.Context) (HubIndex, error) { if s.Exec != nil { @@ -178,20 +197,39 @@ func (s *HubService) fetchIndexHTTP(ctx context.Context) (HubIndex, error) { if s.HTTPClient == nil { return HubIndex{}, fmt.Errorf("http client missing") } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.HubBaseURL+defaultHubIndexPath, nil) + target := strings.TrimRight(s.HubBaseURL, "/") + defaultHubIndexPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return HubIndex{}, err } resp, err := s.HTTPClient.Do(req) if err != nil { - return HubIndex{}, err + return HubIndex{}, fmt.Errorf("fetch hub index: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return HubIndex{}, fmt.Errorf("hub index status %d", resp.StatusCode) + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + return HubIndex{}, fmt.Errorf("hub index redirect (%d) to %s; install cscli or set HUB_BASE_URL to a JSON hub endpoint", resp.StatusCode, firstNonEmpty(loc, target)) + } + return HubIndex{}, fmt.Errorf("hub index status %d from %s", resp.StatusCode, target) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxArchiveSize)) + if err != nil { + return HubIndex{}, fmt.Errorf("read hub index: %w", err) + } + ct := strings.ToLower(resp.Header.Get("Content-Type")) + if ct != "" && !strings.Contains(ct, "application/json") { + if isLikelyHTML(data) { + return HubIndex{}, fmt.Errorf("hub index responded with HTML; install cscli or set HUB_BASE_URL to a JSON hub endpoint") + } + return HubIndex{}, fmt.Errorf("unexpected hub content-type %s; install cscli or set HUB_BASE_URL to a JSON hub endpoint", ct) } var idx HubIndex - if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil { + if err := json.Unmarshal(data, &idx); err != nil { + if isLikelyHTML(data) { + return HubIndex{}, fmt.Errorf("hub index responded with HTML; install cscli or set HUB_BASE_URL to a JSON hub endpoint") + } return HubIndex{}, fmt.Errorf("decode hub index: %w", err) } return idx, nil @@ -234,11 +272,11 @@ func (s *HubService) Pull(ctx context.Context, slug string) (PullResult, error) archiveURL := entry.DownloadURL if archiveURL == "" { - archiveURL = fmt.Sprintf(s.HubBaseURL+defaultHubArchivePath, cleanSlug) + archiveURL = fmt.Sprintf(strings.TrimRight(s.HubBaseURL, "/")+defaultHubArchivePath, cleanSlug) } previewURL := entry.PreviewURL if previewURL == "" { - previewURL = fmt.Sprintf(s.HubBaseURL+defaultHubPreviewPath, cleanSlug) + previewURL = fmt.Sprintf(strings.TrimRight(s.HubBaseURL, "/")+defaultHubPreviewPath, cleanSlug) } archiveBytes, err := s.fetchWithLimit(pullCtx, archiveURL) @@ -266,37 +304,46 @@ func (s *HubService) Apply(ctx context.Context, slug string) (ApplyResult, error if cleanSlug == "" { return ApplyResult{}, fmt.Errorf("invalid slug") } + applyCtx, cancel := context.WithTimeout(ctx, s.ApplyTimeout) + defer cancel() + + result := ApplyResult{AppliedPreset: cleanSlug, Status: "failed"} + meta, metaErr := s.loadCacheMeta(applyCtx, cleanSlug) + if metaErr == nil { + result.CacheKey = meta.CacheKey + } + hasCS := s.hasCSCLI(applyCtx) + if !hasCS && metaErr != nil { + msg := "cscli unavailable and no cached preset; pull the preset or install cscli" + result.ErrorMessage = msg + return result, errors.New(msg) + } backupPath := filepath.Clean(s.DataDir) + ".backup." + time.Now().Format("20060102-150405") - result := ApplyResult{BackupPath: backupPath, AppliedPreset: cleanSlug, Status: "failed"} + result.BackupPath = backupPath if err := s.backupExisting(backupPath); err != nil { return result, fmt.Errorf("backup: %w", err) } - applyCtx, cancel := context.WithTimeout(ctx, s.ApplyTimeout) - defer cancel() - if meta, err := s.loadCacheMeta(applyCtx, cleanSlug); err == nil { - result.CacheKey = meta.CacheKey - } - // Try cscli first - if s.hasCSCLI(applyCtx) { - if err := s.runCSCLI(applyCtx, cleanSlug); err == nil { + if hasCS { + cscliErr := s.runCSCLI(applyCtx, cleanSlug) + if cscliErr == nil { result.Status = "applied" result.ReloadHint = true result.UsedCSCLI = true return result, nil - } else { - logger.Log().WithError(err).WithField("slug", cleanSlug).Warn("cscli install failed; attempting cache fallback") } + logger.Log().WithField("slug", cleanSlug).WithError(cscliErr).Warn("cscli install failed; attempting cache fallback") } - meta, err := s.loadCacheMeta(applyCtx, cleanSlug) - if err != nil { + if metaErr != nil { _ = s.rollback(backupPath) - return result, fmt.Errorf("load cache: %w", err) + msg := fmt.Sprintf("load cache: %v", metaErr) + result.ErrorMessage = msg + return result, errors.New(msg) } - result.CacheKey = meta.CacheKey + archive, err := os.ReadFile(meta.ArchivePath) if err != nil { _ = s.rollback(backupPath) @@ -398,6 +445,18 @@ func findIndexEntry(idx HubIndex, slug string) (HubIndexEntry, bool) { return HubIndexEntry{}, false } +func isLikelyHTML(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return false + } + lower := bytes.ToLower(trimmed) + if bytes.HasPrefix(lower, []byte(" + +Moved +

Moved

Resource moved.

+ diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 09c5775d..e03b2228 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -6,6 +6,14 @@ - Frontend: [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx) calls `pullAndApplyCrowdsecPreset` then falls back to local `writeCrowdsecFile` apply. Preset catalog merges backend list with [frontend/src/data/crowdsecPresets.ts](frontend/src/data/crowdsecPresets.ts). Errors 501/404 are surfaced as info to keep local apply working. Overview toggle/start/stop already wired to `startCrowdsec`/`stopCrowdsec`. - Docs: [docs/cerberus.md](docs/cerberus.md) still notes CrowdSec integration is a placeholder; no hub sync described. +## Incident Triage: CrowdSec preset pull/apply 502/500 (feature/beta-release) +- Logs to pull first: backend app/GIN logs under `/app/data/logs/charon.log` (or `data/logs/charon.log` in dev) via [backend/cmd/api/main.go](backend/cmd/api/main.go); look for warnings "crowdsec preset pull failed" / "crowdsec preset apply failed" emitted in [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go). Access logs will also show 502/500 for POST `/api/v1/admin/crowdsec/presets/pull` and `/apply`. +- Routes and code paths: handlers `PullPreset` and `ApplyPreset` live in [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go) and delegate to `HubService.Pull/Apply` in [backend/internal/crowdsec/hub_sync.go](backend/internal/crowdsec/hub_sync.go) with cache helpers in [backend/internal/crowdsec/hub_cache.go](backend/internal/crowdsec/hub_cache.go). Data dir used is `data/crowdsec` with cache under `data/crowdsec/hub_cache` from [backend/internal/api/routes/routes.go](backend/internal/api/routes/routes.go). +- Quick checks before repro: (1) Cerberus enabled (`feature.cerberus.enabled` setting or `FEATURE_CERBERUS_ENABLED`/`CERBERUS_ENABLED` env) or handler returns 404 early; (2) `cscli` on PATH and executable (`HubService` uses real executor and calls `cscli version`/`cscli hub install`); (3) outbound HTTPS to https://hub.crowdsec.net reachable (fallback after `cscli hub list`); (4) cache dir writable `data/crowdsec/hub_cache` and contains per-slug `metadata.json`, `bundle.tgz`, `preview.yaml`; (5) backup path writable (apply renames `data/crowdsec` to `data/crowdsec.backup.`). +- Likely 502 on pull: hub cache unavailable or init failed (cache dir permission), invalid slug, hub index fetch errors (`cscli hub list -o json` or direct GET `/api/index.json`), download blocked/size >25MiB, preview/download HTTP non-200, or cache write errors. Handler logs warning and returns 502 with error string. +- Likely 500 on apply: backup rename fails, `cscli` install fails with no cache fallback (if pull never succeeded or cache expired/missing), cache read errors (`metadata.json`/`bundle.tgz` unreadable), tar extraction rejects symlinks/unsafe paths, or rollback after extract failure. Handler writes `CrowdsecPresetEvent` (if DB reachable) with backup path and returns 500 with `backup` hint. +- Validation steps during triage: verify cache entry freshness (TTL 24h) via `metadata.json` timestamps; confirm `cscli hub install ` succeeds manually; if cscli missing, ensure prior pull populated cache; test hub egress with curl to hub index and archive URLs; check file ownership/permissions on `data/crowdsec` and `data/crowdsec/hub_cache`; confirm log lines around warnings for exact error message; inspect backup directory to restore if partial apply. + ## Goal Implement real CrowdSec Hub preset sync + apply on backend (using cscli or direct hub index) with caching, validation, backups, rollback, and wire the UI to new endpoints so operators can preview/apply hub items with clear status/errors. diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index fd16c0dd..04ebfffe 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -9,7 +9,7 @@ **Final Verdict:** ✅ PASS (coverage gate met) -- `pre-commit run --all-files` passes; coverage hook reports 85.0% vs required 85% (gate met) with hooks including Go vet, version check, frontend type-check, and lint fix. +- `pre-commit run --all-files` passes; coverage hook reports 85.0% vs required 85% (gate met) after adding middleware sanitize tests. Hooks include Go vet, version check, frontend type-check, and lint fix. - `go test ./...` (backend) passes via task `Go: Test Backend`. - `npm run test:ci` passes (Vitest, 70 files / 598 tests). React Query undefined-data warnings and jsdom navigation warnings appear but suites stay green. @@ -17,7 +17,7 @@ | Area | Status | Notes | | --- | --- | --- | -| Pre-commit | ✅ PASS | Coverage gate satisfied at 85.0%; all hooks succeeded. | +| Pre-commit | ✅ PASS | Coverage gate satisfied at 85.0% (minimum 85%) after middleware sanitize tests; all hooks succeeded. | | Backend Unit Tests | ✅ PASS | `cd backend && go test ./...` (task: Go: Test Backend). | | Frontend Unit Tests | ✅ PASS* | `npm run test:ci` (Vitest, 70 files / 598 tests). Warnings: React Query "query data cannot be undefined" for `securityConfig`/`securityRulesets`/`feature-flags`; jsdom "navigation to another Document". | From 571a61aaeaa99f682b8afc7b50d6cb9b5dc19b41 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 8 Dec 2025 23:19:38 +0000 Subject: [PATCH 14/14] feat: install CrowdSec CLI (cscli) in Docker runtime stage - Add cscli installation from official CrowdSec releases - Update to CrowdSec v1.7.4 (from v1.6.0) - Extract both crowdsec and cscli binaries from release tarball - Install cscli to /usr/local/bin for PATH availability - Add build-time validation with cscli version check - Maintain minimal image size (293MB) - Keep existing multi-stage build structure intact --- Dockerfile | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index be85a870..f08a8653 100644 --- a/Dockerfile +++ b/Dockerfile @@ -177,18 +177,22 @@ RUN mkdir -p /app/data/geoip && \ # Copy Caddy binary from caddy-builder (overwriting the one from base image) COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy -# Install CrowdSec binary (default version can be overridden at build time) -ARG CROWDSEC_VERSION=1.6.0 +# Install CrowdSec binary and CLI (default version can be overridden at build time) +ARG CROWDSEC_VERSION=1.7.4 # hadolint ignore=DL3018 RUN apk add --no-cache curl tar gzip && \ set -eux; \ - URL="https://github.com/crowdsecurity/crowdsec/releases/download/v${CROWDSEC_VERSION}/crowdsec-v${CROWDSEC_VERSION}-linux-musl.tar.gz"; \ + URL="https://github.com/crowdsecurity/crowdsec/releases/download/v${CROWDSEC_VERSION}/crowdsec-release.tgz"; \ curl -fSL "$URL" -o /tmp/crowdsec.tar.gz && \ - mkdir -p /tmp/crowdsec && tar -xzf /tmp/crowdsec.tar.gz -C /tmp/crowdsec --strip-components=1 || true; \ - if [ -f /tmp/crowdsec/crowdsec ]; then \ - mv /tmp/crowdsec/crowdsec /usr/local/bin/crowdsec && chmod +x /usr/local/bin/crowdsec; \ + mkdir -p /tmp/crowdsec && tar -xzf /tmp/crowdsec.tar.gz -C /tmp/crowdsec || true; \ + if [ -f /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec/crowdsec ]; then \ + mv /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec/crowdsec /usr/local/bin/crowdsec && chmod +x /usr/local/bin/crowdsec; \ fi && \ - rm -rf /tmp/crowdsec /tmp/crowdsec.tar.gz || true + if [ -f /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec-cli/cscli ]; then \ + mv /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec-cli/cscli /usr/local/bin/cscli && chmod +x /usr/local/bin/cscli; \ + fi && \ + rm -rf /tmp/crowdsec /tmp/crowdsec.tar.gz && \ + cscli version # Copy Go binary from backend builder COPY --from=backend-builder /app/backend/charon /app/charon