Merge pull request #345 from Wikid82/development

chore(history-rewrite): Propagate history-rewrite from development to main (draft)
This commit is contained in:
Jeremy
2025-12-09 11:07:19 -05:00
committed by GitHub
239 changed files with 16575 additions and 2017 deletions

View File

@@ -0,0 +1,67 @@
---
trigger: always_on
---
# Charon Instructions
## Code Quality Guidelines
Every session should improve the codebase, not just add to it. Actively refactor code you encounter, even outside of your immediate task scope. Think about long-term maintainability and consistency. Make a detailed plan before writing code. Always create unit tests for new code coverage.
- **DRY**: Consolidate duplicate patterns into reusable functions, types, or components after the second occurrence.
- **CLEAN**: Delete dead code immediately. Remove unused imports, variables, functions, types, commented code, and console logs.
- **LEVERAGE**: Use battle-tested packages over custom implementations.
- **READABLE**: Maintain comments and clear naming for complex logic. Favor clarity over cleverness.
- **CONVENTIONAL COMMITS**: Write commit messages using `feat:`, `fix:`, `chore:`, `refactor:`, or `docs:` prefixes.
## 🚨 CRITICAL ARCHITECTURE RULES 🚨
- **Single Frontend Source**: All frontend code MUST reside in `frontend/`. NEVER create `backend/frontend/` or any other nested frontend directory.
- **Single Backend Source**: All backend code MUST reside in `backend/`.
- **No Python**: This is a Go (Backend) + React/TypeScript (Frontend) project. Do not introduce Python scripts or requirements.
## Big Picture
- Charon is a self-hosted web app for managing reverse proxy host configurations with the novice user in mind. Everything should prioritize simplicity, usability, reliability, and security, all rolled into one simple binary + static assets deployment. No external dependencies.
- Users should feel like they have enterprise-level security and features with zero effort.
- `backend/cmd/api` loads config, opens SQLite, then hands off to `internal/server`.
- `internal/config` respects `CHARON_ENV`, `CHARON_HTTP_PORT`, `CHARON_DB_PATH` and creates the `data/` directory.
- `internal/server` mounts the built React app (via `attachFrontend`) whenever `frontend/dist` exists.
- Persistent types live in `internal/models`; GORM auto-migrates them.
## Backend Workflow
- **Run**: `cd backend && go run ./cmd/api`.
- **Test**: `go test ./...`.
- **API Response**: Handlers return structured errors using `gin.H{"error": "message"}`.
- **JSON Tags**: All struct fields exposed to the frontend MUST have explicit `json:"snake_case"` tags.
- **IDs**: UUIDs (`github.com/google/uuid`) are generated server-side; clients never send numeric IDs.
- **Security**: Sanitize all file paths using `filepath.Clean`. Use `fmt.Errorf("context: %w", err)` for error wrapping.
- **Graceful Shutdown**: Long-running work must respect `server.Run(ctx)`.
## Frontend Workflow
- **Location**: Always work within `frontend/`.
- **Stack**: React 18 + Vite + TypeScript + TanStack Query (React Query).
- **State Management**: Use `src/hooks/use*.ts` wrapping React Query.
- **API Layer**: Create typed API clients in `src/api/*.ts` that wrap `client.ts`.
- **Forms**: Use local `useState` for form fields, submit via `useMutation`, then `invalidateQueries` on success.
## Cross-Cutting Notes
- **VS Code Integration**: If you introduce new repetitive CLI actions (e.g., scans, builds, scripts), register them in .vscode/tasks.json to allow for easy manual verification.
- **Sync**: React Query expects the exact JSON produced by GORM tags (snake_case). Keep API and UI field names aligned.
- **Migrations**: When adding models, update `internal/models` AND `internal/api/routes/routes.go` (AutoMigrate).
- **Testing**: All new code MUST include accompanying unit tests.
- **Ignore Files**: Always check `.gitignore`, `.dockerignore`, and `.codecov.yml` when adding new file or folders.
## Documentation
- **Features**: Update `docs/features.md` when adding capabilities.
- **Links**: Use GitHub Pages URLs (`https://wikid82.github.io/charon/`) for docs and GitHub blob links for repo files.
## CI/CD & Commit Conventions
- **Triggers**: Use `feat:`, `fix:`, or `perf:` to trigger Docker builds. `chore:` skips builds.
- **Beta**: `feature/beta-release` always builds.
## ✅ Task Completion Protocol (Definition of Done)
Before marking an implementation task as complete, perform the following:
1. **Pre-Commit Triage**: Run `pre-commit run --all-files`.
- If errors occur, **fix them immediately**.
- If logic errors occur, analyze and propose a fix.
- Do not output code that violates pre-commit standards.
2. **Verify Build**: Ensure the backend compiles and the frontend builds without errors.
3. **Clean Up**: Ensure no debug print statements or commented-out blocks remain.

View File

@@ -1,5 +1,7 @@
# Codecov configuration - require 75% overall coverage by default
# Adjust target as needed
# =============================================================================
# Codecov Configuration
# Require 75% overall coverage, exclude test files and non-source code
# =============================================================================
coverage:
status:
@@ -11,30 +13,79 @@ coverage:
# Fail CI if Codecov upload/report indicates a problem
require_ci_to_pass: yes
# Exclude folders from Codecov
# -----------------------------------------------------------------------------
# Exclude from coverage reporting
# -----------------------------------------------------------------------------
ignore:
- "**/tests/*"
- "**/test/*"
- "**/__tests__/*"
# Test files
- "**/tests/**"
- "**/test/**"
- "**/__tests__/**"
- "**/test_*.go"
- "**/*_test.go"
- "**/*.test.ts"
- "**/*.test.tsx"
- "docs/*"
- ".github/*"
- "scripts/*"
- "tools/*"
- "frontend/node_modules/*"
- "frontend/dist/*"
- "frontend/coverage/*"
- "backend/cmd/seed/*"
- "backend/cmd/api/*"
- "backend/data/*"
- "backend/coverage/*"
- "**/*.spec.ts"
- "**/*.spec.tsx"
- "**/vitest.config.ts"
- "**/vitest.setup.ts"
# E2E tests
- "**/e2e/**"
- "**/integration/**"
# Documentation
- "docs/**"
- "*.md"
# CI/CD & Config
- ".github/**"
- "scripts/**"
- "tools/**"
- "*.yml"
- "*.yaml"
- "*.json"
# Frontend build artifacts & dependencies
- "frontend/node_modules/**"
- "frontend/dist/**"
- "frontend/coverage/**"
- "frontend/test-results/**"
- "frontend/public/**"
# Backend non-source files
- "backend/cmd/seed/**"
- "backend/cmd/api/**"
- "backend/data/**"
- "backend/coverage/**"
- "backend/bin/**"
- "backend/*.cover"
- "backend/*.out"
- "backend/*.html"
- "backend/codeql-db/**"
# Docker-only code (not testable in CI)
- "backend/internal/services/docker_service.go"
- "backend/internal/api/handlers/docker_handler.go"
- "codeql-db/*"
# CodeQL artifacts
- "codeql-db/**"
- "codeql-db-*/**"
- "codeql-agent-results/**"
- "codeql-custom-queries-*/**"
- "*.sarif"
- "*.md"
# Config files (no logic)
- "**/tailwind.config.js"
- "**/postcss.config.js"
- "**/eslint.config.js"
- "**/vite.config.ts"
- "**/tsconfig*.json"
# Type definitions only
- "**/*.d.ts"
# Import/data directories
- "import/**"
- "data/**"
- ".cache/**"

View File

@@ -1,9 +1,22 @@
# Version control
.git
# =============================================================================
# .dockerignore - Exclude files from Docker build context
# Keep this file in sync with .gitignore where applicable
# =============================================================================
# -----------------------------------------------------------------------------
# Version Control & CI/CD
# -----------------------------------------------------------------------------
.git/
.gitignore
.github/
.pre-commit-config.yaml
.codecov.yml
.goreleaser.yaml
.sourcery.yml
# Python
# -----------------------------------------------------------------------------
# Python (pre-commit, tooling)
# -----------------------------------------------------------------------------
__pycache__/
*.py[cod]
*$py.class
@@ -15,99 +28,173 @@ env/
ENV/
.pytest_cache/
.coverage
*.cover
.hypothesis/
htmlcov/
*.egg-info/
# Node/Frontend build artifacts
# -----------------------------------------------------------------------------
# Node/Frontend - Build in Docker, not from host
# -----------------------------------------------------------------------------
frontend/node_modules/
frontend/coverage/
frontend/coverage.out
frontend/test-results/
frontend/dist/
frontend/.vite/
frontend/*.tsbuildinfo
frontend/frontend/
frontend/e2e/
# Go/Backend
backend/coverage.txt
# Root-level node artifacts (eslint config runner)
node_modules/
package-lock.json
package.json
# -----------------------------------------------------------------------------
# Go/Backend - Build artifacts & coverage
# -----------------------------------------------------------------------------
backend/bin/
backend/api
backend/*.out
backend/*.cover
backend/*.html
backend/coverage/
backend/coverage.*.out
backend/coverage_*.out
backend/coverage*.out
backend/coverage*.txt
backend/*.coverage.out
backend/handler_coverage.txt
backend/handlers.out
backend/services.test
backend/test-output.txt
backend/tr_no_cover.txt
backend/nohup.out
backend/package.json
backend/package-lock.json
# Databases (runtime)
backend/data/*.db
backend/data/**/*.db
backend/cmd/api/data/*.db
# Backend data (created at runtime)
backend/data/
backend/codeql-db/
backend/.venv/
backend/.vscode/
# -----------------------------------------------------------------------------
# Databases (created at runtime)
# -----------------------------------------------------------------------------
*.db
*.sqlite
*.sqlite3
cpm.db
data/
charon.db
cpm.db
# IDE
# -----------------------------------------------------------------------------
# IDE & Editor
# -----------------------------------------------------------------------------
.vscode/
.vscode.backup*/
.idea/
*.swp
*.swo
*~
*.xcf
Chiron.code-workspace
# Logs
# -----------------------------------------------------------------------------
# Logs & Temp Files
# -----------------------------------------------------------------------------
.trivy_logs/
*.log
logs/
nohup.out
# Environment
# -----------------------------------------------------------------------------
# Environment Files
# -----------------------------------------------------------------------------
.env
.env.local
.env.*.local
!.env.example
# OS
# -----------------------------------------------------------------------------
# OS Files
# -----------------------------------------------------------------------------
.DS_Store
Thumbs.db
# Documentation
# -----------------------------------------------------------------------------
# Documentation (not needed in image)
# -----------------------------------------------------------------------------
docs/
*.md
!README.md
!CONTRIBUTING.md
!LICENSE
# Docker
# -----------------------------------------------------------------------------
# Docker Compose (not needed inside image)
# -----------------------------------------------------------------------------
docker-compose*.yml
**/Dockerfile.*
# CI/CD
.github/
.pre-commit-config.yaml
.codecov.yml
.goreleaser.yaml
# GoReleaser artifacts
# -----------------------------------------------------------------------------
# GoReleaser & dist artifacts
# -----------------------------------------------------------------------------
dist/
# Scripts
# -----------------------------------------------------------------------------
# Scripts & Tools (not needed in image)
# -----------------------------------------------------------------------------
scripts/
tools/
create_issues.sh
cookies.txt
cookies.txt.bak
test.caddyfile
Makefile
# Testing artifacts
# -----------------------------------------------------------------------------
# Testing & Coverage Artifacts
# -----------------------------------------------------------------------------
coverage/
coverage.out
*.cover
*.crdownload
*.sarif
# Project Documentation
ACME_STAGING_IMPLEMENTATION.md
# -----------------------------------------------------------------------------
# CodeQL & Security Scanning (large, not needed)
# -----------------------------------------------------------------------------
codeql-db/
codeql-db-*/
codeql-agent-results/
codeql-custom-queries-*/
codeql-*.sarif
codeql-results*.sarif
.codeql/
# -----------------------------------------------------------------------------
# Import Directory (user data)
# -----------------------------------------------------------------------------
import/
# -----------------------------------------------------------------------------
# Project Documentation & Planning (not needed in image)
# -----------------------------------------------------------------------------
*.md.bak
ACME_STAGING_IMPLEMENTATION.md*
ARCHITECTURE_PLAN.md
BULK_ACL_FEATURE.md
DOCKER_TASKS.md
DOCKER_TASKS.md*
DOCUMENTATION_POLISH_SUMMARY.md
GHCR_MIGRATION_SUMMARY.md
ISSUE_*_IMPLEMENTATION.md
ISSUE_*_IMPLEMENTATION.md*
PHASE_*_SUMMARY.md
PROJECT_BOARD_SETUP.md
PROJECT_PLANNING.md
SECURITY_IMPLEMENTATION_PLAN.md
VERSIONING_IMPLEMENTATION.md
QA_AUDIT_REPORT*.md
VERSION.md
eslint.config.js
go.work
go.work.sum
.cache

16
.gitattributes vendored Normal file
View File

@@ -0,0 +1,16 @@
# .gitattributes - LFS filter and binary markers for large files and DBs
# Mark CodeQL DB directories as binary
codeql-db/** binary
codeql-db-*/** binary
# Use Git LFS for larger binary database files and archives
*.db filter=lfs diff=lfs merge=lfs -text
*.sqlite filter=lfs diff=lfs merge=lfs -text
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
*.tar.gz filter=lfs diff=lfs merge=lfs -text
*.tgz filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.iso filter=lfs diff=lfs merge=lfs -text
*.exe filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text

View File

@@ -0,0 +1,27 @@
<!-- PR: History Rewrite & Large-file Removal -->
## Summary
- Provide a short summary of why the history rewrite is needed.
## Checklist - required for history rewrite PRs
- [ ] I have created a **local** backup branch: `backup/history-YYYYMMDD-HHMMSS` and verified it contains all refs.
- [ ] I have pushed the backup branch to the remote origin and it is visible to reviewers.
- [ ] I have run a dry-run locally: `scripts/history-rewrite/preview_removals.sh --paths 'backend/codeql-db,codeql-db,codeql-db-js,codeql-db-go' --strip-size 50` and attached the output or paste it below.
- [ ] I have verified the `data/backups` tarball is present and tests showing rewrite will not remove unrelated artifacts.
- [ ] I have created a tag backup (see `data/backups/`) and verified tags are pushed to the remote or included in the tarball.
- [ ] I have coordinated with repo maintainers for a rewrite window and notified other active forks/tokens that may be affected.
- [ ] I have run the CI dry-run job and ensured it completes without blocked findings.
- [ ] This PR only contains the history-rewrite helpers; no destructive rewrite is included in this PR.
- [ ] I will not run the destructive `--force` step without explicit approval from maintainers and a scheduled maintenance window.
**Note for maintainers**: `validate_after_rewrite.sh` will check that the `backups` and `backup_branch` are present and will fail if they are not. Provide `--backup-branch "backup/history-YYYYMMDD-HHMMSS"` when running the scripts or set the `BACKUP_BRANCH` environment variable so automated validation can find the backup branch.
## Attachments
Attach the `preview_removals` output and `data/backups/history_cleanup-*.log` content and any `data/backups` tarball created for this PR.
## Approach
Describe the paths to be removed, strip size, and whether additional blob stripping is required.
# Notes for maintainers
- The workflow `.github/workflows/dry-run-history-rewrite.yml` will run automatically on PR updates.
- Please follow the checklist and only approve after offline confirmation.

View File

@@ -1,4 +1,4 @@
name: Backend_Dev
name: Backend Dev
description: Senior Go Engineer focused on high-performance, secure backend implementation.
argument-hint: The specific backend task from the Plan (e.g., "Implement ProxyHost CRUD endpoints")
# ADDED 'list_dir' below so Step 1 works

View File

@@ -1,4 +1,4 @@
name: Dev_Ops
name: Dev Ops
description: DevOps specialist that debugs GitHub Actions, CI pipelines, and Docker builds.
argument-hint: The workflow issue (e.g., "Why did the last build fail?" or "Fix the Docker push error")
tools: ['run_terminal_command', 'read_file', 'write_file', 'search', 'list_dir']

View File

@@ -1,4 +1,4 @@
name: Docs_Writer
name: Docs Writer
description: User Advocate and Writer focused on creating simple, layman-friendly documentation.
argument-hint: The feature to document (e.g., "Write the guide for the new Real-Time Logs")
tools: ['search', 'read_file', 'write_file', 'list_dir', 'changes']
@@ -20,6 +20,7 @@ Your goal is to translate "Engineer Speak" into simple, actionable instructions.
- **ELI5 (Explain Like I'm 5)**: Use simple words. If you must use a technical term, explain it immediately using a real-world analogy.
- **Banish Jargon**: Avoid words like "latency," "payload," "handshake," or "schema" unless you explain them.
- **Focus on Action**: Structure text as: "Do this -> Get that result."
- **Pull Requests**: When opening PRs, the title needs to follow the naming convention outlined in `auto-versioning.md` to make sure new versions are generated correctly upon merge.
</style_guide>
<workflow>

View File

@@ -1,4 +1,4 @@
name: Frontend_Dev
name: Frontend Dev
description: Senior React/UX Engineer focused on seamless user experiences and clean component architecture.
argument-hint: The specific frontend task from the Plan (e.g., "Create Proxy Host Form")
# ADDED 'list_dir' below so Step 1 works

55
.github/agents/Manegment.agent.md vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Management
description: Engineering Director. Delegates ALL research and execution. DO NOT ask it to debug code directly.
argument-hint: The high-level goal (e.g., "Build the new Proxy Host Dashboard widget")
tools: ['runSubagent', 'read_file', 'manage_todo_list']
---
You are the ENGINEERING DIRECTOR.
**YOUR OPERATING MODEL: AGGRESSIVE DELEGATION.**
You are "lazy" in the smartest way possible. You never do what a subordinate can do.
<global_context>
1. **Initialize**: ALWAYS read `.github/copilot-instructions.md` first to load global project rules.
2. **Team Roster**:
- `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 and testing here).
- `Docs_Writer`: The Scribe. (Delegate docs here).
- `DevOps`: The Packager. (Delegate CI/CD and infrastructure here).
</global_context>
<workflow>
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.
- **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.
- **Ask**: "Plan created. Shall I authorize the construction?"
3. **Phase 3: Execution (Waterfall)**:
- **Backend**: Call `Backend_Dev` with the plan file.
- **Frontend**: Call `Frontend_Dev` with the plan file.
4. **Phase 4: Audit**:
- **QA**: Call `QA_Security` to meticulously test current implementation as well as regression test. Run all linting, security tasks, and manual pre-commit checks. Write a report to `docs/reports/qa_report.md`. Start back at Phase 1 if issues are found.
5. **Phase 5: Closure**:
- **Docs**: Call `Docs_Writer`.
- **Final Report**: Summarize the successful subagent runs.
</workflow>
## DEFENITION OF DONE ##
- 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.
<constraints>
- **SOURCE CODE BAN**: You are FORBIDDEN from reading `.go`, `.tsx`, `.ts`, or `.css` files. You may ONLY read `.md` (Markdown) files.
- **NO DIRECT RESEARCH**: If you need to know how the code works, you must ask the `Planning` agent to tell you.
- **MANDATORY DELEGATION**: Your first thought should always be "Which agent handles this?", not "How do I solve this?"
- **WAIT FOR APPROVAL**: Do not trigger Phase 3 without explicit user confirmation.
</constraints>

View File

@@ -6,7 +6,7 @@ tools: ['search', 'runSubagent', 'usages', 'problems', 'changes', 'fetch', 'gith
---
You are a PRINCIPAL SOFTWARE ARCHITECT and TECHNICAL PRODUCT MANAGER.
Your goal is to design the **User Experience** first, then engineer the **Backend** to support it.
Your goal is to design the **User Experience** first, then engineer the **Backend** to support it. Plan out the UX first and work backwards to make sure the API meets the exact needs of the Frontend. When you need a subagent to perform a task, use the `#runSubagent` tool. Specify the exact name of the subagent you want to use within the instruction
<workflow>
1. **Context Loading (CRITICAL)**:
@@ -26,6 +26,7 @@ Your goal is to design the **User Experience** first, then engineer the **Backen
4. **Review**:
- Ask the user for confirmation.
</workflow>
<output_format>

View File

@@ -1,4 +1,4 @@
name: QA_Security
name: QA and Security
description: Security Engineer and QA specialist focused on breaking the implementation.
argument-hint: The feature or endpoint to audit (e.g., "Audit the new Proxy Host creation flow")
tools: ['search', 'runSubagent', 'read_file', 'run_terminal_command', 'usages', 'write_file', 'list_dir', 'run_task']
@@ -10,7 +10,8 @@ Your job is to act as an ADVERSARY. The Developer says "it works"; your job is t
<context>
- **Project**: Charon (Reverse Proxy)
- **Priority**: Security, Input Validation, Error Handling.
- **Tools**: `go test`, `trivy` (if available), manual edge-case analysis.
- **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)
</context>
<workflow>
@@ -26,7 +27,9 @@ Your job is to act as an ADVERSARY. The Developer says "it works"; your job is t
3. **Execute**:
- **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) and triage any findings.
- **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.
- 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.
</workflow>

60
.github/agents/SubagentUsage.md vendored Normal file
View File

@@ -0,0 +1,60 @@
## Subagent Usage Templates and Orchestration
This helper provides the Management agent with templates to create robust and repeatable `runSubagent` calls.
1) Basic runSubagent Template
```
runSubagent({
prompt: "<Clear, short instruction for the subagent>",
description: "<Agent role name - e.g., Backend Dev>",
metadata: {
plan_file: "docs/plans/current_spec.md",
files_to_change: ["..."],
commands_to_run: ["..."],
tests_to_run: ["..."],
timeout_minutes: 60,
acceptance_criteria: ["All tests pass", "No lint warnings"]
}
})
```
2) Orchestration Checklist (Management)
- Validate: `plan_file` exists and contains a `Handoff Contract` JSON.
- Kickoff: call `Planning` to create the plan if not present.
- Run: execute `Backend Dev` then `Frontend Dev` sequentially.
- Parallel: run `QA and Security`, `DevOps` and `Doc Writer` in parallel for CI / QA checks and documentation.
- Return: a JSON summary with `subagent_results`, `overall_status`, and aggregated artifacts.
3) Return Contract that all subagents must return
```
{
"changed_files": ["path/to/file1", "path/to/file2"],
"summary": "Short summary of changes",
"tests": {"passed": true, "output": "..."},
"artifacts": ["..."],
"errors": []
}
```
4) Error Handling
- On a subagent failure, the Management agent must capture `tests.output` and decide to retry (1 retry maximum), or request a revert/rollback.
- Clearly mark the `status` as `failed`, and include `errors` and `failing_tests` in the `summary`.
5) Example: Run a full Feature Implementation
```
// 1. Planning
runSubagent({ description: "Planning", prompt: "<generate plan>", metadata: { plan_file: "docs/plans/current_spec.md" } })
// 2. Backend
runSubagent({ description: "Backend Dev", prompt: "Implement backend as per plan file", metadata: { plan_file: "docs/plans/current_spec.md", commands_to_run: ["cd backend && go test ./..."] } })
// 3. Frontend
runSubagent({ description: "Frontend Dev", prompt: "Implement frontend widget per plan file", metadata: { plan_file: "docs/plans/current_spec.md", commands_to_run: ["cd frontend && npm run build"] } })
// 4. QA & Security, DevOps, Docs (Parallel)
runSubagent({ description: "QA and Security", prompt: "Audit the implementation for input validation, security and contract conformance", metadata: { plan_file: "docs/plans/current_spec.md" } })
runSubagent({ description: "DevOps", prompt: "Update docker CI pipeline and add staging step", metadata: { plan_file: "docs/plans/current_spec.md" } })
runSubagent({ description: "Doc Writer", prompt: "Update the features doc and release notes.", metadata: { plan_file: "docs/plans/current_spec.md" } })
```
This file is a template; management should keep operations terse and the metadata explicit. Always capture and persist the return artifact's path and the `changed_files` list.

12
.github/propagate-config.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
## Propagation Config
# Central list of sensitive paths that should not be auto-propagated.
# The workflow reads this file and will skip automatic propagation if any
# changed files match these paths. Only a simple YAML list under `sensitive_paths:` is parsed.
sensitive_paths:
- scripts/history-rewrite/
- data/backups
- docs/plans/history_rewrite.md
- .github/workflows/
- scripts/history-rewrite/preview_removals.sh
- scripts/history-rewrite/clean_history.sh

View File

@@ -17,18 +17,24 @@ jobs:
with:
fetch-depth: 0
- name: Generate semantic version (fallback script)
- name: Calculate Semantic Version
id: semver
run: |
# Ensure git tags are fetched
git fetch --tags --quiet || true
# Get latest tag or default to v0.0.0
TAG=$(git describe --abbrev=0 --tags 2>/dev/null || echo "v0.0.0")
echo "Detected latest tag: $TAG"
# Set outputs for downstream steps
echo "version=$TAG" >> $GITHUB_OUTPUT
echo "release_notes=Fallback: using latest tag only" >> $GITHUB_OUTPUT
echo "changed=false" >> $GITHUB_OUTPUT
uses: paulhatch/semantic-version@a8f8f59fd7f0625188492e945240f12d7ad2dca3 # v5.4.0
with:
# The prefix to use to create tags
tag_prefix: "v"
# A string which, if present in the git log, indicates that a major version increase is required
major_pattern: "(MAJOR)"
# A string which, if present in the git log, indicates that a minor version increase is required
minor_pattern: "(feat)"
# Pattern to determine formatting
version_format: "${major}.${minor}.${patch}"
# If no tags are found, this version is used
version_from_branch: "0.0.0"
# This helps it search through history to find the last tag
search_commit_body: true
# Important: This enables the output 'changed' which your other steps rely on
enable_prerelease_mode: false
- name: Show version
run: |
@@ -96,7 +102,7 @@ jobs:
with:
tag_name: ${{ steps.determine_tag.outputs.tag }}
name: Release ${{ steps.determine_tag.outputs.tag }}
body: ${{ steps.semver.outputs.release_notes }}
generate_release_notes: true
make_latest: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -26,19 +26,19 @@ jobs:
go-version: '1.25.5'
cache-dependency-path: backend/go.sum
- name: Run Go tests
working-directory: backend
- name: Run Go tests with coverage
working-directory: ${{ github.workspace }}
env:
CGO_ENABLED: 1
run: |
go test -race -v -coverprofile=coverage.out ./... 2>&1 | tee test-output.txt
bash scripts/go-test-coverage.sh 2>&1 | tee backend/test-output.txt
exit ${PIPESTATUS[0]}
- name: Upload backend coverage to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./backend/coverage.out
files: ./backend/coverage.txt
flags: backend
fail_ci_if_error: true

View File

@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Initialize CodeQL
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4
with:
languages: ${{ matrix.language }}
@@ -45,9 +45,9 @@ jobs:
go-version: '1.25.5'
- name: Autobuild
uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4
with:
category: "/language:${{ matrix.language }}"

268
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,268 @@
name: Docker Build, Publish & Test
on:
push:
branches:
- main
- development
- feature/beta-release
# Note: Tags are handled by release-goreleaser.yml to avoid duplicate builds
pull_request:
branches:
- main
- development
- feature/beta-release
workflow_dispatch:
workflow_call:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/charon
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
security-events: write
outputs:
skip_build: ${{ steps.skip.outputs.skip_build }}
digest: ${{ steps.build-and-push.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Normalize image name
run: |
IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
- name: Determine skip condition
id: skip
env:
ACTOR: ${{ github.actor }}
EVENT: ${{ github.event_name }}
HEAD_MSG: ${{ github.event.head_commit.message }}
REF: ${{ github.ref }}
run: |
should_skip=false
pr_title=""
if [ "$EVENT" = "pull_request" ]; then
pr_title=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH" 2>/dev/null || echo '')
fi
if [ "$ACTOR" = "renovate[bot]" ]; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
# Always build on beta-release branch to ensure artifacts for testing
if [[ "$REF" == "refs/heads/feature/beta-release" ]]; then
should_skip=false
echo "Force building on beta-release branch"
fi
echo "skip_build=$should_skip" >> $GITHUB_OUTPUT
- name: Set up QEMU
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Resolve Caddy base digest
if: steps.skip.outputs.skip_build != 'true'
id: caddy
run: |
docker pull caddy:2-alpine
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' caddy:2-alpine)
echo "image=$DIGEST" >> $GITHUB_OUTPUT
- name: Log in to Container Registry
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
if: steps.skip.outputs.skip_build != 'true'
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/development' }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/feature/beta-release' }}
type=raw,value=pr-${{ github.ref_name }},enable=${{ github.event_name == 'pull_request' }}
type=sha,format=short,enable=${{ github.event_name != 'pull_request' }}
- name: Build and push Docker image
if: steps.skip.outputs.skip_build != 'true'
id: build-and-push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.meta.outputs.version }}
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VCS_REF=${{ github.sha }}
CADDY_IMAGE=${{ steps.caddy.outputs.image }}
- name: Run Trivy scan (table output)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '0'
continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
id: trivy
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
continue-on-error: true
- name: Check Trivy SARIF exists
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
id: trivy-check
run: |
if [ -f trivy-results.sarif ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Upload Trivy results
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
sarif_file: 'trivy-results.sarif'
token: ${{ secrets.GITHUB_TOKEN }}
- name: Create summary
if: steps.skip.outputs.skip_build != 'true'
run: |
echo "## 🎉 Docker Image Built Successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
echo "- **Registry**: GitHub Container Registry (ghcr.io)" >> $GITHUB_STEP_SUMMARY
echo "- **Repository**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
test-image:
name: Test Docker Image
needs: build-and-push
runs-on: ubuntu-latest
if: needs.build-and-push.outputs.skip_build != 'true' && github.event_name != 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Normalize image name
run: |
raw="${{ github.repository_owner }}/${{ github.event.repository.name }}"
IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
- name: Determine image tag
id: tag
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
else
echo "tag=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
fi
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker image
run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
- name: Create Docker Network
run: docker network create charon-test-net
- name: Run Upstream Service (whoami)
run: |
docker run -d \
--name whoami \
--network charon-test-net \
traefik/whoami
- name: Run Charon Container
run: |
docker run -d \
--name test-container \
--network charon-test-net \
-p 8080:8080 \
-p 80:80 \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
- name: Run Integration Test
run: ./scripts/integration-test.sh
- name: Check container logs
if: always()
run: docker logs test-container
- name: Stop container
if: always()
run: |
docker stop test-container whoami || true
docker rm test-container whoami || true
docker network rm charon-test-net || true
- name: Create test summary
if: always()
run: |
echo "## 🧪 Docker Image Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Integration Test**: ${{ job.status == 'success' && '✅ Passed' || '❌ Failed' }}" >> $GITHUB_STEP_SUMMARY
trivy-pr-app-only:
name: Trivy (PR) - App-only
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Build image locally for PR
run: |
docker build -t charon:pr-${{ github.sha }} .
- name: Extract `charon` binary from image
run: |
CONTAINER=$(docker create charon:pr-${{ github.sha }})
docker cp ${CONTAINER}:/app/charon ./charon_binary || true
docker rm ${CONTAINER} || true
- name: Run Trivy filesystem scan on `charon` (fail PR on HIGH/CRITICAL)
run: |
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy -v $PWD:/workdir aquasec/trivy:latest fs --exit-code 1 --severity CRITICAL,HIGH /workdir/charon_binary

View File

@@ -155,7 +155,7 @@ jobs:
- name: Upload Trivy results
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
sarif_file: 'trivy-results.sarif'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,34 @@
name: History Rewrite Dry-Run
on:
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '0 2 * * *' # daily at 02:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
preview-history:
name: Dry-run preview for history rewrite
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Debug git info
run: |
git --version
git rev-parse --is-shallow-repository || true
git status --porcelain
- name: Make CI script executable
run: chmod +x scripts/ci/dry_run_history_rewrite.sh
- name: Run dry-run history check
run: |
scripts/ci/dry_run_history_rewrite.sh --paths 'backend/codeql-db,codeql-db,codeql-db-js,codeql-db-go' --strip-size 50

View File

@@ -0,0 +1,32 @@
name: History Rewrite Tests
on:
push:
paths:
- 'scripts/history-rewrite/**'
- '.github/workflows/history-rewrite-tests.yml'
pull_request:
paths:
- 'scripts/history-rewrite/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with full history
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y bats shellcheck
- name: Run Bats tests
run: |
bats ./scripts/history-rewrite/tests || exit 1
- name: ShellCheck scripts
run: |
shellcheck scripts/history-rewrite/*.sh || true

48
.github/workflows/pr-checklist.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: PR Checklist Validation (History Rewrite)
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
validate:
name: Validate history-rewrite checklist (conditional)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Validate PR checklist (only for history-rewrite changes)
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.issue.number;
const pr = await github.rest.pulls.get({owner, repo, pull_number: prNumber});
const body = (pr.data && pr.data.body) || '';
// Determine if this PR modifies history-rewrite related files
const filesResp = await github.rest.pulls.listFiles({ owner, repo, pull_number: prNumber });
const files = filesResp.data.map(f => f.filename.toLowerCase());
const relevant = files.some(fn => fn.startsWith('scripts/history-rewrite/') || fn.startsWith('docs/plans/history_rewrite.md') || fn.includes('history-rewrite'));
if (!relevant) {
core.info('No history-rewrite related files changed; skipping checklist validation.');
return;
}
// Use a set of named checks with robust regex patterns for checkbox and phrase variants
const checks = [
{ name: 'preview_removals.sh mention', pattern: /preview_removals\.sh/i },
{ name: 'data/backups mention', pattern: /data\/?backups/i },
// Accept checked checkbox variants and inline code/backtick usage for the '--force' phrase
{ name: 'explicit non-run of --force', pattern: /(?:\[\s*[xX]\s*\]\s*)?(?:i will not run|will not run|do not run|don'?t run|won'?t run)\b[^\n]*--force/i },
];
const missing = checks.filter(c => !c.pattern.test(body)).map(c => c.name);
if (missing.length > 0) {
// Post a comment to the PR with instructions for filling the checklist
const commentBody = `Hi! This PR touches history-rewrite artifacts and requires the checklist in .github/PULL_REQUEST_TEMPLATE/history-rewrite.md. The following items are missing in your PR body: ${missing.join(', ')}\n\nPlease update the PR description using the history-rewrite template and re-run checks.`;
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: commentBody });
core.setFailed('Missing required checklist items: ' + missing.join(', '));
}

View File

@@ -9,6 +9,7 @@ on:
permissions:
contents: write
pull-requests: write
issues: write
jobs:
propagate:
@@ -60,6 +61,47 @@ jobs:
core.info(`${src} is not ahead of ${base}. No propagation needed.`);
return;
}
// If files changed include history-rewrite or other sensitive scripts,
// avoid automatic propagation. This prevents bypassing checklist validation
// and manual review for potentially destructive changes.
let files = (compare.data.files || []).map(f => (f.filename || '').toLowerCase());
// Fallback: if compare.files is empty/truncated, aggregate files from the commit list
if (files.length === 0 && Array.isArray(compare.data.commits) && compare.data.commits.length > 0) {
for (const commit of compare.data.commits) {
const commitData = await github.rest.repos.getCommit({ owner: context.repo.owner, repo: context.repo.repo, ref: commit.sha });
for (const f of (commitData.data.files || [])) {
files.push((f.filename || '').toLowerCase());
}
}
files = Array.from(new Set(files));
}
// Load propagation config (list of sensitive paths) from .github/propagate-config.yml when available
let configPaths = ['scripts/history-rewrite/', 'data/backups', 'docs/plans/history_rewrite.md', '.github/workflows/'];
try {
const configResp = await github.rest.repos.getContent({ owner: context.repo.owner, repo: context.repo.repo, path: '.github/propagate-config.yml', ref: src });
const contentStr = Buffer.from(configResp.data.content, 'base64').toString('utf8');
const lines = contentStr.split(/\r?\n/);
let inSensitive = false;
const parsedPaths = [];
for (const line of lines) {
const trimmed = line.trim();
if (!inSensitive && trimmed.startsWith('sensitive_paths:')) { inSensitive = true; continue; }
if (inSensitive) {
if (trimmed.startsWith('-')) parsedPaths.push(trimmed.substring(1).trim());
else if (trimmed.length === 0) continue; else break;
}
}
if (parsedPaths.length > 0) configPaths = parsedPaths.map(p => p.toLowerCase());
} catch (err) { core.info('No .github/propagate-config.yml or parse failure; using defaults.'); }
const sensitive = files.some(fn => configPaths.some(sp => fn.startsWith(sp) || fn.includes(sp)));
if (sensitive) {
core.info(`${src} -> ${base} contains sensitive changes (${files.join(', ')}). Skipping automatic propagation.`);
return;
}
} catch (error) {
// If base branch doesn't exist, etc.
core.warning(`Error comparing ${src} to ${base}: ${error.message}`);
@@ -75,8 +117,20 @@ jobs:
head: src,
base: base,
body: `Automated PR to propagate changes from ${src} into ${base}.\n\nTriggered by push to ${currentBranch}.`,
draft: true,
});
core.info(`Created PR #${pr.data.number} to merge ${src} into ${base}`);
// Add an 'auto-propagate' label to the created PR and create the label if missing
try {
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate' });
} catch (e) {
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate', color: '7dd3fc', description: 'Automatically created propagate PRs' });
}
await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.data.number, labels: ['auto-propagate'] });
} catch (labelErr) {
core.warning('Failed to ensure or add auto-propagate label: ' + labelErr.message);
}
} catch (error) {
core.warning(`Failed to create PR from ${src} to ${base}: ${error.message}`);
}

View File

@@ -19,6 +19,10 @@ jobs:
go-version: '1.25.5'
cache-dependency-path: backend/go.sum
- name: Repo health check
run: |
bash scripts/repo_health_check.sh
- name: Run Go tests
id: go-tests
working-directory: ${{ github.workspace }}
@@ -75,6 +79,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Repo health check
run: |
bash scripts/repo_health_check.sh
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
@@ -83,13 +93,48 @@ jobs:
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Check if frontend was modified in PR
id: check-frontend
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "frontend_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Try to fetch the PR base ref. This may fail for forked PRs or other cases.
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 || true
# Compute changed files against the PR base ref, fallback to origin/main, then fallback to last 10 commits
CHANGED=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD 2>/dev/null || echo "")
echo "Changed files (base ref):\n$CHANGED"
if [ -z "$CHANGED" ]; then
echo "Base ref diff empty or failed; fetching origin/main for fallback..."
git fetch origin main --depth=1 || true
CHANGED=$(git diff --name-only origin/main...HEAD 2>/dev/null || echo "")
echo "Changed files (main fallback):\n$CHANGED"
fi
if [ -z "$CHANGED" ]; then
echo "Still empty; falling back to diffing last 10 commits from HEAD..."
CHANGED=$(git diff --name-only HEAD~10...HEAD 2>/dev/null || echo "")
echo "Changed files (HEAD~10 fallback):\n$CHANGED"
fi
if echo "$CHANGED" | grep -q '^frontend/'; then
echo "frontend_changed=true" >> $GITHUB_OUTPUT
else
echo "frontend_changed=false" >> $GITHUB_OUTPUT
fi
- name: Install dependencies
working-directory: frontend
if: ${{ github.event_name == 'push' || steps.check-frontend.outputs.frontend_changed == 'true' }}
run: npm ci
- name: Run frontend tests and coverage
id: frontend-tests
working-directory: ${{ github.workspace }}
if: ${{ github.event_name == 'push' || steps.check-frontend.outputs.frontend_changed == 'true' }}
run: |
bash scripts/frontend-test-coverage.sh 2>&1 | tee frontend/test-output.txt
exit ${PIPESTATUS[0]}

View File

@@ -20,18 +20,29 @@ jobs:
fetch-depth: 1
- name: Choose Renovate Token
run: |
# Prefer explicit tokens (CHARON_TOKEN > CPMP_TOKEN) if provided; otherwise use the default GITHUB_TOKEN
if [ -n "${{ secrets.CHARON_TOKEN }}" ]; then
echo "Using CHARON_TOKEN" >&2
echo "RENOVATE_TOKEN=${{ secrets.CHARON_TOKEN }}" >> $GITHUB_ENV
else
echo "GITHUB_TOKEN=${{ secrets.CHARON_TOKEN }}" >> $GITHUB_ENV
elif [ -n "${{ secrets.CPMP_TOKEN }}" ]; then
echo "Using CPMP_TOKEN fallback" >&2
echo "RENOVATE_TOKEN=${{ secrets.CPMP_TOKEN }}" >> $GITHUB_ENV
echo "GITHUB_TOKEN=${{ secrets.CPMP_TOKEN }}" >> $GITHUB_ENV
else
echo "Using default GITHUB_TOKEN from Actions" >&2
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
fi
- name: Fail-fast if token not set
run: |
if [ -z "${{ env.GITHUB_TOKEN }}" ]; then
echo "ERROR: No Renovate token provided. Set CHARON_TOKEN, CPMP_TOKEN, or rely on default GITHUB_TOKEN." >&2
exit 1
fi
- name: Run Renovate
uses: renovatebot/github-action@5712c6a41dea6cdf32c72d92a763bd417e6606aa # v44.0.5
with:
configurationFile: .github/renovate.json
token: ${{ env.RENOVATE_TOKEN }}
token: ${{ env.GITHUB_TOKEN }}
env:
LOG_LEVEL: info

39
.github/workflows/repo-health.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Repo Health Check
on:
schedule:
- cron: '0 0 * * *'
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch: {}
jobs:
repo_health:
name: Repo health
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
lfs: true
- name: Set up Git
run: |
git --version
git lfs install --local || true
- name: Run repo health check
env:
MAX_MB: 100
LFS_ALLOW_MB: 50
run: |
bash scripts/repo_health_check.sh
- name: Upload health output
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: repo-health-output
path: |
/tmp/repo_big_files.txt

124
.gitignore vendored
View File

@@ -1,4 +1,10 @@
# Python
# =============================================================================
# .gitignore - Files to exclude from version control
# =============================================================================
# -----------------------------------------------------------------------------
# Python (pre-commit, tooling)
# -----------------------------------------------------------------------------
__pycache__/
*.py[cod]
*$py.class
@@ -14,108 +20,156 @@ ENV/
.hypothesis/
htmlcov/
# -----------------------------------------------------------------------------
# Node/Frontend
# -----------------------------------------------------------------------------
node_modules/
frontend/node_modules/
backend/node_modules/
frontend/dist/
frontend/coverage/
frontend/test-results/
frontend/.vite/
frontend/*.tsbuildinfo
/frontend/frontend/
# Go/Backend
# -----------------------------------------------------------------------------
# Go/Backend - Build artifacts & coverage
# -----------------------------------------------------------------------------
backend/api
backend/bin/
backend/*.out
backend/*.cover
backend/*.html
backend/coverage/
backend/coverage.*.out
backend/coverage_*.out
backend/coverage*.out
backend/coverage*.txt
backend/*.coverage.out
backend/handler_coverage.txt
backend/handlers.out
backend/services.test
backend/test-output.txt
backend/tr_no_cover.txt
backend/nohup.out
backend/charon
backend/codeql-db/
backend/.venv/
# -----------------------------------------------------------------------------
# Databases
# -----------------------------------------------------------------------------
*.db
*.sqlite
*.sqlite3
backend/data/
backend/data/*.db
backend/data/**/*.db
backend/cmd/api/data/*.db
cpm.db
charon.db
# IDE
# -----------------------------------------------------------------------------
# IDE & Editor
# -----------------------------------------------------------------------------
.idea/
*.swp
*.swo
*~
.DS_Store
*.xcf
.vscode/
.vscode/launch.json
.vscode.backup*/
# Logs
.trivy_logs
# -----------------------------------------------------------------------------
# Logs & Temp Files
# -----------------------------------------------------------------------------
.trivy_logs/
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
nohup.out
# Environment
# -----------------------------------------------------------------------------
# Environment Files
# -----------------------------------------------------------------------------
.env
.env.*
!.env.example
# OS
# -----------------------------------------------------------------------------
# OS Files
# -----------------------------------------------------------------------------
Thumbs.db
*.xcf
# Caddy
# -----------------------------------------------------------------------------
# Caddy Runtime Data
# -----------------------------------------------------------------------------
backend/data/caddy/
/data/
/data/backups/
# Docker
# -----------------------------------------------------------------------------
# Docker Overrides
# -----------------------------------------------------------------------------
docker-compose.override.yml
# -----------------------------------------------------------------------------
# GoReleaser
# -----------------------------------------------------------------------------
dist/
# Testing
# -----------------------------------------------------------------------------
# Testing & Coverage
# -----------------------------------------------------------------------------
coverage/
coverage.out
*.xml
.trivy_logs/
.trivy_logs/trivy-report.txt
backend/coverage.txt
# CodeQL
codeql-db/
codeql-results.sarif
**.sarif
codeql-results-js.sarif
codeql-results-go.sarif
*.crdownload
.vscode/launch.json
# More CodeQL/analysis artifacts and DBs
# -----------------------------------------------------------------------------
# CodeQL & Security Scanning
# -----------------------------------------------------------------------------
codeql-db/
codeql-db-*/
codeql-db-js/
codeql-db-go/
codeql-agent-results/
codeql-custom-queries-*/
codeql-results*.sarif
codeql-*.sarif
*.sarif
.codeql/
.codeql/**
# Scripts (project-specific)
# -----------------------------------------------------------------------------
# Scripts & Temp Files (project-specific)
# -----------------------------------------------------------------------------
create_issues.sh
cookies.txt
cookies.txt.bak
test.caddyfile
# Project Documentation (keep important docs, ignore implementation notes)
ACME_STAGING_IMPLEMENTATION.md
# -----------------------------------------------------------------------------
# Project Documentation (implementation notes - not needed in repo)
# -----------------------------------------------------------------------------
*.md.bak
ACME_STAGING_IMPLEMENTATION.md*
ARCHITECTURE_PLAN.md
BULK_ACL_FEATURE.md
DOCKER_TASKS.md
DOCKER_TASKS.md*
DOCUMENTATION_POLISH_SUMMARY.md
GHCR_MIGRATION_SUMMARY.md
ISSUE_*_IMPLEMENTATION.md
ISSUE_*_IMPLEMENTATION.md*
PHASE_*_SUMMARY.md
PROJECT_BOARD_SETUP.md
PROJECT_PLANNING.md
SECURITY_IMPLEMENTATION_PLAN.md
VERSIONING_IMPLEMENTATION.md
backend/internal/api/handlers/import_handler.go.bak
# -----------------------------------------------------------------------------
# Import Directory (user uploads)
# -----------------------------------------------------------------------------
import/
test-results/charon.hatfieldhosted.com.har
test-results/local.har
.cache

View File

@@ -1,13 +1,4 @@
repos:
- repo: local
hooks:
- id: python-compile
name: python compile check
entry: tools/python_compile_check.sh
language: script
files: ".*\\.py$"
pass_filenames: false
always_run: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
@@ -45,6 +36,27 @@ repos:
language: system
files: '\.version$'
pass_filenames: false
- id: check-lfs-large-files
name: Prevent large files that are not tracked by LFS
entry: bash scripts/pre-commit-hooks/check-lfs-for-large-files.sh
language: system
pass_filenames: false
verbose: true
always_run: true
- id: block-codeql-db-commits
name: Prevent committing CodeQL DB artifacts
entry: bash scripts/pre-commit-hooks/block-codeql-db-commits.sh
language: system
pass_filenames: false
verbose: true
always_run: true
- id: block-data-backups-commit
name: Prevent committing data/backups files
entry: bash scripts/pre-commit-hooks/block-data-backups-commit.sh
language: system
pass_filenames: false
verbose: true
always_run: true
# === MANUAL/CI-ONLY HOOKS ===
# These are slow and should only run on-demand or in CI

43
.vscode/settings.json vendored
View File

@@ -1,36 +1,27 @@
{
"python-envs.pythonProjects": [
{
"path": "",
"envManager": "ms-python.python:venv",
"packageManager": "ms-python.python:pip"
}
]
,
"gopls": {
"buildFlags": ["-tags=ignore", "-mod=mod"],
"env": {
"GOWORK": "off",
"GOFLAGS": "-mod=mod",
"GOTOOLCHAIN": "none"
"staticcheck": true,
"analyses": {
"unusedparams": true,
"nilness": true
},
"directoryFilters": [
"-**/pkg/mod/**",
"-**/go/pkg/mod/**",
"-**/root/go/pkg/mod/**",
"-**/golang.org/toolchain@**"
]
"completeUnimported": true,
"matcher": "Fuzzy",
"verboseOutput": true
},
"go.buildFlags": ["-tags=ignore", "-mod=mod"],
"go.useLanguageServer": true,
"go.toolsEnvVars": {
"GOWORK": "off",
"GOFLAGS": "-mod=mod",
"GOTOOLCHAIN": "none"
"GOMODCACHE": "${workspaceFolder}/.cache/go/pkg/mod"
},
"go.buildOnSave": "workspace",
"go.lintOnSave": "package",
"go.formatTool": "gofmt",
"files.watcherExclude": {
"**/pkg/mod/**": true,
"**/go/pkg/mod/**": true,
"**/root/go/pkg/mod/**": true
"**/root/go/pkg/mod/**": true,
"**/backend/data/**": true,
"**/frontend/dist/**": true
},
"search.exclude": {
"**/pkg/mod/**": true,
@@ -39,5 +30,7 @@
},
"githubPullRequests.ignoredPullRequestBranches": [
"main"
]
],
// Toggle workspace-specific keybindings (used by .vscode/keybindings.json)
"charon.workspaceKeybindingsEnabled": true
}

101
.vscode/tasks.json vendored
View File

@@ -17,6 +17,37 @@
"group": "test",
"problemMatcher": []
},
{
"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" }
},
{
"label": "Git Remove Cached",
"type": "shell",
@@ -216,3 +247,73 @@
},
"problemMatcher": ["$go"]
}
,
{
"label": "Frontend: Lint Fix",
"type": "shell",
"command": "cd frontend && npm run lint -- --fix",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Lint: GolangCI-Lint Fix",
"type": "shell",
"command": "cd backend && docker run --rm -v $(pwd):/app:rw -w /app golangci/golangci-lint:latest golangci-lint run --fix -v",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": ["$go"]
},
{
"label": "Frontend: Run All Tests & Scans",
"dependsOn": [
"Frontend: Type Check",
"Frontend: Test Coverage",
"Run CodeQL Scan (Local)"
],
"dependsOrder": "sequence",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Backend: Run All Tests & Scans",
"dependsOn": [
"Backend: Go Test Coverage",
"Backend: Run Benchmarks (Quick)",
"Run Security Scan (govulncheck)",
"Lint: GolangCI-Lint",
"Lint: Go Race Detector"
],
"dependsOrder": "sequence",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "Lint: Apply Fixes",
"dependsOn": [
"Frontend: Lint Fix",
"Lint: GolangCI-Lint Fix",
"Lint: Hadolint (Dockerfile)",
"Run Pre-commit (Staged Files)"
],
"dependsOrder": "sequence",
"group": "test",
"presentation": {
"reveal": "always",
"panel": "new"
}
}
]
}

177
BULK_ACL_FEATURE.md Normal file
View File

@@ -0,0 +1,177 @@
# Bulk ACL Application Feature
## Overview
Implemented a bulk ACL (Access Control List) application feature that allows users to quickly apply or remove access lists from multiple proxy hosts at once, eliminating the need to edit each host individually.
## User Workflow Improvements
### Previous Workflow (Manual)
1. Create proxy hosts
2. Create access list
3. **Edit each host individually** to apply the ACL (tedious for many hosts)
### New Workflow (Bulk)
1. Create proxy hosts
2. Create access list
3. **Select multiple hosts** → Bulk Actions → Apply/Remove ACL (one operation)
## Implementation Details
### Backend (`backend/internal/api/handlers/proxy_host_handler.go`)
**New Endpoint**: `PUT /api/v1/proxy-hosts/bulk-update-acl`
**Request Body**:
```json
{
"host_uuids": ["uuid-1", "uuid-2", "uuid-3"],
"access_list_id": 42 // or null to remove ACL
}
```
**Response**:
```json
{
"updated": 2,
"errors": [
{"uuid": "uuid-3", "error": "proxy host not found"}
]
}
```
**Features**:
- Updates multiple hosts in a single database transaction
- Applies Caddy config once for all updates (efficient)
- Partial failure handling (returns both successes and errors)
- Validates host existence before applying ACL
- Supports both applying and removing ACLs (null = remove)
### Frontend
#### API Client (`frontend/src/api/proxyHosts.ts`)
```typescript
export const bulkUpdateACL = async (
hostUUIDs: string[],
accessListID: number | null
): Promise<BulkUpdateACLResponse>
```
#### React Query Hook (`frontend/src/hooks/useProxyHosts.ts`)
```typescript
const { bulkUpdateACL, isBulkUpdating } = useProxyHosts()
// Usage
await bulkUpdateACL(['uuid-1', 'uuid-2'], 42) // Apply ACL 42
await bulkUpdateACL(['uuid-1', 'uuid-2'], null) // Remove ACL
```
#### UI Components (`frontend/src/pages/ProxyHosts.tsx`)
**Multi-Select Checkboxes**:
- Checkbox column added to proxy hosts table
- "Select All" checkbox in table header
- Individual checkboxes per row
**Bulk Actions UI**:
- "Bulk Actions" button appears when hosts are selected
- Shows count of selected hosts
- Opens modal with ACL selection dropdown
**Modal Features**:
- Lists all enabled access lists
- "Remove Access List" option (sets null)
- Real-time feedback on success/failure
- Toast notifications for user feedback
## Testing
### Backend Tests (`proxy_host_handler_test.go`)
-`TestProxyHostHandler_BulkUpdateACL_Success` - Apply ACL to multiple hosts
-`TestProxyHostHandler_BulkUpdateACL_RemoveACL` - Remove ACL (null value)
-`TestProxyHostHandler_BulkUpdateACL_PartialFailure` - Mixed success/failure
-`TestProxyHostHandler_BulkUpdateACL_EmptyUUIDs` - Validation error
-`TestProxyHostHandler_BulkUpdateACL_InvalidJSON` - Malformed request
### Frontend Tests
**API Tests** (`proxyHosts-bulk.test.ts`):
- ✅ Apply ACL to multiple hosts
- ✅ Remove ACL with null value
- ✅ Handle partial failures
- ✅ Handle empty host list
- ✅ Propagate API errors
**Hook Tests** (`useProxyHosts-bulk.test.tsx`):
- ✅ Apply ACL via mutation
- ✅ Remove ACL via mutation
- ✅ Query invalidation after success
- ✅ Error handling
- ✅ Loading state tracking
**Test Results**:
- Backend: All tests passing (106+ tests)
- Frontend: All tests passing (132 tests)
## Usage Examples
### Example 1: Apply ACL to Multiple Hosts
```typescript
// Select hosts in UI
setSelectedHosts(new Set(['host-1-uuid', 'host-2-uuid', 'host-3-uuid']))
// User clicks "Bulk Actions" → Selects ACL from dropdown
await bulkUpdateACL(['host-1-uuid', 'host-2-uuid', 'host-3-uuid'], 5)
// Result: "Access list applied to 3 host(s)"
```
### Example 2: Remove ACL from Hosts
```typescript
// User selects "Remove Access List" from dropdown
await bulkUpdateACL(['host-1-uuid', 'host-2-uuid'], null)
// Result: "Access list removed from 2 host(s)"
```
### Example 3: Partial Failure Handling
```typescript
const result = await bulkUpdateACL(['valid-uuid', 'invalid-uuid'], 10)
// result = {
// updated: 1,
// errors: [{ uuid: 'invalid-uuid', error: 'proxy host not found' }]
// }
// Toast: "Updated 1 host(s), 1 failed"
```
## Benefits
1. **Time Savings**: Apply ACLs to dozens of hosts in one click vs. editing each individually
2. **User-Friendly**: Clear visual feedback with checkboxes and selection count
3. **Error Resilient**: Partial failures don't block the entire operation
4. **Efficient**: Single Caddy config reload for all updates
5. **Flexible**: Supports both applying and removing ACLs
6. **Well-Tested**: Comprehensive test coverage for all scenarios
## Future Enhancements (Optional)
- Add bulk ACL application from Access Lists page (when creating/editing ACL)
- Bulk enable/disable hosts
- Bulk delete hosts
- Bulk certificate assignment
- Filter hosts before selection (e.g., "Select all hosts without ACL")
## Related Files Modified
### Backend
- `backend/internal/api/handlers/proxy_host_handler.go` (+73 lines)
- `backend/internal/api/handlers/proxy_host_handler_test.go` (+140 lines)
### Frontend
- `frontend/src/api/proxyHosts.ts` (+19 lines)
- `frontend/src/hooks/useProxyHosts.ts` (+11 lines)
- `frontend/src/pages/ProxyHosts.tsx` (+95 lines)
- `frontend/src/api/__tests__/proxyHosts-bulk.test.ts` (+93 lines, new file)
- `frontend/src/hooks/__tests__/useProxyHosts-bulk.test.tsx` (+149 lines, new file)
**Total**: ~580 lines added (including tests)

View File

@@ -21,7 +21,7 @@ ARG CADDY_VERSION=2.10.2
ARG CADDY_IMAGE=alpine:3.23
# ---- Cross-Compilation Helpers ----
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.8.0 AS xx
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0 AS xx
# ---- Frontend Builder ----
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
@@ -122,6 +122,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
--with github.com/corazawaf/coraza-caddy/v2 \
--with github.com/hslatman/caddy-crowdsec-bouncer \
--with github.com/zhangjiayin/caddy-geoip2 \
--with github.com/mholt/caddy-ratelimit \
--output /tmp/caddy-temp || true; \
# Find the build directory
BUILDDIR=$(ls -td /tmp/buildenv_* 2>/dev/null | head -1); \
@@ -151,6 +152,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
--with github.com/corazawaf/coraza-caddy/v2 \
--with github.com/hslatman/caddy-crowdsec-bouncer \
--with github.com/zhangjiayin/caddy-geoip2 \
--with github.com/mholt/caddy-ratelimit \
--output /usr/bin/caddy; \
fi; \
rm -rf /tmp/buildenv_* /tmp/caddy-temp; \
@@ -175,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

View File

@@ -1,4 +1,4 @@
.PHONY: help install test build run clean docker-build docker-run release
.PHONY: help install test build run clean docker-build docker-run release go-check gopls-logs
# Default target
help:
@@ -16,6 +16,8 @@ help:
@echo " docker-dev - Run Docker in development mode"
@echo " release - Create a new semantic version release (interactive)"
@echo " dev - Run both backend and frontend in dev mode (requires tmux)"
@echo " go-check - Verify backend build readiness (runs scripts/check_go_build.sh)"
@echo " gopls-logs - Collect gopls diagnostics (runs scripts/gopls_collect.sh)"
@echo ""
@echo "Security targets:"
@echo " security-scan - Quick security scan (govulncheck on Go deps)"
@@ -29,6 +31,16 @@ install:
@echo "Installing frontend dependencies..."
cd frontend && npm install
# Install Go 1.25.5 system-wide and setup GOPATH/bin
install-go:
@echo "Installing Go 1.25.5 and gopls (requires sudo)"
sudo ./scripts/install-go-1.25.5.sh
# Clear Go and gopls caches
clear-go-cache:
@echo "Clearing Go and gopls caches"
./scripts/clear-go-cache.sh
# Run all tests
test:
@echo "Running backend tests..."
@@ -112,6 +124,12 @@ dev:
release:
@./scripts/release.sh
go-check:
./scripts/check_go_build.sh
gopls-logs:
./scripts/gopls_collect.sh
# Security scanning targets
security-scan:
@echo "Running security scan (govulncheck)..."

View File

@@ -0,0 +1,113 @@
# Security Services Implementation Plan
## Overview
This document outlines the plan to implement a modular Security Dashboard in Charon (previously 'CPM+'). The goal is to provide optional, high-value security integrations (CrowdSec, WAF, ACLs, Rate Limiting) while keeping the core Docker image lightweight.
## Core Philosophy
1. **Optionality**: All security services are disabled by default.
2. **Environment Driven**: Activation is controlled via `CHARON_SECURITY_*` environment variables (legacy `CPM_SECURITY_*` names supported for backward compatibility).
3. **Minimal Footprint**:
* Lightweight Caddy modules (WAF, Bouncers) are compiled into the binary (negligible size impact).
* Heavy standalone agents (e.g., CrowdSec Agent) are only installed at runtime if explicitly enabled in "Local" mode.
4. **Unified Dashboard**: A single pane of glass in the UI to view status and configuration.
---
## 1. Environment Variables
We will introduce a new set of environment variables to control these services.
| Variable | Values | Description |
| :--- | :--- | :--- |
| `CHARON_SECURITY_CROWDSEC_MODE` (legacy `CPM_SECURITY_CROWDSEC_MODE`) | `disabled` (default), `local`, `external` | `local` installs agent inside container; `external` uses remote agent. |
| `CPM_SECURITY_CROWDSEC_API_URL` | URL (e.g., `http://crowdsec:8080`) | Required if mode is `external`. |
| `CPM_SECURITY_CROWDSEC_API_KEY` | String | Required if mode is `external`. |
| `CPM_SECURITY_WAF_MODE` | `disabled` (default), `enabled` | Enables Coraza WAF with OWASP Core Rule Set (CRS). |
| `CPM_SECURITY_RATELIMIT_MODE` | `disabled` (default), `enabled` | Enables global rate limiting controls. |
| `CPM_SECURITY_ACL_MODE` | `disabled` (default), `enabled` | Enables IP-based Access Control Lists. |
---
## 2. Backend Implementation
### A. Dockerfile Updates
We need to compile the necessary Caddy modules into our binary. This adds minimal size overhead but enables the features natively.
* **Action**: Update `Dockerfile` `caddy-builder` stage to include:
* `github.com/corazawaf/coraza-caddy/v2` (WAF)
* `github.com/hslatman/caddy-crowdsec-bouncer` (CrowdSec Bouncer)
### B. Configuration Management (`internal/config`)
* **Action**: Update `Config` struct to parse `CHARON_SECURITY_*` variables while still accepting `CPM_SECURITY_*` as legacy fallbacks.
* **Action**: Create `SecurityConfig` struct to hold these values.
### C. Runtime Installation (`docker-entrypoint.sh`)
To satisfy the "install locally" requirement for CrowdSec without bloating the image:
* **Action**: Modify `docker-entrypoint.sh` to check `CHARON_SECURITY_CROWDSEC_MODE` (and fallback to `CPM_SECURITY_CROWDSEC_MODE`).
* **Logic**: If `local`, execute `apk add --no-cache crowdsec` (and dependencies) before starting the app. This keeps the base image small for users who don't use it.
### D. API Endpoints (`internal/api`)
* **New Endpoint**: `GET /api/v1/security/status`
* Returns the enabled/disabled state of each service.
* Returns basic metrics if available (e.g., "WAF: Active", "CrowdSec: Connected").
---
## 3. Frontend Implementation
### A. Navigation
* **Action**: Add "Security" item to the Sidebar in `Layout.tsx`.
### B. Security Dashboard (`src/pages/Security.tsx`)
* **Layout**: Grid of cards representing each service.
* **Empty State**: If all services are disabled, show a clean "Security Not Enabled" state with a link to the GitHub Pages documentation on how to enable them.
### C. Service Cards
1. **CrowdSec Card**:
* **Status**: Active (Local/External) / Disabled.
* **Content**: If Local, show basic stats (last push, alerts). If External, show connection status.
* **Action**: Link to CrowdSec Console or Dashboard.
2. **WAF Card**:
* **Status**: Active / Disabled.
* **Content**: "OWASP CRS Loaded".
3. **Access Control Lists (ACL)**:
* **Status**: Active / Disabled.
* **Action**: "Manage Blocklists" (opens modal/page to edit IP lists).
4. **Rate Limiting**:
* **Status**: Active / Disabled.
* **Action**: "Configure Limits" (opens modal to set global requests/second).
---
## 4. Service-Specific Logic
### CrowdSec
* **Local**:
* Installs CrowdSec agent via `apk`.
* Generates `acquis.yaml` to read Caddy logs.
* Configures Caddy bouncer to talk to `localhost:8080`.
* **External**:
* Configures Caddy bouncer to talk to `CPM_SECURITY_CROWDSEC_API_URL`.
### WAF (Coraza)
* **Implementation**:
* When enabled, inject `coraza_waf` directive into the global Caddyfile or per-host.
* Use default OWASP Core Rule Set (CRS).
### IP ACLs
* **Implementation**:
* Create a snippet `(ip_filter)` in Caddyfile.
* Use `@matcher` with `remote_ip` to block/allow IPs.
* UI allows adding CIDR ranges to this list.
### Rate Limiting
* **Implementation**:
* Use `rate_limit` directive.
* Allow user to define "zones" (e.g., API, Static) in the UI.
---
## 5. Documentation
* **New Doc**: `docs/security.md`
* **Content**:
* Explanation of each service.
* How to configure Env Vars.
* Trade-offs of "Local" CrowdSec (startup time vs convenience).

View File

@@ -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

View File

@@ -20,6 +20,9 @@ linters:
enabled-tags:
- diagnostic
- performance
- style
- opinionated
- experimental
disabled-checks:
- whyNoLint
- wrapperFunc

View File

@@ -23,10 +23,10 @@ import (
func main() {
// Setup logging with rotation
logDir := "/app/data/logs"
if err := os.MkdirAll(logDir, 0755); err != nil {
if err := os.MkdirAll(logDir, 0o755); err != nil {
// Fallback to local directory if /app/data fails (e.g. local dev)
logDir = "data/logs"
_ = os.MkdirAll(logDir, 0755)
_ = os.MkdirAll(logDir, 0o755)
}
logFile := filepath.Join(logDir, "charon.log")

View File

@@ -5,6 +5,7 @@ go 1.25.5
require (
github.com/containrrr/shoutrrr v0.8.0
github.com/docker/docker v28.5.2+incompatible
github.com/gin-contrib/gzip v1.2.5
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
@@ -12,7 +13,7 @@ require (
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.45.0
golang.org/x/crypto v0.46.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
@@ -21,7 +22,8 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -34,14 +36,14 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -67,8 +69,8 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
@@ -77,17 +79,13 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

View File

@@ -4,8 +4,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -37,8 +39,10 @@ github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
@@ -54,12 +58,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
@@ -141,10 +145,10 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -187,27 +191,29 @@ go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOV
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
@@ -218,8 +224,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -0,0 +1,34 @@
//go:build integration
// +build integration
package integration
import (
"context"
"os/exec"
"strings"
"testing"
"time"
)
// TestCrowdsecIntegration runs scripts/crowdsec_integration.sh and ensures it completes successfully.
func TestCrowdsecIntegration(t *testing.T) {
t.Parallel()
cmd := exec.CommandContext(context.Background(), "bash", "./scripts/crowdsec_integration.sh")
// Ensure script runs from repo root so relative paths in scripts work reliably
cmd.Dir = "../../"
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
cmd = exec.CommandContext(ctx, "bash", "./scripts/crowdsec_integration.sh")
cmd.Dir = "../../"
out, err := cmd.CombinedOutput()
t.Logf("crowdsec_integration script output:\n%s", string(out))
if err != nil {
t.Fatalf("crowdsec integration failed: %v", err)
}
if !strings.Contains(string(out), "Apply response: ") {
t.Fatalf("unexpected script output, expected Apply response in output")
}
}

View File

@@ -16,7 +16,7 @@ import (
func TestAccessListHandler_Get_InvalidID(t *testing.T) {
router, _ := setupAccessListTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/access-lists/invalid", nil)
req := httptest.NewRequest(http.MethodGet, "/access-lists/invalid", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -53,7 +53,7 @@ func TestAccessListHandler_Update_InvalidJSON(t *testing.T) {
func TestAccessListHandler_Delete_InvalidID(t *testing.T) {
router, _ := setupAccessListTestRouter(t)
req := httptest.NewRequest(http.MethodDelete, "/access-lists/invalid", nil)
req := httptest.NewRequest(http.MethodDelete, "/access-lists/invalid", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -98,7 +98,7 @@ func TestAccessListHandler_List_DBError(t *testing.T) {
handler := NewAccessListHandler(db)
router.GET("/access-lists", handler.List)
req := httptest.NewRequest(http.MethodGet, "/access-lists", nil)
req := httptest.NewRequest(http.MethodGet, "/access-lists", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -115,7 +115,7 @@ func TestAccessListHandler_Get_DBError(t *testing.T) {
handler := NewAccessListHandler(db)
router.GET("/access-lists/:id", handler.Get)
req := httptest.NewRequest(http.MethodGet, "/access-lists/1", nil)
req := httptest.NewRequest(http.MethodGet, "/access-lists/1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -138,7 +138,7 @@ func TestAccessListHandler_Delete_InternalError(t *testing.T) {
acl := models.AccessList{UUID: "test-uuid", Name: "Test", Type: "whitelist"}
db.Create(&acl)
req := httptest.NewRequest(http.MethodDelete, "/access-lists/1", nil)
req := httptest.NewRequest(http.MethodDelete, "/access-lists/1", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@@ -129,7 +129,7 @@ func TestAccessListHandler_List(t *testing.T) {
db.Create(&acls[i])
}
req := httptest.NewRequest(http.MethodGet, "/access-lists", nil)
req := httptest.NewRequest(http.MethodGet, "/access-lists", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -173,7 +173,7 @@ func TestAccessListHandler_Get(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/access-lists/"+tt.id, nil)
req := httptest.NewRequest(http.MethodGet, "/access-lists/"+tt.id, http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -313,7 +313,7 @@ func TestAccessListHandler_Delete(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/access-lists/"+tt.id, nil)
req := httptest.NewRequest(http.MethodDelete, "/access-lists/"+tt.id, http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -393,7 +393,7 @@ func TestAccessListHandler_TestIP(t *testing.T) {
func TestAccessListHandler_GetTemplates(t *testing.T) {
router, _ := setupAccessListTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/access-lists/templates", nil)
req := httptest.NewRequest(http.MethodGet, "/access-lists/templates", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
@@ -143,7 +144,7 @@ func TestSecurityHandler_GetConfig_InternalError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/security/config", nil)
c.Request = httptest.NewRequest("GET", "/security/config", http.NoBody)
h.GetConfig(c)
@@ -186,7 +187,7 @@ func TestSecurityHandler_GenerateBreakGlass_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/security/breakglass", nil)
c.Request = httptest.NewRequest("POST", "/security/breakglass", http.NoBody)
h.GenerateBreakGlass(c)
@@ -205,7 +206,7 @@ func TestSecurityHandler_ListDecisions_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/security/decisions", nil)
c.Request = httptest.NewRequest("GET", "/security/decisions", http.NoBody)
h.ListDecisions(c)
@@ -224,7 +225,7 @@ func TestSecurityHandler_ListRuleSets_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/security/rulesets", nil)
c.Request = httptest.NewRequest("GET", "/security/rulesets", http.NoBody)
h.ListRuleSets(c)
@@ -445,19 +446,19 @@ func TestImportHandler_UploadMulti_PathTraversal(t *testing.T) {
// Logs Handler Download error coverage
func setupLogsDownloadTest(t *testing.T) (*LogsHandler, string) {
func setupLogsDownloadTest(t *testing.T) (h *LogsHandler, logsDir string) {
t.Helper()
tmpDir := t.TempDir()
dataDir := filepath.Join(tmpDir, "data")
os.MkdirAll(dataDir, 0o755)
logsDir := filepath.Join(dataDir, "logs")
logsDir = filepath.Join(dataDir, "logs")
os.MkdirAll(logsDir, 0o755)
dbPath := filepath.Join(dataDir, "charon.db")
cfg := &config.Config{DatabasePath: dbPath}
svc := services.NewLogService(cfg)
h := NewLogsHandler(svc)
h = NewLogsHandler(svc)
return h, logsDir
}
@@ -469,7 +470,7 @@ func TestLogsHandler_Download_PathTraversal(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "../../../etc/passwd"}}
c.Request = httptest.NewRequest("GET", "/logs/../../../etc/passwd/download", nil)
c.Request = httptest.NewRequest("GET", "/logs/../../../etc/passwd/download", http.NoBody)
h.Download(c)
@@ -484,7 +485,7 @@ func TestLogsHandler_Download_NotFound(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "nonexistent.log"}}
c.Request = httptest.NewRequest("GET", "/logs/nonexistent.log/download", nil)
c.Request = httptest.NewRequest("GET", "/logs/nonexistent.log/download", http.NoBody)
h.Download(c)
@@ -502,7 +503,7 @@ func TestLogsHandler_Download_Success(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "test.log"}}
c.Request = httptest.NewRequest("GET", "/logs/test.log/download", nil)
c.Request = httptest.NewRequest("GET", "/logs/test.log/download", http.NoBody)
h.Download(c)
@@ -574,7 +575,7 @@ func TestBackupHandler_List_ServiceError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/backups", nil)
c.Request = httptest.NewRequest("GET", "/backups", http.NoBody)
h.List(c)
@@ -602,7 +603,7 @@ func TestBackupHandler_Delete_PathTraversal(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "../../../etc/passwd"}}
c.Request = httptest.NewRequest("DELETE", "/backups/../../../etc/passwd", nil)
c.Request = httptest.NewRequest("DELETE", "/backups/../../../etc/passwd", http.NoBody)
h.Delete(c)
@@ -641,7 +642,7 @@ func TestBackupHandler_Delete_InternalError2(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "test.zip"}}
c.Request = httptest.NewRequest("DELETE", "/backups/test.zip", nil)
c.Request = httptest.NewRequest("DELETE", "/backups/test.zip", http.NoBody)
h.Delete(c)
@@ -722,7 +723,7 @@ func TestHealthHandler_Basic(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/health", nil)
c.Request = httptest.NewRequest("GET", "/health", http.NoBody)
HealthHandler(c)
@@ -753,7 +754,7 @@ func TestBackupHandler_Create_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/backups", nil)
c.Request = httptest.NewRequest("POST", "/backups", http.NoBody)
h.Create(c)
@@ -782,7 +783,7 @@ func TestSettingsHandler_GetSettings_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/settings", nil)
c.Request = httptest.NewRequest("GET", "/settings", http.NoBody)
h.GetSettings(c)

View File

@@ -137,7 +137,7 @@ func TestAuthHandler_Logout(t *testing.T) {
r := gin.New()
r.POST("/logout", handler.Logout)
req := httptest.NewRequest("POST", "/logout", nil)
req := httptest.NewRequest("POST", "/logout", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -171,7 +171,7 @@ func TestAuthHandler_Me(t *testing.T) {
})
r.GET("/me", handler.Me)
req := httptest.NewRequest("GET", "/me", nil)
req := httptest.NewRequest("GET", "/me", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -194,7 +194,7 @@ func TestAuthHandler_Me_NotFound(t *testing.T) {
})
r.GET("/me", handler.Me)
req := httptest.NewRequest("GET", "/me", nil)
req := httptest.NewRequest("GET", "/me", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -319,7 +319,7 @@ func TestAuthHandler_Verify_NoCookie(t *testing.T) {
r := gin.New()
r.GET("/verify", handler.Verify)
req := httptest.NewRequest("GET", "/verify", nil)
req := httptest.NewRequest("GET", "/verify", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -333,7 +333,7 @@ func TestAuthHandler_Verify_InvalidToken(t *testing.T) {
r := gin.New()
r.GET("/verify", handler.Verify)
req := httptest.NewRequest("GET", "/verify", nil)
req := httptest.NewRequest("GET", "/verify", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid-token"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -362,7 +362,7 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) {
r := gin.New()
r.GET("/verify", handler.Verify)
req := httptest.NewRequest("GET", "/verify", nil)
req := httptest.NewRequest("GET", "/verify", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -391,7 +391,7 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) {
r := gin.New()
r.GET("/verify", handler.Verify)
req := httptest.NewRequest("GET", "/verify", nil)
req := httptest.NewRequest("GET", "/verify", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -420,7 +420,7 @@ func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
r := gin.New()
r.GET("/verify", handler.Verify)
req := httptest.NewRequest("GET", "/verify", nil)
req := httptest.NewRequest("GET", "/verify", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -459,7 +459,7 @@ func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
r := gin.New()
r.GET("/verify", handler.Verify)
req := httptest.NewRequest("GET", "/verify", nil)
req := httptest.NewRequest("GET", "/verify", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
req.Header.Set("X-Forwarded-Host", "app.example.com")
w := httptest.NewRecorder()
@@ -474,7 +474,7 @@ func TestAuthHandler_VerifyStatus_NotAuthenticated(t *testing.T) {
r := gin.New()
r.GET("/status", handler.VerifyStatus)
req := httptest.NewRequest("GET", "/status", nil)
req := httptest.NewRequest("GET", "/status", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -490,7 +490,7 @@ func TestAuthHandler_VerifyStatus_InvalidToken(t *testing.T) {
r := gin.New()
r.GET("/status", handler.VerifyStatus)
req := httptest.NewRequest("GET", "/status", nil)
req := httptest.NewRequest("GET", "/status", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -520,7 +520,7 @@ func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
r := gin.New()
r.GET("/status", handler.VerifyStatus)
req := httptest.NewRequest("GET", "/status", nil)
req := httptest.NewRequest("GET", "/status", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -553,7 +553,7 @@ func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
r := gin.New()
r.GET("/status", handler.VerifyStatus)
req := httptest.NewRequest("GET", "/status", nil)
req := httptest.NewRequest("GET", "/status", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -570,7 +570,7 @@ func TestAuthHandler_GetAccessibleHosts_Unauthorized(t *testing.T) {
r := gin.New()
r.GET("/hosts", handler.GetAccessibleHosts)
req := httptest.NewRequest("GET", "/hosts", nil)
req := httptest.NewRequest("GET", "/hosts", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -604,7 +604,7 @@ func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
})
r.GET("/hosts", handler.GetAccessibleHosts)
req := httptest.NewRequest("GET", "/hosts", nil)
req := httptest.NewRequest("GET", "/hosts", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -640,7 +640,7 @@ func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
})
r.GET("/hosts", handler.GetAccessibleHosts)
req := httptest.NewRequest("GET", "/hosts", nil)
req := httptest.NewRequest("GET", "/hosts", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -679,7 +679,7 @@ func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
})
r.GET("/hosts", handler.GetAccessibleHosts)
req := httptest.NewRequest("GET", "/hosts", nil)
req := httptest.NewRequest("GET", "/hosts", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -701,7 +701,7 @@ func TestAuthHandler_GetAccessibleHosts_UserNotFound(t *testing.T) {
})
r.GET("/hosts", handler.GetAccessibleHosts)
req := httptest.NewRequest("GET", "/hosts", nil)
req := httptest.NewRequest("GET", "/hosts", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -714,7 +714,7 @@ func TestAuthHandler_CheckHostAccess_Unauthorized(t *testing.T) {
r := gin.New()
r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
req := httptest.NewRequest("GET", "/hosts/1/access", nil)
req := httptest.NewRequest("GET", "/hosts/1/access", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -735,7 +735,7 @@ func TestAuthHandler_CheckHostAccess_InvalidHostID(t *testing.T) {
})
r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
req := httptest.NewRequest("GET", "/hosts/invalid/access", nil)
req := httptest.NewRequest("GET", "/hosts/invalid/access", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -764,7 +764,7 @@ func TestAuthHandler_CheckHostAccess_Allowed(t *testing.T) {
})
r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
req := httptest.NewRequest("GET", "/hosts/1/access", nil)
req := httptest.NewRequest("GET", "/hosts/1/access", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -796,7 +796,7 @@ func TestAuthHandler_CheckHostAccess_Denied(t *testing.T) {
})
r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
req := httptest.NewRequest("GET", "/hosts/1/access", nil)
req := httptest.NewRequest("GET", "/hosts/1/access", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

View File

@@ -39,7 +39,7 @@ func TestBackupHandlerSanitizesFilename(t *testing.T) {
// Create a malicious filename with newline and path components
malicious := "../evil\nname"
c.Request = httptest.NewRequest(http.MethodGet, "/backups/"+strings.ReplaceAll(malicious, "\n", "%0A")+"/restore", nil)
c.Request = httptest.NewRequest(http.MethodGet, "/backups/"+strings.ReplaceAll(malicious, "\n", "%0A")+"/restore", http.NoBody)
// Call handler directly with the test context
h.Restore(c)

View File

@@ -31,12 +31,12 @@ func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string
// So if DatabasePath is /tmp/data/charon.db, DataDir is /tmp/data, BackupDir is /tmp/data/backups.
dataDir := filepath.Join(tmpDir, "data")
err = os.MkdirAll(dataDir, 0755)
err = os.MkdirAll(dataDir, 0o755)
require.NoError(t, err)
dbPath := filepath.Join(dataDir, "charon.db")
// Create a dummy DB file to back up
err = os.WriteFile(dbPath, []byte("dummy db content"), 0644)
err = os.WriteFile(dbPath, []byte("dummy db content"), 0o644)
require.NoError(t, err)
cfg := &config.Config{
@@ -72,7 +72,7 @@ func TestBackupLifecycle(t *testing.T) {
defer os.RemoveAll(tmpDir)
// 1. List backups (should be empty)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -80,7 +80,7 @@ func TestBackupLifecycle(t *testing.T) {
// ...
// 2. Create backup
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
@@ -92,20 +92,20 @@ func TestBackupLifecycle(t *testing.T) {
require.NotEmpty(t, filename)
// 3. List backups (should have 1)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// Verify list contains filename
// 4. Restore backup
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", nil)
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 5. Download backup
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -113,13 +113,13 @@ func TestBackupLifecycle(t *testing.T) {
// require.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
// 6. Delete backup
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 7. List backups (should be empty again)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -128,19 +128,19 @@ func TestBackupLifecycle(t *testing.T) {
require.Empty(t, list)
// 8. Delete non-existent backup
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 9. Restore non-existent backup
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/missing.zip/restore", nil)
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/missing.zip/restore", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 10. Download non-existent backup
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/missing.zip/download", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/missing.zip/download", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
@@ -154,7 +154,7 @@ func TestBackupHandler_Errors(t *testing.T) {
// Note: Service now handles missing dir gracefully by returning empty list
os.RemoveAll(svc.BackupDir)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -163,7 +163,7 @@ func TestBackupHandler_Errors(t *testing.T) {
require.Empty(t, list)
// 4. Delete Error (Not Found)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
@@ -174,13 +174,13 @@ func TestBackupHandler_List_Success(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Create a backup first
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
// Now list should return it
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -196,7 +196,7 @@ func TestBackupHandler_Create_Success(t *testing.T) {
router, _, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
@@ -212,7 +212,7 @@ func TestBackupHandler_Download_Success(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Create backup
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
@@ -222,7 +222,7 @@ func TestBackupHandler_Download_Success(t *testing.T) {
filename := result["filename"]
// Download it
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -234,19 +234,19 @@ func TestBackupHandler_PathTraversal(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Try path traversal in Delete
req := httptest.NewRequest(http.MethodDelete, "/api/v1/backups/../../../etc/passwd", nil)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/backups/../../../etc/passwd", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Try path traversal in Download
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/../../../etc/passwd/download", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/../../../etc/passwd/download", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Contains(t, []int{http.StatusBadRequest, http.StatusNotFound}, resp.Code)
// Try path traversal in Restore
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/../../../etc/passwd/restore", nil)
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/../../../etc/passwd/restore", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
@@ -257,7 +257,7 @@ func TestBackupHandler_Download_InvalidPath(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Request with path traversal attempt
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups/../invalid/download", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups/../invalid/download", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should be BadRequest due to path validation failure
@@ -269,10 +269,10 @@ func TestBackupHandler_Create_ServiceError(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Remove write permissions on backup dir to force create error
os.Chmod(svc.BackupDir, 0444)
defer os.Chmod(svc.BackupDir, 0755)
os.Chmod(svc.BackupDir, 0o444)
defer os.Chmod(svc.BackupDir, 0o755)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should fail with 500 due to permission error
@@ -284,7 +284,7 @@ func TestBackupHandler_Delete_InternalError(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Create a backup first
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
@@ -294,10 +294,10 @@ func TestBackupHandler_Delete_InternalError(t *testing.T) {
filename := result["filename"]
// Make backup dir read-only to cause delete error (not NotExist)
os.Chmod(svc.BackupDir, 0444)
defer os.Chmod(svc.BackupDir, 0755)
os.Chmod(svc.BackupDir, 0o444)
defer os.Chmod(svc.BackupDir, 0o755)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should fail with 500 due to permission error (not 404)
@@ -309,7 +309,7 @@ func TestBackupHandler_Restore_InternalError(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Create a backup first
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
@@ -319,10 +319,10 @@ func TestBackupHandler_Restore_InternalError(t *testing.T) {
filename := result["filename"]
// Make data dir read-only to cause restore error
os.Chmod(svc.DataDir, 0444)
defer os.Chmod(svc.DataDir, 0755)
os.Chmod(svc.DataDir, 0o444)
defer os.Chmod(svc.DataDir, 0o755)
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", nil)
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should fail with 500 due to permission error

View File

@@ -70,7 +70,7 @@ func BenchmarkSecurityHandler_GetStatus(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -93,7 +93,7 @@ func BenchmarkSecurityHandler_GetStatus_NoSettings(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -126,7 +126,7 @@ func BenchmarkSecurityHandler_ListDecisions(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", nil)
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -159,7 +159,7 @@ func BenchmarkSecurityHandler_ListRuleSets(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/rulesets", nil)
req := httptest.NewRequest("GET", "/api/v1/security/rulesets", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -254,7 +254,7 @@ func BenchmarkSecurityHandler_GetConfig(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/config", nil)
req := httptest.NewRequest("GET", "/api/v1/security/config", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -323,7 +323,7 @@ func BenchmarkSecurityHandler_GetStatus_Parallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -366,7 +366,7 @@ func BenchmarkSecurityHandler_ListDecisions_Parallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", nil)
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -453,7 +453,7 @@ func BenchmarkSecurityHandler_ManySettingsLookups(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {

View File

@@ -4,9 +4,12 @@ import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
@@ -18,26 +21,32 @@ type BackupServiceInterface interface {
DeleteBackup(filename string) error
GetBackupPath(filename string) (string, error)
RestoreBackup(filename string) error
GetAvailableSpace() (int64, error)
}
type CertificateHandler struct {
service *services.CertificateService
backupService BackupServiceInterface
notificationService *services.NotificationService
// Rate limiting for notifications
notificationMu sync.Mutex
lastNotificationTime map[uint]time.Time
}
func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
return &CertificateHandler{
service: service,
backupService: backupService,
notificationService: ns,
service: service,
backupService: backupService,
notificationService: ns,
lastNotificationTime: make(map[uint]time.Time),
}
}
func (h *CertificateHandler) List(c *gin.Context) {
certs, err := h.service.ListCertificates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithError(err).Error("failed to list certificates")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list certificates"})
return
}
@@ -77,14 +86,22 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
return
}
defer func() { _ = certSrc.Close() }()
defer func() {
if err := certSrc.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close certificate file")
}
}()
keySrc, err := keyFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
return
}
defer func() { _ = keySrc.Close() }()
defer func() {
if err := keySrc.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close key file")
}
}()
// Read to string
// Limit size to avoid DoS (e.g. 1MB)
@@ -98,7 +115,8 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
logger.Log().WithError(err).Error("failed to upload certificate")
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload certificate"})
return
}
@@ -127,9 +145,16 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
return
}
// Validate ID range
if id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
// Check if certificate is in use before proceeding
inUse, err := h.service.IsCertificateInUse(uint(id))
if err != nil {
logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to check certificate usage")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
return
}
@@ -140,7 +165,17 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
// Create backup before deletion
if h.backupService != nil {
// Check disk space before backup (require at least 100MB free)
if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil {
logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup")
} else if availableSpace < 100*1024*1024 {
logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup")
c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"})
return
}
if _, err := h.backupService.CreateBackup(); err != nil {
logger.Log().WithError(err).Error("failed to create backup before deletion")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"})
return
}
@@ -152,21 +187,31 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to delete certificate")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
return
}
// Send Notification
// Send Notification with rate limiting (1 per cert per 10 seconds)
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
h.notificationMu.Lock()
lastTime, exists := h.lastNotificationTime[uint(id)]
if !exists || time.Since(lastTime) > 10*time.Second {
h.lastNotificationTime[uint(id)] = time.Now()
h.notificationMu.Unlock()
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
} else {
h.notificationMu.Unlock()
logger.Log().WithField("certificate_id", id).Debug("notification rate limited")
}
}
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})

View File

@@ -21,11 +21,12 @@ func TestCertificateHandler_List_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", nil)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -38,11 +39,12 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/invalid", nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/invalid", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -56,11 +58,12 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/9999", nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/9999", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -78,6 +81,7 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Wait for background sync goroutine to complete to avoid race with -race flag
// NewCertificateService spawns a goroutine that immediately queries the DB
@@ -95,7 +99,7 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) {
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -115,11 +119,12 @@ func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -137,11 +142,12 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", nil)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

View File

@@ -0,0 +1,208 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// TestCertificateHandler_Delete_RequiresAuth tests that delete requires authentication
func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
// Add a middleware that rejects all unauthenticated requests
r.Use(func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
})
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/1", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
}
}
// TestCertificateHandler_List_RequiresAuth tests that list requires authentication
func TestCertificateHandler_List_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
// Add a middleware that rejects all unauthenticated requests
r.Use(func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
})
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
}
}
// TestCertificateHandler_Upload_RequiresAuth tests that upload requires authentication
func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
// Add a middleware that rejects all unauthenticated requests
r.Use(func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
})
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
req := httptest.NewRequest(http.MethodPost, "/api/certificates", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
}
}
// TestCertificateHandler_Delete_DiskSpaceCheck tests the disk space check before backup
func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
// Create a certificate
cert := models.SSLCertificate{
UUID: "test-cert",
Name: "test",
Provider: "custom",
Domains: "test.com",
}
if err := db.Create(&cert).Error; err != nil {
t.Fatalf("failed to create cert: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock backup service that reports low disk space
mockBackup := &mockBackupService{
availableSpaceFunc: func() (int64, error) {
return 50 * 1024 * 1024, nil // 50MB (less than 100MB required)
},
}
h := NewCertificateHandler(svc, mockBackup, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInsufficientStorage {
t.Fatalf("expected 507 Insufficient Storage with low disk space, got %d", w.Code)
}
}
// TestCertificateHandler_Delete_NotificationRateLimiting tests rate limiting
func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
// Create certificates
cert1 := models.SSLCertificate{UUID: "test-1", Name: "test1", Provider: "custom", Domains: "test1.com"}
cert2 := models.SSLCertificate{UUID: "test-2", Name: "test2", Provider: "custom", Domains: "test2.com"}
if err := db.Create(&cert1).Error; err != nil {
t.Fatalf("failed to create cert1: %v", err)
}
if err := db.Create(&cert2).Error; err != nil {
t.Fatalf("failed to create cert2: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
mockBackup := &mockBackupService{
createFunc: func() (string, error) {
return "backup.zip", nil
},
}
h := NewCertificateHandler(svc, mockBackup, nil)
r.DELETE("/api/certificates/:id", h.Delete)
// Delete first cert
req1 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert1.ID), http.NoBody)
w1 := httptest.NewRecorder()
r.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first delete failed: got %d", w1.Code)
}
// Delete second cert (different ID, should not be rate limited)
req2 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert2.ID), http.NoBody)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second delete failed: got %d", w2.Code)
}
// The test passes if both deletions succeed
// Rate limiting is per-certificate ID, so different certs should not interfere
}

View File

@@ -24,10 +24,20 @@ import (
"github.com/Wikid82/charon/backend/internal/services"
)
// mockAuthMiddleware adds a mock user to the context for testing
func mockAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user", map[string]interface{}{"id": 1, "username": "testuser"})
c.Next()
}
}
func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
@@ -60,7 +70,7 @@ func TestDeleteCertificate_InUse(t *testing.T) {
r := setupCertTestRouter(t, db)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -92,6 +102,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock BackupService
@@ -106,7 +117,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) {
h := NewCertificateHandler(svc, mockBackupService, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -145,6 +156,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock BackupService that fails
@@ -157,7 +169,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) {
h := NewCertificateHandler(svc, mockBackupService, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -198,6 +210,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock BackupService
@@ -212,7 +225,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
h := NewCertificateHandler(svc, mockBackupService, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), nil)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -227,7 +240,8 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
// Mock BackupService for testing
type mockBackupService struct {
createFunc func() (string, error)
createFunc func() (string, error)
availableSpaceFunc func() (int64, error)
}
func (m *mockBackupService) CreateBackup() (string, error) {
@@ -253,6 +267,14 @@ func (m *mockBackupService) RestoreBackup(filename string) error {
return fmt.Errorf("not implemented")
}
func (m *mockBackupService) GetAvailableSpace() (int64, error) {
if m.availableSpaceFunc != nil {
return m.availableSpaceFunc()
}
// Default: return 1GB available
return 1024 * 1024 * 1024, nil
}
// Test List handler
func TestCertificateHandler_List(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
@@ -266,11 +288,13 @@ func TestCertificateHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", nil)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -292,6 +316,7 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -319,6 +344,7 @@ func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -349,6 +375,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -376,6 +403,7 @@ func TestCertificateHandler_Upload_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
// Create a mock CertificateService that returns a created certificate
// Create a temporary services.CertificateService with a temp dir and DB
@@ -408,7 +436,7 @@ func TestCertificateHandler_Upload_Success(t *testing.T) {
}
}
func generateSelfSignedCertPEM() (string, string, error) {
func generateSelfSignedCertPEM() (certPEM, keyPEM string, err error) {
// generate RSA key
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -430,21 +458,13 @@ func generateSelfSignedCertPEM() (string, string, error) {
if err != nil {
return "", "", err
}
certPEM := new(bytes.Buffer)
pem.Encode(certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := new(bytes.Buffer)
pem.Encode(keyPEM, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM.String(), keyPEM.String(), nil
certBuf := new(bytes.Buffer)
pem.Encode(certBuf, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyBuf := new(bytes.Buffer)
pem.Encode(keyBuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
certPEM = certBuf.String()
keyPEM = keyBuf.String()
return certPEM, keyPEM, nil
}
// mockCertificateService implements minimal interface for Upload handler tests
type mockCertificateService struct {
uploadFunc func(name, cert, key string) (*models.SSLCertificate, error)
}
func (m *mockCertificateService) UploadCertificate(name, cert, key string) (*models.SSLCertificate, error) {
if m.uploadFunc != nil {
return m.uploadFunc(name, cert, key)
}
return nil, fmt.Errorf("not implemented")
}
// Note: mockCertificateService removed — helper tests now use real service instances or testify mocks inlined where required.

View File

@@ -36,7 +36,7 @@ func TestBackupHandlerQuick(t *testing.T) {
// List
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/backups", nil)
req := httptest.NewRequest(http.MethodGet, "/backups", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
@@ -44,7 +44,7 @@ func TestBackupHandlerQuick(t *testing.T) {
// Create (backup)
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/backups", nil)
req2 := httptest.NewRequest(http.MethodPost, "/backups", http.NoBody)
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusCreated {
t.Fatalf("create expected 201 got %d", w2.Code)
@@ -59,7 +59,7 @@ func TestBackupHandlerQuick(t *testing.T) {
// Delete missing
w3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodDelete, "/backups/missing", nil)
req3 := httptest.NewRequest(http.MethodDelete, "/backups/missing", http.NoBody)
r.ServeHTTP(w3, req3)
if w3.Code != http.StatusNotFound {
t.Fatalf("delete missing expected 404 got %d", w3.Code)
@@ -67,7 +67,7 @@ func TestBackupHandlerQuick(t *testing.T) {
// Download missing
w4 := httptest.NewRecorder()
req4 := httptest.NewRequest(http.MethodGet, "/backups/missing", nil)
req4 := httptest.NewRequest(http.MethodGet, "/backups/missing", http.NoBody)
r.ServeHTTP(w4, req4)
if w4.Code != http.StatusNotFound {
t.Fatalf("download missing expected 404 got %d", w4.Code)
@@ -75,7 +75,7 @@ func TestBackupHandlerQuick(t *testing.T) {
// Download present (use filename returned from create)
w5 := httptest.NewRecorder()
req5 := httptest.NewRequest(http.MethodGet, "/backups/"+createResp.Filename, nil)
req5 := httptest.NewRequest(http.MethodGet, "/backups/"+createResp.Filename, http.NoBody)
r.ServeHTTP(w5, req5)
if w5.Code != http.StatusOK {
t.Fatalf("download expected 200 got %d", w5.Code)
@@ -83,7 +83,7 @@ func TestBackupHandlerQuick(t *testing.T) {
// Restore missing
w6 := httptest.NewRecorder()
req6 := httptest.NewRequest(http.MethodPost, "/backups/missing/restore", nil)
req6 := httptest.NewRequest(http.MethodPost, "/backups/missing/restore", http.NoBody)
r.ServeHTTP(w6, req6)
if w6.Code != http.StatusNotFound {
t.Fatalf("restore missing expected 404 got %d", w6.Code)
@@ -91,7 +91,7 @@ func TestBackupHandlerQuick(t *testing.T) {
// Restore ok
w7 := httptest.NewRecorder()
req7 := httptest.NewRequest(http.MethodPost, "/backups/"+createResp.Filename+"/restore", nil)
req7 := httptest.NewRequest(http.MethodPost, "/backups/"+createResp.Filename+"/restore", http.NoBody)
r.ServeHTTP(w7, req7)
if w7.Code != http.StatusOK {
t.Fatalf("restore expected 200 got %d", w7.Code)

View File

@@ -0,0 +1,450 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockCommandExecutor is a mock implementation of CommandExecutor for testing
type mockCommandExecutor struct {
output []byte
err error
calls [][]string // Track all calls made
}
func (m *mockCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
call := append([]string{name}, args...)
m.calls = append(m.calls, call)
return m.output, m.err
}
func TestListDecisions_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
output: []byte(`[{"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual 'ban' from 'localhost'","created_at":"2025-12-05T10:00:00Z","until":"2025-12-05T14:00:00Z"}]`),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
decisions := resp["decisions"].([]interface{})
assert.Len(t, decisions, 1)
decision := decisions[0].(map[string]interface{})
assert.Equal(t, "192.168.1.100", decision["value"])
assert.Equal(t, "ban", decision["type"])
assert.Equal(t, "ip", decision["scope"])
// Verify cscli was called with correct args
require.Len(t, mockExec.calls, 1)
assert.Equal(t, []string{"cscli", "decisions", "list", "-o", "json"}, mockExec.calls[0])
}
func TestListDecisions_EmptyList(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
output: []byte("null"),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
decisions := resp["decisions"].([]interface{})
assert.Len(t, decisions, 0)
assert.Equal(t, float64(0), resp["total"])
}
func TestListDecisions_CscliError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
err: errors.New("cscli not found"),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
r.ServeHTTP(w, req)
// Should return 200 with empty list and error message
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
decisions := resp["decisions"].([]interface{})
assert.Len(t, decisions, 0)
assert.Contains(t, resp["error"], "cscli not available")
}
func TestListDecisions_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
output: []byte("invalid json"),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "failed to parse decisions")
}
func TestBanIP_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
output: []byte(""),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
payload := BanIPRequest{
IP: "192.168.1.100",
Duration: "24h",
Reason: "suspicious activity",
}
b, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "banned", resp["status"])
assert.Equal(t, "192.168.1.100", resp["ip"])
assert.Equal(t, "24h", resp["duration"])
// Verify cscli was called with correct args
require.Len(t, mockExec.calls, 1)
assert.Equal(t, "cscli", mockExec.calls[0][0])
assert.Equal(t, "decisions", mockExec.calls[0][1])
assert.Equal(t, "add", mockExec.calls[0][2])
assert.Equal(t, "-i", mockExec.calls[0][3])
assert.Equal(t, "192.168.1.100", mockExec.calls[0][4])
assert.Equal(t, "-d", mockExec.calls[0][5])
assert.Equal(t, "24h", mockExec.calls[0][6])
assert.Equal(t, "-R", mockExec.calls[0][7])
assert.Equal(t, "manual ban: suspicious activity", mockExec.calls[0][8])
}
func TestBanIP_DefaultDuration(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
output: []byte(""),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
payload := BanIPRequest{
IP: "10.0.0.1",
}
b, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
// Duration should default to 24h
assert.Equal(t, "24h", resp["duration"])
// Verify cscli was called with default duration
require.Len(t, mockExec.calls, 1)
assert.Equal(t, "24h", mockExec.calls[0][6])
}
func TestBanIP_MissingIP(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
payload := map[string]string{}
b, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "ip is required")
}
func TestBanIP_EmptyIP(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
payload := BanIPRequest{
IP: " ",
}
b, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "ip cannot be empty")
}
func TestBanIP_CscliError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
err: errors.New("cscli failed"),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
payload := BanIPRequest{
IP: "192.168.1.100",
}
b, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "failed to ban IP")
}
func TestUnbanIP_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
output: []byte(""),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "unbanned", resp["status"])
assert.Equal(t, "192.168.1.100", resp["ip"])
// Verify cscli was called with correct args
require.Len(t, mockExec.calls, 1)
assert.Equal(t, []string{"cscli", "decisions", "delete", "-i", "192.168.1.100"}, mockExec.calls[0])
}
func TestUnbanIP_CscliError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
err: errors.New("cscli failed"),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "failed to unban IP")
}
func TestListDecisions_MultipleDecisions(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
mockExec := &mockCommandExecutor{
output: []byte(`[
{"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual ban","created_at":"2025-12-05T10:00:00Z"},
{"id":2,"origin":"crowdsec","type":"ban","scope":"ip","value":"10.0.0.50","duration":"1h","scenario":"ssh-bf","created_at":"2025-12-05T11:00:00Z"},
{"id":3,"origin":"cscli","type":"ban","scope":"range","value":"172.16.0.0/24","duration":"24h","scenario":"manual ban","created_at":"2025-12-05T12:00:00Z"}
]`),
}
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.CmdExec = mockExec
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
decisions := resp["decisions"].([]interface{})
assert.Len(t, decisions, 3)
assert.Equal(t, float64(3), resp["total"])
// Verify each decision
d1 := decisions[0].(map[string]interface{})
assert.Equal(t, "192.168.1.100", d1["value"])
assert.Equal(t, "cscli", d1["origin"])
d2 := decisions[1].(map[string]interface{})
assert.Equal(t, "10.0.0.50", d2["value"])
assert.Equal(t, "crowdsec", d2["origin"])
assert.Equal(t, "ssh-bf", d2["scenario"])
d3 := decisions[2].(map[string]interface{})
assert.Equal(t, "172.16.0.0/24", d3["value"])
assert.Equal(t, "range", d3["scope"])
}
func TestBanIP_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "ip is required")
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
@@ -61,23 +62,33 @@ func (e *DefaultCrowdsecExecutor) Stop(ctx context.Context, configDir string) er
return nil
}
func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (bool, int, error) {
func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
b, err := os.ReadFile(e.pidFile(configDir))
if err != nil {
// Missing pid file is treated as not running
return false, 0, nil
}
pid, err := strconv.Atoi(string(b))
pid, err = strconv.Atoi(string(b))
if err != nil {
// Malformed pid file is treated as not running
return false, 0, nil
}
// Check process exists
proc, err := os.FindProcess(pid)
if err != nil {
// Process lookup failures are treated as not running
return false, pid, nil
}
// Sending signal 0 is not portable on Windows, but OK for Linux containers
if err := proc.Signal(syscall.Signal(0)); err != nil {
if err = proc.Signal(syscall.Signal(0)); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return false, pid, nil
}
// ESRCH or other errors mean process isn't running
return false, pid, nil
}
return true, pid, nil
}

View File

@@ -4,36 +4,106 @@ import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/Wikid82/charon/backend/internal/logger"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Wikid82/charon/backend/internal/crowdsec"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Executor abstracts starting/stopping CrowdSec so tests can mock it.
// CrowdsecExecutor abstracts starting/stopping CrowdSec so tests can mock it.
type CrowdsecExecutor interface {
Start(ctx context.Context, binPath, configDir string) (int, error)
Stop(ctx context.Context, configDir string) error
Status(ctx context.Context, configDir string) (running bool, pid int, err error)
}
// CommandExecutor abstracts command execution for testing.
type CommandExecutor interface {
Execute(ctx context.Context, name string, args ...string) ([]byte, error)
}
// RealCommandExecutor executes commands using os/exec.
type RealCommandExecutor struct{}
// Execute runs a command and returns its combined output (stdout/stderr)
func (r *RealCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
return cmd.CombinedOutput()
}
// CrowdsecHandler manages CrowdSec process and config imports.
type CrowdsecHandler struct {
DB *gorm.DB
Executor CrowdsecExecutor
CmdExec CommandExecutor
BinPath string
DataDir string
Hub *crowdsec.HubService
}
func NewCrowdsecHandler(db *gorm.DB, exec CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
return &CrowdsecHandler{DB: db, Executor: exec, BinPath: binPath, DataDir: dataDir}
func mapCrowdsecStatus(err error, defaultCode int) int {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return http.StatusGatewayTimeout
}
return defaultCode
}
func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
cacheDir := filepath.Join(dataDir, "hub_cache")
cache, err := crowdsec.NewHubCache(cacheDir, 24*time.Hour)
if err != nil {
logger.Log().WithError(err).Warn("failed to init crowdsec hub cache")
}
hubSvc := crowdsec.NewHubService(&RealCommandExecutor{}, cache, dataDir)
return &CrowdsecHandler{
DB: db,
Executor: executor,
CmdExec: &RealCommandExecutor{},
BinPath: binPath,
DataDir: dataDir,
Hub: hubSvc,
}
}
// isCerberusEnabled returns true when Cerberus is enabled via DB or env flag.
func (h *CrowdsecHandler) isCerberusEnabled() bool {
if h.DB != nil && h.DB.Migrator().HasTable(&models.Setting{}) {
var s models.Setting
if err := h.DB.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil {
v := strings.ToLower(strings.TrimSpace(s.Value))
return v == "true" || v == "1" || v == "yes"
}
}
if envVal, ok := os.LookupEnv("FEATURE_CERBERUS_ENABLED"); ok {
if b, err := strconv.ParseBool(envVal); err == nil {
return b
}
return envVal == "1"
}
if envVal, ok := os.LookupEnv("CERBERUS_ENABLED"); ok {
if b, err := strconv.ParseBool(envVal); err == nil {
return b
}
return envVal == "1"
}
return true
}
// Start starts the CrowdSec process.
@@ -115,13 +185,21 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open temp file"})
return
}
defer in.Close()
defer func() {
if err := in.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close temp file")
}
}()
out, err := os.Create(target)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create target file"})
return
}
defer out.Close()
defer func() {
if err := out.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close target file")
}
}()
if _, err := io.Copy(out, in); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"})
return
@@ -173,7 +251,11 @@ func (h *CrowdsecHandler) ExportConfig(c *gin.Context) {
if err != nil {
return err
}
defer f.Close()
defer func() {
if err := f.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close file while archiving", "path", path)
}
}()
hdr := &tar.Header{
Name: rel,
@@ -290,6 +372,359 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})
}
// ListPresets returns the curated preset catalog when Cerberus is enabled.
func (h *CrowdsecHandler) ListPresets(c *gin.Context) {
if !h.isCerberusEnabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
return
}
type presetInfo struct {
crowdsec.Preset
Available bool `json:"available"`
Cached bool `json:"cached"`
CacheKey string `json:"cache_key,omitempty"`
Etag string `json:"etag,omitempty"`
RetrievedAt *time.Time `json:"retrieved_at,omitempty"`
}
result := map[string]*presetInfo{}
for _, p := range crowdsec.ListCuratedPresets() {
cp := p
result[p.Slug] = &presetInfo{Preset: cp, Available: true}
}
// Merge hub index when available
if h.Hub != nil {
ctx := c.Request.Context()
if idx, err := h.Hub.FetchIndex(ctx); err == nil {
for _, item := range idx.Items {
slug := strings.TrimSpace(item.Name)
if slug == "" {
continue
}
if _, ok := result[slug]; !ok {
result[slug] = &presetInfo{Preset: crowdsec.Preset{
Slug: slug,
Title: item.Title,
Summary: item.Description,
Source: "hub",
Tags: []string{item.Type},
RequiresHub: true,
}, Available: true}
} else {
result[slug].Available = true
}
}
} else {
logger.Log().WithError(err).Warn("crowdsec hub index unavailable")
}
}
// Merge cache metadata
if h.Hub != nil && h.Hub.Cache != nil {
ctx := c.Request.Context()
if cached, err := h.Hub.Cache.List(ctx); err == nil {
for _, entry := range cached {
if _, ok := result[entry.Slug]; !ok {
result[entry.Slug] = &presetInfo{Preset: crowdsec.Preset{Slug: entry.Slug, Title: entry.Slug, Summary: "cached preset", Source: "hub", RequiresHub: true}}
}
result[entry.Slug].Cached = true
result[entry.Slug].CacheKey = entry.CacheKey
result[entry.Slug].Etag = entry.Etag
if !entry.RetrievedAt.IsZero() {
val := entry.RetrievedAt
result[entry.Slug].RetrievedAt = &val
}
}
} else {
logger.Log().WithError(err).Warn("crowdsec hub cache list failed")
}
}
list := make([]presetInfo, 0, len(result))
for _, v := range result {
list = append(list, *v)
}
c.JSON(http.StatusOK, gin.H{"presets": list})
}
// PullPreset downloads and caches a hub preset while returning a preview.
func (h *CrowdsecHandler) PullPreset(c *gin.Context) {
if !h.isCerberusEnabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
return
}
var payload struct {
Slug string `json:"slug"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
slug := strings.TrimSpace(payload.Slug)
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
return
}
if h.Hub == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
return
}
ctx := c.Request.Context()
res, err := h.Hub.Pull(ctx, slug)
if err != nil {
status := mapCrowdsecStatus(err, http.StatusBadGateway)
logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).Warn("crowdsec preset pull failed")
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "pulled",
"slug": res.Meta.Slug,
"preview": res.Preview,
"cache_key": res.Meta.CacheKey,
"etag": res.Meta.Etag,
"retrieved_at": res.Meta.RetrievedAt,
"source": res.Meta.Source,
})
}
// ApplyPreset installs a pulled preset from cache or via cscli.
func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) {
if !h.isCerberusEnabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
return
}
var payload struct {
Slug string `json:"slug"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
slug := strings.TrimSpace(payload.Slug)
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
return
}
if h.Hub == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
return
}
ctx := c.Request.Context()
res, err := h.Hub.Apply(ctx, slug)
if err != nil {
status := mapCrowdsecStatus(err, http.StatusInternalServerError)
logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).Warn("crowdsec preset apply failed")
if h.DB != nil {
_ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slug, Action: "apply", Status: "failed", CacheKey: res.CacheKey, BackupPath: res.BackupPath, Error: err.Error()}).Error
}
c.JSON(status, gin.H{"error": err.Error(), "backup": res.BackupPath})
return
}
if h.DB != nil {
status := res.Status
if status == "" {
status = "applied"
}
slugVal := res.AppliedPreset
if slugVal == "" {
slugVal = slug
}
_ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slugVal, Action: "apply", Status: status, CacheKey: res.CacheKey, BackupPath: res.BackupPath}).Error
}
c.JSON(http.StatusOK, gin.H{
"status": res.Status,
"backup": res.BackupPath,
"reload_hint": res.ReloadHint,
"used_cscli": res.UsedCSCLI,
"cache_key": res.CacheKey,
"slug": res.AppliedPreset,
})
}
// GetCachedPreset returns cached preview for a slug when available.
func (h *CrowdsecHandler) GetCachedPreset(c *gin.Context) {
if !h.isCerberusEnabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
return
}
if h.Hub == nil || h.Hub.Cache == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub cache unavailable"})
return
}
ctx := c.Request.Context()
slug := strings.TrimSpace(c.Param("slug"))
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
return
}
preview, err := h.Hub.Cache.LoadPreview(ctx, slug)
if err != nil {
if errors.Is(err, crowdsec.ErrCacheMiss) || errors.Is(err, crowdsec.ErrCacheExpired) {
c.JSON(http.StatusNotFound, gin.H{"error": "cache miss"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
meta, _ := h.Hub.Cache.Load(ctx, slug)
c.JSON(http.StatusOK, gin.H{"preview": preview, "cache_key": meta.CacheKey, "etag": meta.Etag})
}
// CrowdSecDecision represents a ban decision from CrowdSec
type CrowdSecDecision struct {
ID int64 `json:"id"`
Origin string `json:"origin"`
Type string `json:"type"`
Scope string `json:"scope"`
Value string `json:"value"`
Duration string `json:"duration"`
Scenario string `json:"scenario"`
CreatedAt time.Time `json:"created_at"`
Until string `json:"until,omitempty"`
}
// cscliDecision represents the JSON output from cscli decisions list
type cscliDecision struct {
ID int64 `json:"id"`
Origin string `json:"origin"`
Type string `json:"type"`
Scope string `json:"scope"`
Value string `json:"value"`
Duration string `json:"duration"`
Scenario string `json:"scenario"`
CreatedAt string `json:"created_at"`
Until string `json:"until"`
}
// ListDecisions calls cscli to get current decisions (banned IPs)
func (h *CrowdsecHandler) ListDecisions(c *gin.Context) {
ctx := c.Request.Context()
output, err := h.CmdExec.Execute(ctx, "cscli", "decisions", "list", "-o", "json")
if err != nil {
// If cscli is not available or returns error, return empty list with warning
logger.Log().WithError(err).Warn("Failed to execute cscli decisions list")
c.JSON(http.StatusOK, gin.H{"decisions": []CrowdSecDecision{}, "error": "cscli not available or failed"})
return
}
// Handle empty output (no decisions)
if len(output) == 0 || string(output) == "null" || string(output) == "null\n" {
c.JSON(http.StatusOK, gin.H{"decisions": []CrowdSecDecision{}, "total": 0})
return
}
// Parse JSON output
var rawDecisions []cscliDecision
if err := json.Unmarshal(output, &rawDecisions); err != nil {
logger.Log().WithError(err).WithField("output", string(output)).Warn("Failed to parse cscli decisions output")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse decisions"})
return
}
// Convert to our format
decisions := make([]CrowdSecDecision, 0, len(rawDecisions))
for _, d := range rawDecisions {
var createdAt time.Time
if d.CreatedAt != "" {
createdAt, _ = time.Parse(time.RFC3339, d.CreatedAt)
}
decisions = append(decisions, CrowdSecDecision{
ID: d.ID,
Origin: d.Origin,
Type: d.Type,
Scope: d.Scope,
Value: d.Value,
Duration: d.Duration,
Scenario: d.Scenario,
CreatedAt: createdAt,
Until: d.Until,
})
}
c.JSON(http.StatusOK, gin.H{"decisions": decisions, "total": len(decisions)})
}
// BanIPRequest represents the request body for banning an IP
type BanIPRequest struct {
IP string `json:"ip" binding:"required"`
Duration string `json:"duration"`
Reason string `json:"reason"`
}
// BanIP adds a manual ban for an IP address
func (h *CrowdsecHandler) BanIP(c *gin.Context) {
var req BanIPRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"})
return
}
// Validate IP format (basic check)
ip := strings.TrimSpace(req.IP)
if ip == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip cannot be empty"})
return
}
// Default duration to 24h if not specified
duration := req.Duration
if duration == "" {
duration = "24h"
}
// Build reason string
reason := "manual ban"
if req.Reason != "" {
reason = fmt.Sprintf("manual ban: %s", req.Reason)
}
ctx := c.Request.Context()
args := []string{"decisions", "add", "-i", ip, "-d", duration, "-R", reason, "-t", "ban"}
_, err := h.CmdExec.Execute(ctx, "cscli", args...)
if err != nil {
logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions add")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ban IP"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "banned", "ip": ip, "duration": duration})
}
// UnbanIP removes a ban for an IP address
func (h *CrowdsecHandler) UnbanIP(c *gin.Context) {
ip := c.Param("ip")
if ip == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip parameter required"})
return
}
// Sanitize IP
ip = strings.TrimSpace(ip)
ctx := c.Request.Context()
args := []string{"decisions", "delete", "-i", ip}
_, err := h.CmdExec.Execute(ctx, "cscli", args...)
if err != nil {
logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions delete")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unban IP"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "unbanned", "ip": ip})
}
// RegisterRoutes registers crowdsec admin routes under protected group
func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/admin/crowdsec/start", h.Start)
@@ -300,4 +735,12 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/admin/crowdsec/files", h.ListFiles)
rg.GET("/admin/crowdsec/file", h.ReadFile)
rg.POST("/admin/crowdsec/file", h.WriteFile)
rg.GET("/admin/crowdsec/presets", h.ListPresets)
rg.POST("/admin/crowdsec/presets/pull", h.PullPreset)
rg.POST("/admin/crowdsec/presets/apply", h.ApplyPreset)
rg.GET("/admin/crowdsec/presets/cache/:slug", h.GetCachedPreset)
// Decision management endpoints (Banned IP Dashboard)
rg.GET("/admin/crowdsec/decisions", h.ListDecisions)
rg.POST("/admin/crowdsec/ban", h.BanIP)
rg.DELETE("/admin/crowdsec/ban/:ip", h.UnbanIP)
}

View File

@@ -24,7 +24,7 @@ func (f *errorExec) Start(ctx context.Context, binPath, configDir string) (int,
func (f *errorExec) Stop(ctx context.Context, configDir string) error {
return errors.New("failed to stop crowdsec")
}
func (f *errorExec) Status(ctx context.Context, configDir string) (bool, int, error) {
func (f *errorExec) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
return false, 0, errors.New("failed to get status")
}
@@ -40,7 +40,7 @@ func TestCrowdsec_Start_Error(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
@@ -59,7 +59,7 @@ func TestCrowdsec_Stop_Error(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
@@ -78,7 +78,7 @@ func TestCrowdsec_Status_Error(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
@@ -98,7 +98,7 @@ func TestCrowdsec_ReadFile_MissingPath(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
@@ -118,7 +118,7 @@ func TestCrowdsec_ReadFile_PathTraversal(t *testing.T) {
// Attempt path traversal
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../../../etc/passwd", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../../../etc/passwd", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
@@ -137,7 +137,7 @@ func TestCrowdsec_ReadFile_NotFound(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=nonexistent.conf", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=nonexistent.conf", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
@@ -221,13 +221,15 @@ func TestCrowdsec_ExportConfig_NotFound(t *testing.T) {
os.RemoveAll(nonExistentDir) // Make sure it doesn't exist
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", nonExistentDir)
// remove any cache dir created during handler init so Export sees missing dir
_ = os.RemoveAll(nonExistentDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
@@ -247,7 +249,7 @@ func TestCrowdsec_ListFiles_EmptyDir(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -273,7 +275,7 @@ func TestCrowdsec_ListFiles_NonExistent(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -298,7 +300,7 @@ func TestCrowdsec_ImportConfig_NoFile(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", http.NoBody)
req.Header.Set("Content-Type", "multipart/form-data")
r.ServeHTTP(w, req)
@@ -323,7 +325,7 @@ func TestCrowdsec_ReadFile_NestedPath(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=subdir/test.conf", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=subdir/test.conf", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -360,3 +362,95 @@ func TestCrowdsec_WriteFile_Success(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "new content", string(content))
}
func TestCrowdsec_ListPresets_Disabled(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
tmpDir := t.TempDir()
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestCrowdsec_ListPresets_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
presets, ok := resp["presets"].([]interface{})
assert.True(t, ok)
assert.Greater(t, len(presets), 0)
}
func TestCrowdsec_PullPreset_Validation(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.Hub = nil // simulate hub unavailable
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte(`{"slug":"demo"}`)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestCrowdsec_ApplyPreset_Validation(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
tmpDir := t.TempDir()
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
h.Hub = nil
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte(`{"slug":"demo"}`)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}

View File

@@ -1,17 +1,24 @@
package handlers
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
@@ -27,7 +34,7 @@ func (f *fakeExec) Stop(ctx context.Context, configDir string) error {
f.started = false
return nil
}
func (f *fakeExec) Status(ctx context.Context, configDir string) (bool, int, error) {
func (f *fakeExec) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
if f.started {
return true, 12345, nil
}
@@ -53,7 +60,7 @@ func TestCrowdsecEndpoints(t *testing.T) {
// Status (initially stopped)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status expected 200 got %d", w.Code)
@@ -61,7 +68,7 @@ func TestCrowdsecEndpoints(t *testing.T) {
// Start
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("start expected 200 got %d", w2.Code)
@@ -69,7 +76,7 @@ func TestCrowdsecEndpoints(t *testing.T) {
// Stop
w3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", nil)
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", http.NoBody)
r.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("stop expected 200 got %d", w3.Code)
@@ -151,7 +158,7 @@ func TestImportCreatesBackup(t *testing.T) {
// fallback: check for any .backup.* in same parent dir
entries, _ := os.ReadDir(filepath.Dir(tmpDir))
for _, e := range entries {
if e.IsDir() && filepath.Ext(e.Name()) == "" && (len(e.Name()) > 0) && (filepath.Base(e.Name()) != filepath.Base(tmpDir)) {
if e.IsDir() && filepath.Ext(e.Name()) == "" && e.Name() != "" && (filepath.Base(e.Name()) != filepath.Base(tmpDir)) {
// best-effort assume backup present
found = true
break
@@ -181,7 +188,7 @@ func TestExportConfig(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("export expected 200 got %d body=%s", w.Code, w.Body.String())
@@ -211,20 +218,60 @@ func TestListAndReadFile(t *testing.T) {
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("files expected 200 got %d body=%s", w.Code, w.Body.String())
}
// read a single file
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=conf.d/a.conf", nil)
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=conf.d/a.conf", http.NoBody)
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("file read expected 200 got %d body=%s", w2.Code, w2.Body.String())
}
}
func TestExportConfigStreamsArchive(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
dataDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("hello"), 0o644))
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
require.Contains(t, w.Header().Get("Content-Disposition"), "crowdsec-config-")
gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
require.NoError(t, err)
tr := tar.NewReader(gr)
found := false
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
if hdr.Name == "config.yaml" {
data, readErr := io.ReadAll(tr)
require.NoError(t, readErr)
require.Equal(t, "hello", string(data))
found = true
}
}
require.True(t, found, "expected exported archive to contain config file")
}
func TestWriteFileCreatesBackup(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupCrowdDB(t)
@@ -251,20 +298,224 @@ func TestWriteFileCreatesBackup(t *testing.T) {
t.Fatalf("write expected 200 got %d body=%s", w.Code, w.Body.String())
}
// ensure backup directory exists next to data dir
found := false
entries, _ := os.ReadDir(filepath.Dir(tmpDir))
// ensure backup directory was created
entries, err := os.ReadDir(filepath.Dir(tmpDir))
require.NoError(t, err)
foundBackup := false
for _, e := range entries {
if e.IsDir() && filepath.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
found = true
if e.IsDir() && strings.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
foundBackup = true
break
}
}
if !found {
t.Fatalf("expected backup directory next to data dir")
}
// ensure file content exists in new data dir
if _, err := os.Stat(filepath.Join(tmpDir, "conf.d", "new.conf")); err != nil {
t.Fatalf("expected file written: %v", err)
require.True(t, foundBackup, "expected backup directory to be created")
}
func TestListPresetsCerberusDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 when cerberus disabled got %d", w.Code)
}
}
func TestReadFileInvalidPath(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../secret", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid path got %d", w.Code)
}
}
func TestWriteFileInvalidPath(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body, _ := json.Marshal(map[string]string{"path": "../../escape", "content": "bad"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid path got %d", w.Code)
}
}
func TestWriteFileMissingPath(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body, _ := json.Marshal(map[string]string{"content": "data only"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestWriteFileInvalidPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewBufferString("not-json"))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestImportConfigRequiresFile(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 when file missing got %d", w.Code)
}
}
func TestImportConfigRejectsEmptyUpload(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
buf := &bytes.Buffer{}
mw := multipart.NewWriter(buf)
_, _ = mw.CreateFormFile("file", "empty.tgz")
_ = mw.Close()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty upload got %d", w.Code)
}
}
func TestListFilesMissingDir(t *testing.T) {
gin.SetMode(gin.TestMode)
missingDir := filepath.Join(t.TempDir(), "does-not-exist")
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", missingDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for missing dir got %d", w.Code)
}
}
func TestListFilesReturnsEntries(t *testing.T) {
gin.SetMode(gin.TestMode)
dataDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "root.txt"), []byte("root"), 0o644))
nestedDir := filepath.Join(dataDir, "nested")
require.NoError(t, os.MkdirAll(nestedDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(nestedDir, "child.txt"), []byte("child"), 0o644))
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 got %d", w.Code)
}
var resp struct {
Files []string `json:"files"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.ElementsMatch(t, []string{"root.txt", filepath.Join("nested", "child.txt")}, resp.Files)
}
func TestIsCerberusEnabledFromDB(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
require.NoError(t, db.Create(&models.Setting{Key: "feature.cerberus.enabled", Value: "0"}).Error)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 when cerberus disabled via DB got %d", w.Code)
}
}
func TestIsCerberusEnabledInvalidEnv(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "not-a-bool")
h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
if h.isCerberusEnabled() {
t.Fatalf("expected cerberus to be disabled for invalid env flag")
}
}
func TestIsCerberusEnabledLegacyEnv(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
t.Setenv("CERBERUS_ENABLED", "0")
if h.isCerberusEnabled() {
t.Fatalf("expected cerberus to be disabled for legacy env flag")
}
}

View File

@@ -0,0 +1,441 @@
package handlers
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/crowdsec"
"github.com/Wikid82/charon/backend/internal/models"
)
type presetRoundTripper func(*http.Request) (*http.Response, error)
func (p presetRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return p(req)
}
func makePresetTar(t *testing.T, files map[string]string) []byte {
t.Helper()
buf := &bytes.Buffer{}
gw := gzip.NewWriter(buf)
tw := tar.NewWriter(gw)
for name, content := range files {
hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}
require.NoError(t, tw.WriteHeader(hdr))
_, err := tw.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, tw.Close())
require.NoError(t, gw.Close())
return buf.Bytes()
}
func TestListPresetsIncludesCacheAndIndex(t *testing.T) {
gin.SetMode(gin.TestMode)
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", []byte("archive"))
require.NoError(t, err)
hub := crowdsec.NewHubService(nil, cache, t.TempDir())
hub.HubBaseURL = "http://example.com"
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "http://example.com/api/index.json" {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection"}]}`)), Header: make(http.Header)}, nil
}
return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
})}
db := OpenTestDB(t)
handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
handler.Hub = hub
r := gin.New()
g := r.Group("/api/v1")
handler.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload struct {
Presets []struct {
Slug string `json:"slug"`
Cached bool `json:"cached"`
} `json:"presets"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
found := false
for _, p := range payload.Presets {
if p.Slug == "crowdsecurity/demo" {
found = true
require.True(t, p.Cached)
}
}
require.True(t, found)
}
func TestPullPresetHandlerSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
dataDir := filepath.Join(t.TempDir(), "crowdsec")
archive := makePresetTar(t, map[string]string{"config.yaml": "key: value"})
hub := crowdsec.NewHubService(nil, cache, dataDir)
hub.HubBaseURL = "http://example.com"
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
switch req.URL.String() {
case "http://example.com/api/index.json":
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","etag":"e1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`)), Header: make(http.Header)}, nil
case "http://example.com/demo.yaml":
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview")), Header: make(http.Header)}, nil
case "http://example.com/demo.tgz":
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil
default:
return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
}
})}
handler := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
handler.Hub = hub
r := gin.New()
g := r.Group("/api/v1")
handler.RegisterRoutes(g)
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "cache_key")
require.Contains(t, w.Body.String(), "preview")
}
func TestApplyPresetHandlerAudits(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
dataDir := filepath.Join(t.TempDir(), "crowdsec")
archive := makePresetTar(t, map[string]string{"conf.yaml": "v: 1"})
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", archive)
require.NoError(t, err)
hub := crowdsec.NewHubService(nil, cache, dataDir)
handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
handler.Hub = hub
r := gin.New()
g := r.Group("/api/v1")
handler.RegisterRoutes(g)
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var events []models.CrowdsecPresetEvent
require.NoError(t, db.Find(&events).Error)
require.Len(t, events, 1)
require.Equal(t, "applied", events[0].Status)
// Failure path
badCache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
badArchive := makePresetTar(t, map[string]string{"../bad.txt": "x"})
_, err = badCache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", badArchive)
require.NoError(t, err)
badHub := crowdsec.NewHubService(nil, badCache, filepath.Join(t.TempDir(), "crowdsec2"))
handler.Hub = badHub
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w2, req2)
require.Equal(t, http.StatusInternalServerError, w2.Code)
require.NoError(t, db.Find(&events).Error)
require.Len(t, events, 2)
require.Equal(t, "failed", events[1].Status)
}
func TestPullPresetHandlerHubError(t *testing.T) {
gin.SetMode(gin.TestMode)
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
hub := crowdsec.NewHubService(nil, cache, t.TempDir())
hub.HubBaseURL = "http://example.com"
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
})}
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = hub
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/missing"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadGateway, w.Code)
}
func TestPullPresetHandlerTimeout(t *testing.T) {
gin.SetMode(gin.TestMode)
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
hub := crowdsec.NewHubService(nil, cache, t.TempDir())
hub.HubBaseURL = "http://example.com"
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})}
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = hub
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusGatewayTimeout, w.Code)
require.Contains(t, w.Body.String(), "deadline")
}
func TestGetCachedPresetNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/unknown", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
}
func TestGetCachedPresetServiceUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = &crowdsec.HubService{}
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/demo", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestApplyPresetHandlerBackupFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
baseDir := t.TempDir()
dataDir := filepath.Join(baseDir, "crowdsec")
require.NoError(t, os.MkdirAll(dataDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "keep.txt"), []byte("before"), 0o644))
hub := crowdsec.NewHubService(nil, nil, dataDir)
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
h.Hub = hub
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
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.Empty(t, events[0].BackupPath)
content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt"))
require.NoError(t, readErr)
require.Equal(t, "before", string(content))
}
func TestListPresetsMergesCuratedAndHub(t *testing.T) {
gin.SetMode(gin.TestMode)
hub := crowdsec.NewHubService(nil, nil, t.TempDir())
hub.HubBaseURL = "http://hub.example"
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
if req.URL.String() == "http://hub.example/api/index.json" {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/custom","title":"Custom","description":"d","type":"collection"}]}`)), Header: make(http.Header)}, nil
}
return nil, errors.New("unexpected request")
})}
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = hub
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload struct {
Presets []struct {
Slug string `json:"slug"`
Source string `json:"source"`
Tags []string `json:"tags"`
} `json:"presets"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
foundCurated := false
foundHub := false
for _, p := range payload.Presets {
if p.Slug == "honeypot-friendly-defaults" {
foundCurated = true
}
if p.Slug == "crowdsecurity/custom" {
foundHub = true
require.Equal(t, []string{"collection"}, p.Tags)
}
}
require.True(t, foundCurated)
require.True(t, foundHub)
}
func TestGetCachedPresetSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
const slug = "demo"
_, err = cache.Store(context.Background(), slug, "etag123", "hub", "preview-body", []byte("tgz"))
require.NoError(t, err)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
require.True(t, h.isCerberusEnabled())
preview, err := h.Hub.Cache.LoadPreview(context.Background(), slug)
require.NoError(t, err)
require.Equal(t, "preview-body", preview)
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/"+slug, http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "preview-body")
require.Contains(t, w.Body.String(), "etag123")
}
func TestGetCachedPresetSlugRequired(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
require.NoError(t, err)
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/%20", http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "slug required")
}
func TestGetCachedPresetPreviewError(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
cacheDir := t.TempDir()
cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
require.NoError(t, err)
const slug = "broken"
meta, err := cache.Store(context.Background(), slug, "etag999", "hub", "will-remove", []byte("tgz"))
require.NoError(t, err)
// Remove preview to force LoadPreview read error.
require.NoError(t, os.Remove(meta.PreviewPath))
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
r := gin.New()
g := r.Group("/api/v1")
h.RegisterRoutes(g)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/"+slug, http.NoBody)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), "no such file")
}

View File

@@ -0,0 +1,8 @@
// Package handlers provides HTTP handlers used by the Charon backend API.
//
// It exposes Gin-based handler implementations for resources such as
// certificates, proxy hosts, users, notifications, backups, and system
// configuration. This package wires services to HTTP endpoints and
// performs request validation, response formatting, and basic error
// handling.
package handlers

View File

@@ -50,7 +50,7 @@ func TestDockerHandler_ListContainers(t *testing.T) {
h := NewDockerHandler(svc, rsService)
h.RegisterRoutes(r.Group("/"))
req, _ := http.NewRequest("GET", "/docker/containers", nil)
req, _ := http.NewRequest("GET", "/docker/containers", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -70,7 +70,7 @@ func TestDockerHandler_ListContainers_NonExistentServerID(t *testing.T) {
h.RegisterRoutes(r.Group("/"))
// Request with non-existent server_id
req, _ := http.NewRequest("GET", "/docker/containers?server_id=non-existent-uuid", nil)
req, _ := http.NewRequest("GET", "/docker/containers?server_id=non-existent-uuid", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -101,7 +101,7 @@ func TestDockerHandler_ListContainers_WithServerID(t *testing.T) {
h.RegisterRoutes(r.Group("/"))
// Request with valid server_id (will fail to connect, but shouldn't error on lookup)
req, _ := http.NewRequest("GET", "/docker/containers?server_id="+server.UUID, nil)
req, _ := http.NewRequest("GET", "/docker/containers?server_id="+server.UUID, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -124,7 +124,7 @@ func TestDockerHandler_ListContainers_WithHostQuery(t *testing.T) {
h.RegisterRoutes(r.Group("/"))
// Request with custom host parameter
req, _ := http.NewRequest("GET", "/docker/containers?host=tcp://invalid-host:2375", nil)
req, _ := http.NewRequest("GET", "/docker/containers?host=tcp://invalid-host:2375", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

View File

@@ -54,7 +54,7 @@ func TestDomainLifecycle(t *testing.T) {
require.NotEmpty(t, created.UUID)
// 2. List Domains
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -65,13 +65,13 @@ func TestDomainLifecycle(t *testing.T) {
require.Equal(t, "example.com", list[0].Name)
// 3. Delete Domain
req = httptest.NewRequest(http.MethodDelete, "/api/v1/domains/"+created.UUID, nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/domains/"+created.UUID, http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 4. Verify Deletion
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -101,7 +101,7 @@ func TestDomainErrors(t *testing.T) {
func TestDomainDelete_NotFound(t *testing.T) {
router, _ := setupDomainTestRouter(t)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/domains/nonexistent-uuid", nil)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/domains/nonexistent-uuid", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Handler may return 200 with deleted=true even if not found (soft delete behavior)
@@ -136,7 +136,7 @@ func TestDomainCreate_Duplicate(t *testing.T) {
func TestDomainList_Empty(t *testing.T) {
router, _ := setupDomainTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/domains", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)

View File

@@ -23,11 +23,8 @@ func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler {
// defaultFlags lists the canonical feature flags we expose.
var defaultFlags = []string{
"feature.global.enabled",
"feature.cerberus.enabled",
"feature.uptime.enabled",
"feature.notifications.enabled",
"feature.docker.enabled",
}
// GetFlags returns a map of feature flag -> bool. DB setting takes precedence
@@ -70,8 +67,8 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
}
}
// Default false
result[key] = false
// Default true for core optional features
result[key] = true
}
c.JSON(http.StatusOK, result)

View File

@@ -14,17 +14,248 @@ import (
"github.com/Wikid82/charon/backend/internal/models"
)
func TestFeatureFlags_UpdateFlags_InvalidPayload(t *testing.T) {
func TestFeatureFlagsHandler_GetFlags_DBPrecedence(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
// Set a flag in DB
db.Create(&models.Setting{
Key: "feature.cerberus.enabled",
Value: "false",
Type: "bool",
Category: "feature",
})
// Set env var that should be ignored (DB takes precedence)
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
// DB value (false) should take precedence over env (true)
assert.False(t, flags["feature.cerberus.enabled"])
}
func TestFeatureFlagsHandler_GetFlags_EnvFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
// Set env var (no DB value exists)
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
// Env value should be used
assert.False(t, flags["feature.cerberus.enabled"])
}
func TestFeatureFlagsHandler_GetFlags_EnvShortForm(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
// Set short form env var (CERBERUS_ENABLED instead of FEATURE_CERBERUS_ENABLED)
t.Setenv("CERBERUS_ENABLED", "false")
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
// Short form env value should be used
assert.False(t, flags["feature.cerberus.enabled"])
}
func TestFeatureFlagsHandler_GetFlags_EnvNumeric(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
// Set numeric env var (1/0 instead of true/false)
t.Setenv("FEATURE_UPTIME_ENABLED", "0")
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
// "0" should be parsed as false
assert.False(t, flags["feature.uptime.enabled"])
}
func TestFeatureFlagsHandler_GetFlags_DefaultTrue(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
// No DB value, no env var - should default to true
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
// All flags should default to true
assert.True(t, flags["feature.cerberus.enabled"])
assert.True(t, flags["feature.uptime.enabled"])
}
func TestFeatureFlagsHandler_GetFlags_AllDefaultFlagsPresent(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
// Ensure all default flags are present
for _, key := range defaultFlags {
_, ok := flags[key]
assert.True(t, ok, "expected flag %s to be present", key)
}
}
func TestFeatureFlagsHandler_UpdateFlags_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
// Send invalid JSON
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader([]byte("invalid")))
payload := map[string]bool{
"feature.cerberus.enabled": false,
"feature.uptime.enabled": true,
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Verify DB persistence
var s1 models.Setting
err := db.Where("key = ?", "feature.cerberus.enabled").First(&s1).Error
require.NoError(t, err)
assert.Equal(t, "false", s1.Value)
assert.Equal(t, "bool", s1.Type)
assert.Equal(t, "feature", s1.Category)
var s2 models.Setting
err = db.Where("key = ?", "feature.uptime.enabled").First(&s2).Error
require.NoError(t, err)
assert.Equal(t, "true", s2.Value)
}
func TestFeatureFlagsHandler_UpdateFlags_Upsert(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
// Create existing setting
db.Create(&models.Setting{
Key: "feature.cerberus.enabled",
Value: "true",
Type: "bool",
Category: "feature",
})
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
// Update existing setting
payload := map[string]bool{
"feature.cerberus.enabled": false,
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Verify update
var s models.Setting
err := db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error
require.NoError(t, err)
assert.Equal(t, "false", s.Value)
// Verify only one record exists
var count int64
db.Model(&models.Setting{}).Where("key = ?", "feature.cerberus.enabled").Count(&count)
assert.Equal(t, int64(1), count)
}
func TestFeatureFlagsHandler_UpdateFlags_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -32,227 +263,199 @@ func TestFeatureFlags_UpdateFlags_InvalidPayload(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestFeatureFlags_UpdateFlags_IgnoresInvalidKeys(t *testing.T) {
func TestFeatureFlagsHandler_UpdateFlags_OnlyAllowedKeys(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
h := NewFeatureFlagsHandler(db)
gin.SetMode(gin.TestMode)
r := gin.New()
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
// Try to update a non-whitelisted key
payload := []byte(`{"invalid.key": true, "feature.global.enabled": true}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(payload))
// Try to set a key not in defaultFlags
payload := map[string]bool{
"feature.cerberus.enabled": false,
"feature.invalid.key": true, // Should be ignored
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Verify allowed key was saved
var s1 models.Setting
err := db.Where("key = ?", "feature.cerberus.enabled").First(&s1).Error
require.NoError(t, err)
// Verify disallowed key was NOT saved
var s2 models.Setting
err = db.Where("key = ?", "feature.invalid.key").First(&s2).Error
assert.Error(t, err)
}
func TestFeatureFlagsHandler_UpdateFlags_EmptyPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
payload := map[string]bool{}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify invalid key was NOT saved
var s models.Setting
err := db.Where("key = ?", "invalid.key").First(&s).Error
assert.Error(t, err) // Should not exist
// Valid key should be saved
err = db.Where("key = ?", "feature.global.enabled").First(&s).Error
assert.NoError(t, err)
assert.Equal(t, "true", s.Value)
}
func TestFeatureFlags_EnvFallback_ShortVariant(t *testing.T) {
// Test the short env variant (CERBERUS_ENABLED instead of FEATURE_CERBERUS_ENABLED)
t.Setenv("CERBERUS_ENABLED", "true")
db := OpenTestDB(t)
h := NewFeatureFlagsHandler(db)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Parse response
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
// Should be true via short env fallback
assert.True(t, flags["feature.cerberus.enabled"])
}
func TestFeatureFlags_EnvFallback_WithValue1(t *testing.T) {
// Test env fallback with "1" as value
t.Setenv("FEATURE_UPTIME_ENABLED", "1")
db := OpenTestDB(t)
h := NewFeatureFlagsHandler(db)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
json.Unmarshal(w.Body.Bytes(), &flags)
assert.True(t, flags["feature.uptime.enabled"])
}
func TestFeatureFlags_EnvFallback_WithValue0(t *testing.T) {
// Test env fallback with "0" as value (should be false)
t.Setenv("FEATURE_DOCKER_ENABLED", "0")
db := OpenTestDB(t)
h := NewFeatureFlagsHandler(db)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
json.Unmarshal(w.Body.Bytes(), &flags)
assert.False(t, flags["feature.docker.enabled"])
}
func TestFeatureFlags_DBTakesPrecedence(t *testing.T) {
// Test that DB value takes precedence over env
t.Setenv("FEATURE_NOTIFICATIONS_ENABLED", "false")
db := setupFlagsDB(t)
// Set DB value to true
db.Create(&models.Setting{Key: "feature.notifications.enabled", Value: "true", Type: "bool", Category: "feature"})
h := NewFeatureFlagsHandler(db)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
json.Unmarshal(w.Body.Bytes(), &flags)
// DB value (true) should take precedence over env (false)
assert.True(t, flags["feature.notifications.enabled"])
}
func TestFeatureFlags_DBValueVariations(t *testing.T) {
db := setupFlagsDB(t)
// Test various DB value formats
testCases := []struct {
key string
func TestFeatureFlagsHandler_GetFlags_DBValueVariants(t *testing.T) {
tests := []struct {
name string
dbValue string
expected bool
}{
{"feature.global.enabled", "1", true},
{"feature.cerberus.enabled", "yes", true},
{"feature.uptime.enabled", "TRUE", true},
{"feature.notifications.enabled", "false", false},
{"feature.docker.enabled", "0", false},
{"lowercase true", "true", true},
{"uppercase TRUE", "TRUE", true},
{"mixed case True", "True", true},
{"numeric 1", "1", true},
{"yes", "yes", true},
{"YES uppercase", "YES", true},
{"lowercase false", "false", false},
{"numeric 0", "0", false},
{"no", "no", false},
{"empty string", "", false},
{"random string", "random", false},
{"whitespace padded true", " true ", true},
{"whitespace padded false", " false ", false},
}
for _, tc := range testCases {
db.Create(&models.Setting{Key: tc.key, Value: tc.dbValue, Type: "bool", Category: "feature"})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
h := NewFeatureFlagsHandler(db)
// Set flag with test value
db.Create(&models.Setting{
Key: "feature.cerberus.enabled",
Value: tt.dbValue,
Type: "bool",
Category: "feature",
})
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
json.Unmarshal(w.Body.Bytes(), &flags)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
for _, tc := range testCases {
assert.Equal(t, tc.expected, flags[tc.key], "flag %s expected %v", tc.key, tc.expected)
assert.Equal(t, tt.expected, flags["feature.cerberus.enabled"],
"dbValue=%q should result in %v", tt.dbValue, tt.expected)
})
}
}
func TestFeatureFlags_UpdateMultipleFlags(t *testing.T) {
func TestFeatureFlagsHandler_GetFlags_EnvValueVariants(t *testing.T) {
tests := []struct {
name string
envValue string
expected bool
}{
{"true string", "true", true},
{"TRUE uppercase", "TRUE", true},
{"1 numeric", "1", true},
{"false string", "false", false},
{"FALSE uppercase", "FALSE", false},
{"0 numeric", "0", false},
{"invalid value defaults to numeric check", "invalid", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
// Set env var (no DB value)
t.Setenv("FEATURE_CERBERUS_ENABLED", tt.envValue)
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
err := json.Unmarshal(w.Body.Bytes(), &flags)
require.NoError(t, err)
assert.Equal(t, tt.expected, flags["feature.cerberus.enabled"],
"envValue=%q should result in %v", tt.envValue, tt.expected)
})
}
}
func TestFeatureFlagsHandler_UpdateFlags_BoolValues(t *testing.T) {
tests := []struct {
name string
value bool
dbExpect string
}{
{"true", true, "true"},
{"false", false, "false"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupFlagsDB(t)
h := NewFeatureFlagsHandler(db)
r := gin.New()
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
payload := map[string]bool{
"feature.cerberus.enabled": tt.value,
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var s models.Setting
err := db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error
require.NoError(t, err)
assert.Equal(t, tt.dbExpect, s.Value)
})
}
}
func TestFeatureFlagsHandler_NewFeatureFlagsHandler(t *testing.T) {
db := setupFlagsDB(t)
h := NewFeatureFlagsHandler(db)
gin.SetMode(gin.TestMode)
r := gin.New()
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
r.GET("/api/v1/feature-flags", h.GetFlags)
// Update multiple flags at once
payload := []byte(`{
"feature.global.enabled": true,
"feature.cerberus.enabled": false,
"feature.uptime.enabled": true
}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify by getting flags
req = httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
var flags map[string]bool
json.Unmarshal(w.Body.Bytes(), &flags)
assert.True(t, flags["feature.global.enabled"])
assert.False(t, flags["feature.cerberus.enabled"])
assert.True(t, flags["feature.uptime.enabled"])
}
func TestFeatureFlags_ShortEnvFallback_WithUnparseable(t *testing.T) {
// Test short env fallback with a value that's not directly parseable as bool
// but is "1" which should be treated as true
t.Setenv("GLOBAL_ENABLED", "1")
db := OpenTestDB(t)
h := NewFeatureFlagsHandler(db)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var flags map[string]bool
json.Unmarshal(w.Body.Bytes(), &flags)
assert.True(t, flags["feature.global.enabled"])
assert.NotNil(t, h)
assert.NotNil(t, h.DB)
assert.Equal(t, db, h.DB)
}

View File

@@ -32,7 +32,7 @@ func TestFeatureFlags_GetAndUpdate(t *testing.T) {
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
// 1) GET should return all default flags (as keys)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -83,7 +83,7 @@ func TestFeatureFlags_EnvFallback(t *testing.T) {
r := gin.New()
r.GET("/api/v1/feature-flags", h.GetFlags)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {

View File

@@ -56,7 +56,7 @@ func TestRemoteServerHandler_List(t *testing.T) {
// Test List
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers", nil)
req, _ := http.NewRequest("GET", "/api/v1/remote-servers", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -123,7 +123,7 @@ func TestRemoteServerHandler_TestConnection(t *testing.T) {
// Test connection
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/"+server.UUID+"/test", nil)
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/"+server.UUID+"/test", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -157,7 +157,7 @@ func TestRemoteServerHandler_Get(t *testing.T) {
// Test Get
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -235,14 +235,14 @@ func TestRemoteServerHandler_Delete(t *testing.T) {
// Test Delete
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/v1/remote-servers/"+server.UUID, nil)
req, _ := http.NewRequest("DELETE", "/api/v1/remote-servers/"+server.UUID, http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
// Verify Delete
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
req2, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, http.NoBody)
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusNotFound, w2.Code)
@@ -271,7 +271,7 @@ func TestProxyHostHandler_List(t *testing.T) {
// Test List
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/proxy-hosts", nil)
req, _ := http.NewRequest("GET", "/api/v1/proxy-hosts", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -362,7 +362,7 @@ func TestProxyHostHandler_PartialUpdate_DoesNotWipeFields(t *testing.T) {
// Fetch via GET to ensure DB persisted state correctly
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("GET", "/api/v1/proxy-hosts/"+original.UUID, nil)
req2, _ := http.NewRequest("GET", "/api/v1/proxy-hosts/"+original.UUID, http.NoBody)
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
@@ -382,7 +382,7 @@ func TestHealthHandler(t *testing.T) {
router.GET("/health", handlers.HealthHandler)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/health", nil)
req, _ := http.NewRequest("GET", "/health", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -404,7 +404,7 @@ func TestRemoteServerHandler_Errors(t *testing.T) {
// Get non-existent
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/non-existent", nil)
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/non-existent", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
@@ -417,7 +417,7 @@ func TestRemoteServerHandler_Errors(t *testing.T) {
// Delete non-existent
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent", nil)
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -15,7 +15,7 @@ func TestHealthHandler(t *testing.T) {
r := gin.New()
r.GET("/health", HealthHandler)
req, _ := http.NewRequest("GET", "/health", nil)
req, _ := http.NewRequest("GET", "/health", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -27,3 +27,12 @@ func TestHealthHandler(t *testing.T) {
assert.Equal(t, "ok", resp["status"])
assert.NotEmpty(t, resp["version"])
}
func TestGetLocalIP(t *testing.T) {
// This test just ensures getLocalIP doesn't panic
// It may return empty string in test environments
ip := getLocalIP()
// IP can be empty or a valid IPv4 address
t.Logf("getLocalIP returned: %q", ip)
// No assertion needed - just exercising the code path
}

View File

@@ -267,7 +267,7 @@ func (h *ImportHandler) Upload(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid import directory"})
return
}
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
if err := os.MkdirAll(uploadsDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
return
}
@@ -276,7 +276,7 @@ func (h *ImportHandler) Upload(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid temp path"})
return
}
if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
if err := os.WriteFile(tempPath, []byte(req.Content), 0o644); err != nil {
middleware.GetRequestLogger(c).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithError(err).Error("Import Upload: failed to write temp file")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
return
@@ -415,7 +415,7 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session directory"})
return
}
if err := os.MkdirAll(sessionDir, 0755); err != nil {
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"})
return
}
@@ -438,13 +438,13 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) {
// Create parent directory if file is in a subdirectory
if dir := filepath.Dir(targetPath); dir != sessionDir {
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create directory for %s", f.Filename)})
return
}
}
if err := os.WriteFile(targetPath, []byte(f.Content), 0644); err != nil {
if err := os.WriteFile(targetPath, []byte(f.Content), 0o644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s", f.Filename)})
return
}
@@ -510,12 +510,12 @@ func detectImportDirectives(content string) []string {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "import ") {
path := strings.TrimSpace(strings.TrimPrefix(trimmed, "import"))
importPath := strings.TrimSpace(strings.TrimPrefix(trimmed, "import"))
// Remove any trailing comments
if idx := strings.Index(path, "#"); idx != -1 {
path = strings.TrimSpace(path[:idx])
if idx := strings.Index(importPath, "#"); idx != -1 {
importPath = strings.TrimSpace(importPath[:idx])
}
imports = append(imports, path)
imports = append(imports, importPath)
}
}
return imports

View File

@@ -23,7 +23,7 @@ func TestImportUploadSanitizesFilename(t *testing.T) {
db := OpenTestDB(t)
// Create a fake caddy executable to avoid dependency on system binary
fakeCaddy := filepath.Join(tmpDir, "caddy")
os.WriteFile(fakeCaddy, []byte("#!/bin/sh\nexit 0"), 0755)
os.WriteFile(fakeCaddy, []byte("#!/bin/sh\nexit 0"), 0o755)
svc := NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()

View File

@@ -40,7 +40,7 @@ func TestImportHandler_GetStatus(t *testing.T) {
router.GET("/import/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/status", nil)
req, _ := http.NewRequest("GET", "/import/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -52,7 +52,7 @@ func TestImportHandler_GetStatus(t *testing.T) {
// Case 2: No DB session but has mounted Caddyfile
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
os.WriteFile(mountPath, []byte("example.com"), 0644)
os.WriteFile(mountPath, []byte("example.com"), 0o644)
handler2 := handlers.NewImportHandler(db, "echo", "/tmp", mountPath)
router2 := gin.New()
@@ -97,7 +97,7 @@ func TestImportHandler_GetPreview(t *testing.T) {
// Case 1: No session
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
req, _ := http.NewRequest("GET", "/import/preview", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
@@ -110,7 +110,7 @@ func TestImportHandler_GetPreview(t *testing.T) {
db.Create(&session)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/import/preview", nil)
req, _ = http.NewRequest("GET", "/import/preview", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -141,7 +141,7 @@ func TestImportHandler_Cancel(t *testing.T) {
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=test-uuid", nil)
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=test-uuid", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -198,7 +198,7 @@ func TestImportHandler_Upload(t *testing.T) {
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
os.Chmod(fakeCaddy, 0755)
os.Chmod(fakeCaddy, 0o755)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
@@ -231,7 +231,7 @@ func TestImportHandler_GetPreview_WithContent(t *testing.T) {
// Case: Active session with source file
content := "example.com {\n reverse_proxy localhost:8080\n}"
sourceFile := filepath.Join(tmpDir, "source.caddyfile")
err := os.WriteFile(sourceFile, []byte(content), 0644)
err := os.WriteFile(sourceFile, []byte(content), 0o644)
assert.NoError(t, err)
// Case: Active session with source file
@@ -244,7 +244,7 @@ func TestImportHandler_GetPreview_WithContent(t *testing.T) {
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
req, _ := http.NewRequest("GET", "/import/preview", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -307,7 +307,7 @@ func TestImportHandler_Cancel_Errors(t *testing.T) {
// Case 1: Session not found
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
@@ -320,14 +320,14 @@ func TestCheckMountedImport(t *testing.T) {
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
os.Chmod(fakeCaddy, 0755)
os.Chmod(fakeCaddy, 0o755)
// Case 1: File does not exist
err := handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
// Case 2: File exists, not processed
err = os.WriteFile(mountPath, []byte("example.com"), 0644)
err = os.WriteFile(mountPath, []byte("example.com"), 0o644)
assert.NoError(t, err)
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
@@ -431,10 +431,10 @@ func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
// Create backup file
backupDir := filepath.Join(tmpDir, "backups")
os.MkdirAll(backupDir, 0755)
os.MkdirAll(backupDir, 0o755)
content := "backup content"
backupFile := filepath.Join(backupDir, "source.caddyfile")
os.WriteFile(backupFile, []byte(content), 0644)
os.WriteFile(backupFile, []byte(content), 0o644)
// Case: Active session with missing source file but existing backup
session := models.ImportSession{
@@ -446,7 +446,7 @@ func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
req, _ := http.NewRequest("GET", "/import/preview", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -465,7 +465,7 @@ func TestImportHandler_RegisterRoutes(t *testing.T) {
// Verify routes exist by making requests
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/import/status", nil)
req, _ := http.NewRequest("GET", "/api/v1/import/status", http.NoBody)
router.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusNotFound, w.Code)
}
@@ -478,20 +478,20 @@ func TestImportHandler_GetPreview_TransientMount(t *testing.T) {
// Create a mounted Caddyfile
content := "example.com"
err := os.WriteFile(mountPath, []byte(content), 0644)
err := os.WriteFile(mountPath, []byte(content), 0o644)
assert.NoError(t, err)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
os.Chmod(fakeCaddy, 0o755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
req, _ := http.NewRequest("GET", "/import/preview", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Response body: %s", w.Body.String())
@@ -522,7 +522,7 @@ func TestImportHandler_Commit_TransientUpload(t *testing.T) {
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
os.Chmod(fakeCaddy, 0o755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
@@ -580,13 +580,13 @@ func TestImportHandler_Commit_TransientMount(t *testing.T) {
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
// Create a mounted Caddyfile
err := os.WriteFile(mountPath, []byte("mounted.com"), 0644)
err := os.WriteFile(mountPath, []byte("mounted.com"), 0o644)
assert.NoError(t, err)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
os.Chmod(fakeCaddy, 0o755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
@@ -627,7 +627,7 @@ func TestImportHandler_Cancel_TransientUpload(t *testing.T) {
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
os.Chmod(fakeCaddy, 0o755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
@@ -658,7 +658,7 @@ func TestImportHandler_Cancel_TransientUpload(t *testing.T) {
// Cancel should delete the file
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid="+sessionID, nil)
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid="+sessionID, http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -704,7 +704,7 @@ func TestImportHandler_Errors(t *testing.T) {
// Cancel - Session Not Found
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
@@ -794,7 +794,7 @@ func TestImportHandler_UploadMulti(t *testing.T) {
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
os.Chmod(fakeCaddy, 0o755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
@@ -84,22 +85,36 @@ func (h *LogsHandler) Download(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
return
}
defer os.Remove(tmpFile.Name())
defer func() {
if err := os.Remove(tmpFile.Name()); err != nil {
logger.Log().WithError(err).Warn("failed to remove temp file")
}
}()
srcFile, err := os.Open(path)
if err != nil {
_ = tmpFile.Close()
if err := tmpFile.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close temp file")
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open log file"})
return
}
defer func() { _ = srcFile.Close() }()
defer func() {
if err := srcFile.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close source log file")
}
}()
if _, err := io.Copy(tmpFile, srcFile); err != nil {
_ = tmpFile.Close()
if err := tmpFile.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close temp file after copy error")
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy log file"})
return
}
_ = tmpFile.Close()
if err := tmpFile.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close temp file after copy")
}
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(tmpFile.Name())

View File

@@ -1,6 +1,7 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
@@ -38,7 +39,7 @@ func TestLogsHandler_Read_FilterBySearch(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "access.log"}}
c.Request = httptest.NewRequest("GET", "/logs/access.log?search=error", nil)
c.Request = httptest.NewRequest("GET", "/logs/access.log?search=error", http.NoBody)
h.Read(c)
@@ -69,7 +70,7 @@ func TestLogsHandler_Read_FilterByHost(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "access.log"}}
c.Request = httptest.NewRequest("GET", "/logs/access.log?host=example.com", nil)
c.Request = httptest.NewRequest("GET", "/logs/access.log?host=example.com", http.NoBody)
h.Read(c)
@@ -99,7 +100,7 @@ func TestLogsHandler_Read_FilterByLevel(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "access.log"}}
c.Request = httptest.NewRequest("GET", "/logs/access.log?level=error", nil)
c.Request = httptest.NewRequest("GET", "/logs/access.log?level=error", http.NoBody)
h.Read(c)
@@ -129,7 +130,7 @@ func TestLogsHandler_Read_FilterByStatus(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "access.log"}}
c.Request = httptest.NewRequest("GET", "/logs/access.log?status=500", nil)
c.Request = httptest.NewRequest("GET", "/logs/access.log?status=500", http.NoBody)
h.Read(c)
@@ -159,7 +160,7 @@ func TestLogsHandler_Read_SortAsc(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "access.log"}}
c.Request = httptest.NewRequest("GET", "/logs/access.log?sort=asc", nil)
c.Request = httptest.NewRequest("GET", "/logs/access.log?sort=asc", http.NoBody)
h.Read(c)
@@ -185,7 +186,7 @@ func TestLogsHandler_List_DirectoryIsFile(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/logs", nil)
c.Request = httptest.NewRequest("GET", "/logs", http.NoBody)
h.List(c)

View File

@@ -26,24 +26,24 @@ func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) {
// It derives it from cfg.DatabasePath
dataDir := filepath.Join(tmpDir, "data")
err = os.MkdirAll(dataDir, 0755)
err = os.MkdirAll(dataDir, 0o755)
require.NoError(t, err)
dbPath := filepath.Join(dataDir, "charon.db")
// Create logs dir
logsDir := filepath.Join(dataDir, "logs")
err = os.MkdirAll(logsDir, 0755)
err = os.MkdirAll(logsDir, 0o755)
require.NoError(t, err)
// Create dummy log files with JSON content
log1 := `{"level":"info","ts":1600000000,"msg":"request handled","request":{"method":"GET","host":"example.com","uri":"/","remote_ip":"1.2.3.4"},"status":200}`
log2 := `{"level":"error","ts":1600000060,"msg":"error handled","request":{"method":"POST","host":"api.example.com","uri":"/submit","remote_ip":"5.6.7.8"},"status":500}`
err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(log1+"\n"+log2+"\n"), 0644)
err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(log1+"\n"+log2+"\n"), 0o644)
require.NoError(t, err)
// Write a charon.log and create a cpmp.log symlink to it for backward compatibility (cpmp is legacy)
err = os.WriteFile(filepath.Join(logsDir, "charon.log"), []byte("app log line 1\napp log line 2"), 0644)
err = os.WriteFile(filepath.Join(logsDir, "charon.log"), []byte("app log line 1\napp log line 2"), 0o644)
require.NoError(t, err)
// Create legacy cpmp log symlink (cpmp is a legacy name for Charon)
_ = os.Symlink(filepath.Join(logsDir, "charon.log"), filepath.Join(logsDir, "cpmp.log"))
@@ -72,7 +72,7 @@ func TestLogsLifecycle(t *testing.T) {
defer os.RemoveAll(tmpDir)
// 1. List logs
req := httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/logs", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -93,7 +93,7 @@ func TestLogsLifecycle(t *testing.T) {
require.True(t, found)
// 2. Read log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log?limit=2", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log?limit=2", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
@@ -108,27 +108,27 @@ func TestLogsLifecycle(t *testing.T) {
require.Len(t, content.Logs, 2)
// 3. Download log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log/download", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log/download", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
require.Contains(t, resp.Body.String(), "request handled")
// 4. Read non-existent log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 5. Download non-existent log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log/download", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log/download", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 6. List logs error (delete directory)
os.RemoveAll(filepath.Join(tmpDir, "data", "logs"))
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// ListLogs returns empty list if dir doesn't exist, so it should be 200 OK with empty list

View File

@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -112,7 +113,7 @@ func TestRemoteServerHandler_List_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/remote-servers", nil)
c.Request = httptest.NewRequest("GET", "/remote-servers", http.NoBody)
h.List(c)
@@ -131,7 +132,7 @@ func TestRemoteServerHandler_List_EnabledOnly(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/remote-servers?enabled=true", nil)
c.Request = httptest.NewRequest("GET", "/remote-servers?enabled=true", http.NoBody)
h.List(c)
@@ -267,7 +268,7 @@ func TestUptimeHandler_GetHistory_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
c.Request = httptest.NewRequest("GET", "/uptime/test-id/history", nil)
c.Request = httptest.NewRequest("GET", "/uptime/test-id/history", http.NoBody)
h.GetHistory(c)

View File

@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -35,7 +36,7 @@ func TestNotificationHandler_List_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/notifications", nil)
c.Request = httptest.NewRequest("GET", "/notifications", http.NoBody)
h.List(c)
@@ -55,7 +56,7 @@ func TestNotificationHandler_List_UnreadOnly(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/notifications?unread=true", nil)
c.Request = httptest.NewRequest("GET", "/notifications?unread=true", http.NoBody)
h.List(c)

View File

@@ -44,7 +44,7 @@ func TestNotificationHandler_List(t *testing.T) {
// Test List All
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/notifications", nil)
req, _ := http.NewRequest("GET", "/notifications", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -55,7 +55,7 @@ func TestNotificationHandler_List(t *testing.T) {
// Test List Unread
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notifications?unread=true", nil)
req, _ = http.NewRequest("GET", "/notifications?unread=true", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -79,7 +79,7 @@ func TestNotificationHandler_MarkAsRead(t *testing.T) {
router.POST("/notifications/:id/read", handler.MarkAsRead)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notifications/"+notif.ID+"/read", nil)
req, _ := http.NewRequest("POST", "/notifications/"+notif.ID+"/read", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -103,7 +103,7 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
router.POST("/notifications/read-all", handler.MarkAllAsRead)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
req, _ := http.NewRequest("POST", "/notifications/read-all", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -126,7 +126,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
req, _ := http.NewRequest("POST", "/notifications/read-all", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
@@ -145,7 +145,7 @@ func TestNotificationHandler_DBError(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
req, _ := http.NewRequest("POST", "/notifications/1/read", nil)
req, _ := http.NewRequest("POST", "/notifications/1/read", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)

View File

@@ -61,7 +61,7 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) {
assert.NotEmpty(t, created.ID)
// 2. List
req, _ = http.NewRequest("GET", "/api/v1/notifications/providers", nil)
req, _ = http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -88,7 +88,7 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) {
assert.Equal(t, "Updated Discord", dbProvider.Name)
// 4. Delete
req, _ = http.NewRequest("DELETE", "/api/v1/notifications/providers/"+created.ID, nil)
req, _ = http.NewRequest("DELETE", "/api/v1/notifications/providers/"+created.ID, http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -102,7 +102,7 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) {
func TestNotificationProviderHandler_Templates(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
req, _ := http.NewRequest("GET", "/api/v1/notifications/templates", nil)
req, _ := http.NewRequest("GET", "/api/v1/notifications/templates", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

View File

@@ -45,7 +45,7 @@ func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) {
require.NotEmpty(t, created.ID)
// List
req = httptest.NewRequest(http.MethodGet, "/api/v1/notifications/templates", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/notifications/templates", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
@@ -76,7 +76,7 @@ func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) {
require.NotEmpty(t, previewResp["rendered"])
// Delete
req = httptest.NewRequest(http.MethodDelete, "/api/v1/notifications/templates/"+created.ID, nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/notifications/templates/"+created.ID, http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)

View File

@@ -33,20 +33,7 @@ func setupPerfDB(t *testing.T) *gorm.DB {
}
// thresholdFromEnv loads threshold from environment var as milliseconds
func thresholdFromEnv(envKey string, defaultMs float64) float64 {
if v := os.Getenv(envKey); v != "" {
// try parse as float
if parsed, err := time.ParseDuration(v); err == nil {
return ms(parsed)
}
// fallback try parse as number ms
var f float64
if _, err := fmt.Sscanf(v, "%f", &f); err == nil {
return f
}
}
return defaultMs
}
// thresholdFromEnv removed — tests use inline environment parsing for clarity.
// gatherStats runs the request counts times and returns durations ms
func gatherStats(t *testing.T, req *http.Request, router http.Handler, counts int) []float64 {
@@ -66,7 +53,7 @@ func gatherStats(t *testing.T, req *http.Request, router http.Handler, counts in
}
// computePercentiles returns avg, p50, p95, p99, max
func computePercentiles(samples []float64) (avg, p50, p95, p99, max float64) {
func computePercentiles(samples []float64) (avg, p50, p95, p99, maxVal float64) {
sort.Float64s(samples)
var sum float64
for _, s := range samples {
@@ -86,15 +73,11 @@ func computePercentiles(samples []float64) (avg, p50, p95, p99, max float64) {
p50 = p(0.50)
p95 = p(0.95)
p99 = p(0.99)
max = samples[len(samples)-1]
maxVal = samples[len(samples)-1]
return
}
func perfLogStats(t *testing.T, title string, samples []float64) {
av, p50, p95, p99, max := computePercentiles(samples)
t.Logf("%s - avg=%.3fms p50=%.3fms p95=%.3fms p99=%.3fms max=%.3fms", title, av, p50, p95, p99, max)
// no assert by default, individual tests decide how to fail
}
// perfLogStats removed — tests log stats inline where helpful.
func TestPerf_GetStatus_AssertThreshold(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
@@ -110,9 +93,9 @@ func TestPerf_GetStatus_AssertThreshold(t *testing.T) {
router.GET("/api/v1/security/status", h.GetStatus)
counts := 500
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
samples := gatherStats(t, req, router, counts)
avg, _, p95, _, max := computePercentiles(samples)
avg, _, p95, _, maxVal := computePercentiles(samples)
// default thresholds ms
thresholdP95 := 2.0 // 2ms per request
if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95"); env != "" {
@@ -121,7 +104,7 @@ func TestPerf_GetStatus_AssertThreshold(t *testing.T) {
}
}
// fail if p95 exceeds threshold
t.Logf("GetStatus avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max)
t.Logf("GetStatus avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, maxVal)
if p95 > thresholdP95 {
t.Fatalf("GetStatus P95 (%.3fms) exceeds threshold %.3fms", p95, thresholdP95)
}
@@ -140,7 +123,7 @@ func TestPerf_GetStatus_Parallel_AssertThreshold(t *testing.T) {
samples := make(chan float64, n)
var worker = func() {
for i := 0; i < n; i++ {
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
s := time.Now()
router.ServeHTTP(w, req)
@@ -157,14 +140,14 @@ func TestPerf_GetStatus_Parallel_AssertThreshold(t *testing.T) {
for i := 0; i < n*4; i++ {
collected = append(collected, <-samples)
}
avg, _, p95, _, max := computePercentiles(collected)
avg, _, p95, _, maxVal := computePercentiles(collected)
thresholdP95 := 5.0 // 5ms default
if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95_PARALLEL"); env != "" {
if parsed, err := time.ParseDuration(env); err == nil {
thresholdP95 = ms(parsed)
}
}
t.Logf("GetStatus Parallel avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max)
t.Logf("GetStatus Parallel avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, maxVal)
if p95 > thresholdP95 {
t.Fatalf("GetStatus Parallel P95 (%.3fms) exceeds threshold %.3fms", p95, thresholdP95)
}
@@ -184,16 +167,16 @@ func TestPerf_ListDecisions_AssertThreshold(t *testing.T) {
router.GET("/api/v1/security/decisions", h.ListDecisions)
counts := 200
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", nil)
req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", http.NoBody)
samples := gatherStats(t, req, router, counts)
avg, _, p95, _, max := computePercentiles(samples)
avg, _, p95, _, maxVal := computePercentiles(samples)
thresholdP95 := 30.0 // 30ms default
if env := os.Getenv("PERF_MAX_MS_LISTDECISIONS_P95"); env != "" {
if parsed, err := time.ParseDuration(env); err == nil {
thresholdP95 = ms(parsed)
}
}
t.Logf("ListDecisions avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max)
t.Logf("ListDecisions avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, maxVal)
if p95 > thresholdP95 {
t.Fatalf("ListDecisions P95 (%.3fms) exceeds threshold %.3fms", p95, thresholdP95)
}

View File

@@ -125,9 +125,9 @@ func (h *ProxyHostHandler) Create(c *gin.Context) {
// Get retrieves a proxy host by UUID.
func (h *ProxyHostHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
uuidStr := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
host, err := h.service.GetByUUID(uuidStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
@@ -297,14 +297,22 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
}
}
// Sync associated uptime monitor with updated proxy host values
if h.uptimeService != nil {
if err := h.uptimeService.SyncMonitorForHost(host.ID); err != nil {
middleware.GetRequestLogger(c).WithError(err).WithField("host_id", host.ID).Warn("Failed to sync uptime monitor for host")
// Don't fail the request if sync fails - the host update succeeded
}
}
c.JSON(http.StatusOK, host)
}
// Delete removes a proxy host.
func (h *ProxyHostHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
uuidStr := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
host, err := h.service.GetByUUID(uuidStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
@@ -391,11 +399,11 @@ func (h *ProxyHostHandler) BulkUpdateACL(c *gin.Context) {
updated := 0
errors := []map[string]string{}
for _, uuid := range req.HostUUIDs {
host, err := h.service.GetByUUID(uuid)
for _, hostUUID := range req.HostUUIDs {
host, err := h.service.GetByUUID(hostUUID)
if err != nil {
errors = append(errors, map[string]string{
"uuid": uuid,
"uuid": hostUUID,
"error": "proxy host not found",
})
continue
@@ -404,7 +412,7 @@ func (h *ProxyHostHandler) BulkUpdateACL(c *gin.Context) {
host.AccessListID = req.AccessListID
if err := h.service.Update(host); err != nil {
errors = append(errors, map[string]string{
"uuid": uuid,
"uuid": hostUUID,
"error": err.Error(),
})
continue

View File

@@ -59,7 +59,7 @@ func TestProxyHostLifecycle(t *testing.T) {
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
require.Equal(t, "media.example.com", created.DomainNames)
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", http.NoBody)
listResp := httptest.NewRecorder()
router.ServeHTTP(listResp, listReq)
require.Equal(t, http.StatusOK, listResp.Code)
@@ -69,7 +69,7 @@ func TestProxyHostLifecycle(t *testing.T) {
require.Len(t, hosts, 1)
// Get by ID
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, http.NoBody)
getResp := httptest.NewRecorder()
router.ServeHTTP(getResp, getReq)
require.Equal(t, http.StatusOK, getResp.Code)
@@ -92,13 +92,13 @@ func TestProxyHostLifecycle(t *testing.T) {
require.False(t, updated.Enabled)
// Delete
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+created.UUID, nil)
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+created.UUID, http.NoBody)
delResp := httptest.NewRecorder()
router.ServeHTTP(delResp, delReq)
require.Equal(t, http.StatusOK, delResp.Code)
// Verify Delete
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, http.NoBody)
getResp2 := httptest.NewRecorder()
router.ServeHTTP(getResp2, getReq2)
require.Equal(t, http.StatusNotFound, getResp2.Code)
@@ -131,7 +131,7 @@ func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) {
require.Equal(t, int64(1), count)
// Delete host with delete_uptime=true
req := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID+"?delete_uptime=true", nil)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID+"?delete_uptime=true", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
@@ -198,7 +198,7 @@ func TestProxyHostErrors(t *testing.T) {
db.Create(&host)
// Test Get - Not Found
req = httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
@@ -226,13 +226,13 @@ func TestProxyHostErrors(t *testing.T) {
require.Equal(t, http.StatusInternalServerError, resp.Code)
// Test Delete - Not Found
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Test Delete - Apply Config Error
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID, nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID, http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
@@ -408,7 +408,7 @@ func TestProxyHostHandler_List_Error(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
@@ -465,7 +465,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) {
require.Equal(t, http.StatusOK, resp.Code)
// Test Delete with Caddy Sync
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+createdHost.UUID, nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+createdHost.UUID, http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
@@ -87,9 +88,9 @@ func (h *RemoteServerHandler) Create(c *gin.Context) {
// Get retrieves a remote server by UUID.
func (h *RemoteServerHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
uuidStr := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
server, err := h.service.GetByUUID(uuidStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -100,9 +101,9 @@ func (h *RemoteServerHandler) Get(c *gin.Context) {
// Update updates an existing remote server.
func (h *RemoteServerHandler) Update(c *gin.Context) {
uuid := c.Param("uuid")
uuidStr := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
server, err := h.service.GetByUUID(uuidStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -123,9 +124,9 @@ func (h *RemoteServerHandler) Update(c *gin.Context) {
// Delete removes a remote server.
func (h *RemoteServerHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
uuidStr := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
server, err := h.service.GetByUUID(uuidStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -154,9 +155,9 @@ func (h *RemoteServerHandler) Delete(c *gin.Context) {
// TestConnection tests the TCP connection to a remote server.
func (h *RemoteServerHandler) TestConnection(c *gin.Context) {
uuid := c.Param("uuid")
uuidStr := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
server, err := h.service.GetByUUID(uuidStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -185,7 +186,11 @@ func (h *RemoteServerHandler) TestConnection(c *gin.Context) {
c.JSON(http.StatusOK, result)
return
}
defer func() { _ = conn.Close() }()
defer func() {
if err := conn.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close tcp connection")
}
}()
// Connection successful
result["reachable"] = true
@@ -228,7 +233,11 @@ func (h *RemoteServerHandler) TestConnectionCustom(c *gin.Context) {
c.JSON(http.StatusOK, result)
return
}
defer func() { _ = conn.Close() }()
defer func() {
if err := conn.Close(); err != nil {
logger.Log().WithError(err).Warn("failed to close tcp connection")
}
}()
// Connection successful
result["reachable"] = true

View File

@@ -84,13 +84,13 @@ func TestRemoteServerHandler_FullCRUD(t *testing.T) {
assert.NotEmpty(t, created.UUID)
// List
req, _ = http.NewRequest("GET", "/api/v1/remote-servers", nil)
req, _ = http.NewRequest("GET", "/api/v1/remote-servers", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Get
req, _ = http.NewRequest("GET", "/api/v1/remote-servers/"+created.UUID, nil)
req, _ = http.NewRequest("GET", "/api/v1/remote-servers/"+created.UUID, http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -104,7 +104,7 @@ func TestRemoteServerHandler_FullCRUD(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
// Delete
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/"+created.UUID, nil)
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/"+created.UUID, http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
@@ -122,7 +122,7 @@ func TestRemoteServerHandler_FullCRUD(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
// Delete - Not Found
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent-uuid", nil)
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent-uuid", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)

View File

@@ -29,7 +29,7 @@ func TestSecurityHandler_GetConfigAndUpdateConfig(t *testing.T) {
// Create a gin test context for GetConfig when no config exists
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest("GET", "/security/config", nil)
req := httptest.NewRequest("GET", "/security/config", http.NoBody)
c.Request = req
h.GetConfig(c)
require.Equal(t, http.StatusOK, w.Code)
@@ -53,7 +53,7 @@ func TestSecurityHandler_GetConfigAndUpdateConfig(t *testing.T) {
// Now call GetConfig again and ensure config is returned
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
req = httptest.NewRequest("GET", "/security/config", nil)
req = httptest.NewRequest("GET", "/security/config", http.NoBody)
c.Request = req
h.GetConfig(c)
require.Equal(t, http.StatusOK, w.Code)

View File

@@ -65,7 +65,7 @@ func TestSecurityHandler_GetStatus_SQLInjection(t *testing.T) {
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -248,7 +248,7 @@ func TestSecurityHandler_GetStatus_SettingsOverride(t *testing.T) {
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -293,7 +293,7 @@ func TestSecurityHandler_GetStatus_DisabledViaSettings(t *testing.T) {
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -342,7 +342,7 @@ func TestSecurityAudit_DeleteRuleSet_InvalidID(t *testing.T) {
if tc.id == "" {
url = "/api/v1/security/rulesets/"
}
req := httptest.NewRequest("DELETE", url, nil)
req := httptest.NewRequest("DELETE", url, http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -383,7 +383,7 @@ func TestSecurityHandler_UpsertRuleSet_XSSInContent(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
// Verify it's stored and returned as JSON (not rendered as HTML)
req2 := httptest.NewRequest("GET", "/api/v1/security/rulesets", nil)
req2 := httptest.NewRequest("GET", "/api/v1/security/rulesets", http.NoBody)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
@@ -468,7 +468,7 @@ func TestSecurityHandler_GetStatus_NilDB(t *testing.T) {
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
// Should not panic
@@ -498,7 +498,7 @@ func TestSecurityHandler_Enable_WithoutWhitelist(t *testing.T) {
router.POST("/api/v1/security/enable", h.Enable)
// Try to enable without token or whitelist
req := httptest.NewRequest("POST", "/api/v1/security/enable", nil)
req := httptest.NewRequest("POST", "/api/v1/security/enable", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -525,7 +525,7 @@ func TestSecurityHandler_Disable_RequiresToken(t *testing.T) {
router.POST("/api/v1/security/disable", h.Disable)
// Try to disable from non-localhost without token
req := httptest.NewRequest("POST", "/api/v1/security/disable", nil)
req := httptest.NewRequest("POST", "/api/v1/security/disable", http.NoBody)
req.RemoteAddr = "10.0.0.5:12345"
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -560,7 +560,7 @@ func TestSecurityHandler_GetStatus_CrowdSecModeValidation(t *testing.T) {
router := gin.New()
router.GET("/api/v1/security/status", h.GetStatus)
req := httptest.NewRequest("GET", "/api/v1/security/status", nil)
req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@@ -46,7 +46,7 @@ func TestSecurityHandler_GetStatus_Clean(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -72,7 +72,7 @@ func TestSecurityHandler_Cerberus_DBOverride(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -108,7 +108,7 @@ func TestSecurityHandler_ACL_DBOverride(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -127,7 +127,7 @@ func TestSecurityHandler_GenerateBreakGlass_ReturnsToken(t *testing.T) {
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/breakglass/generate", nil)
req, _ := http.NewRequest("POST", "/security/breakglass/generate", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
@@ -156,7 +156,7 @@ func TestSecurityHandler_ACL_DisabledWhenCerberusOff(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -185,7 +185,7 @@ func TestSecurityHandler_CrowdSec_Mode_DBOverride(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -209,7 +209,7 @@ func TestSecurityHandler_CrowdSec_ExternalMappedToDisabled_DBOverride(t *testing
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
@@ -233,7 +233,7 @@ func TestSecurityHandler_ExternalModeMappedToDisabled(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
@@ -279,7 +279,7 @@ func TestSecurityHandler_Enable_Disable_WithAdminWhitelistAndToken(t *testing.T)
assert.Equal(t, http.StatusOK, resp.Code)
// Generate break-glass token
req = httptest.NewRequest("POST", "/api/v1/security/breakglass/generate", nil)
req = httptest.NewRequest("POST", "/api/v1/security/breakglass/generate", http.NoBody)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)

View File

@@ -102,7 +102,7 @@ func TestSecurityHandler_GetConfig_Success(t *testing.T) {
router.GET("/security/config", handler.GetConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/config", nil)
req, _ := http.NewRequest("GET", "/security/config", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -122,7 +122,7 @@ func TestSecurityHandler_GetConfig_NotFound(t *testing.T) {
router.GET("/security/config", handler.GetConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/config", nil)
req, _ := http.NewRequest("GET", "/security/config", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -147,7 +147,7 @@ func TestSecurityHandler_ListDecisions_Success(t *testing.T) {
router.GET("/security/decisions", handler.ListDecisions)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/decisions", nil)
req, _ := http.NewRequest("GET", "/security/decisions", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -173,7 +173,7 @@ func TestSecurityHandler_ListDecisions_WithLimit(t *testing.T) {
router.GET("/security/decisions", handler.ListDecisions)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/decisions?limit=2", nil)
req, _ := http.NewRequest("GET", "/security/decisions?limit=2", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -286,7 +286,7 @@ func TestSecurityHandler_ListRuleSets_Success(t *testing.T) {
router.GET("/security/rulesets", handler.ListRuleSets)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/rulesets", nil)
req, _ := http.NewRequest("GET", "/security/rulesets", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -377,7 +377,7 @@ func TestSecurityHandler_DeleteRuleSet_Success(t *testing.T) {
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/1", nil)
req, _ := http.NewRequest("DELETE", "/security/rulesets/1", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -397,7 +397,7 @@ func TestSecurityHandler_DeleteRuleSet_NotFound(t *testing.T) {
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/999", nil)
req, _ := http.NewRequest("DELETE", "/security/rulesets/999", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
@@ -413,7 +413,7 @@ func TestSecurityHandler_DeleteRuleSet_InvalidID(t *testing.T) {
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/invalid", nil)
req, _ := http.NewRequest("DELETE", "/security/rulesets/invalid", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
@@ -431,7 +431,7 @@ func TestSecurityHandler_DeleteRuleSet_EmptyID(t *testing.T) {
// This should hit the "id is required" check if we bypass routing
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/security/rulesets/", nil)
req, _ := http.NewRequest("DELETE", "/security/rulesets/", http.NoBody)
router.ServeHTTP(w, req)
// Router won't match this path, so 404
@@ -517,7 +517,7 @@ func TestSecurityHandler_Enable_WithValidBreakGlassToken(t *testing.T) {
// Generate a break-glass token
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/breakglass/generate", nil)
req, _ := http.NewRequest("POST", "/security/breakglass/generate", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -609,7 +609,7 @@ func TestSecurityHandler_Disable_FromRemoteWithToken(t *testing.T) {
// Generate token
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/breakglass/generate", nil)
req, _ := http.NewRequest("POST", "/security/breakglass/generate", http.NoBody)
router.ServeHTTP(w, req)
var tokenResp map[string]string
json.Unmarshal(w.Body.Bytes(), &tokenResp)
@@ -689,7 +689,7 @@ func TestSecurityHandler_GenerateBreakGlass_NoConfig(t *testing.T) {
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/security/breakglass/generate", nil)
req, _ := http.NewRequest("POST", "/security/breakglass/generate", http.NoBody)
router.ServeHTTP(w, req)
// Should succeed and create a new config with the token

View File

@@ -57,7 +57,7 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp))
require.NotNil(t, decisionResp["decision"])
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
@@ -80,7 +80,7 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &rsResp))
require.NotNil(t, rsResp["ruleset"])
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
@@ -94,7 +94,7 @@ func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) {
idFloat, ok := listRsResp["rulesets"][0]["id"].(float64)
require.True(t, ok)
id := int(idFloat)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(id), nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(id), http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
@@ -159,7 +159,7 @@ func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) {
// Read ID from DB
var rs models.SecurityRuleSet
assert.NoError(t, db.First(&rs).Error)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(int(rs.ID)), nil)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(int(rs.ID)), http.NoBody)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)

View File

@@ -131,7 +131,7 @@ func TestSecurityHandler_GetStatus_RespectsSettingsTable(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -173,7 +173,7 @@ func TestSecurityHandler_GetStatus_WAFModeFromSettings(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -205,7 +205,7 @@ func TestSecurityHandler_GetStatus_RateLimitModeFromSettings(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)

View File

@@ -90,7 +90,7 @@ func TestSecurityHandler_GetStatus_Fixed(t *testing.T) {
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
req, _ := http.NewRequest("GET", "/security/status", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)

View File

@@ -38,7 +38,7 @@ func TestSettingsHandler_GetSettings(t *testing.T) {
router.GET("/settings", handler.GetSettings)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings", nil)
req, _ := http.NewRequest("GET", "/settings", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -148,7 +148,7 @@ func TestSettingsHandler_GetSMTPConfig(t *testing.T) {
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings/smtp", nil)
req, _ := http.NewRequest("GET", "/settings/smtp", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -169,7 +169,7 @@ func TestSettingsHandler_GetSMTPConfig_Empty(t *testing.T) {
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings/smtp", nil)
req, _ := http.NewRequest("GET", "/settings/smtp", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -303,7 +303,7 @@ func TestSettingsHandler_TestSMTPConfig_NonAdmin(t *testing.T) {
})
router.POST("/settings/smtp/test", handler.TestSMTPConfig)
req, _ := http.NewRequest("POST", "/settings/smtp/test", nil)
req, _ := http.NewRequest("POST", "/settings/smtp/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -321,7 +321,7 @@ func TestSettingsHandler_TestSMTPConfig_NotConfigured(t *testing.T) {
})
router.POST("/settings/smtp/test", handler.TestSMTPConfig)
req, _ := http.NewRequest("POST", "/settings/smtp/test", nil)
req, _ := http.NewRequest("POST", "/settings/smtp/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@@ -10,7 +10,7 @@ import (
func TestGetClientIPHeadersAndRemoteAddr(t *testing.T) {
// Cloudflare header should win
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req.Header.Set("CF-Connecting-IP", "5.6.7.8")
ip := getClientIP(req)
if ip != "5.6.7.8" {
@@ -18,7 +18,7 @@ func TestGetClientIPHeadersAndRemoteAddr(t *testing.T) {
}
// X-Real-IP should be preferred over RemoteAddr
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2 := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req2.Header.Set("X-Real-IP", "10.0.0.4")
req2.RemoteAddr = "1.2.3.4:5678"
ip2 := getClientIP(req2)
@@ -27,7 +27,7 @@ func TestGetClientIPHeadersAndRemoteAddr(t *testing.T) {
}
// X-Forwarded-For returns first in list
req3 := httptest.NewRequest(http.MethodGet, "/", nil)
req3 := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req3.Header.Set("X-Forwarded-For", "192.168.0.1, 192.168.0.2")
ip3 := getClientIP(req3)
if ip3 != "192.168.0.1" {
@@ -35,7 +35,7 @@ func TestGetClientIPHeadersAndRemoteAddr(t *testing.T) {
}
// Fallback to remote addr port trimmed
req4 := httptest.NewRequest(http.MethodGet, "/", nil)
req4 := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req4.RemoteAddr = "7.7.7.7:8888"
ip4 := getClientIP(req4)
if ip4 != "7.7.7.7" {
@@ -49,12 +49,43 @@ func TestGetMyIPHandler(t *testing.T) {
handler := NewSystemHandler()
r.GET("/myip", handler.GetMyIP)
// With CF header
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/myip", nil)
req.Header.Set("CF-Connecting-IP", "5.6.7.8")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 got %d", w.Code)
}
t.Run("with CF header", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody)
req.Header.Set("CF-Connecting-IP", "5.6.7.8")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 got %d", w.Code)
}
})
t.Run("with X-Forwarded-For header", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody)
req.Header.Set("X-Forwarded-For", "9.9.9.9")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 got %d", w.Code)
}
})
t.Run("with X-Real-IP header", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody)
req.Header.Set("X-Real-IP", "8.8.8.8")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 got %d", w.Code)
}
})
t.Run("direct connection", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/myip", http.NoBody)
req.RemoteAddr = "7.7.7.7:9999"
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 got %d", w.Code)
}
})
}

View File

@@ -1,8 +1,9 @@
package handlers
import (
crand "crypto/rand"
"fmt"
"math/rand"
"math/big"
"strings"
"testing"
"time"
@@ -11,14 +12,15 @@ import (
"gorm.io/gorm"
)
// openTestDB creates a SQLite in-memory DB unique per test and applies
// OpenTestDB creates a SQLite in-memory DB unique per test and applies
// a busy timeout and WAL journal mode to reduce SQLITE locking during parallel tests.
func OpenTestDB(t *testing.T) *gorm.DB {
t.Helper()
// Append a timestamp/random suffix to ensure uniqueness even across parallel runs
dsnName := strings.ReplaceAll(t.Name(), "/", "_")
rand.Seed(time.Now().UnixNano())
uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(10000))
// Use crypto/rand for suffix generation in tests to avoid static analysis warnings
n, _ := crand.Int(crand.Reader, big.NewInt(10000))
uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), n.Int64())
dsn := fmt.Sprintf("file:%s_%s?mode=memory&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName, uniqueSuffix)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {

View File

@@ -37,7 +37,7 @@ func TestUpdateHandler_Check(t *testing.T) {
r.GET("/api/v1/update", h.Check)
// Test Request
req := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/update", http.NoBody)
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
@@ -62,7 +62,7 @@ func TestUpdateHandler_Check(t *testing.T) {
rError := gin.New()
rError.GET("/api/v1/update", hError.Check)
reqError := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
reqError := httptest.NewRequest(http.MethodGet, "/api/v1/update", http.NoBody)
respError := httptest.NewRecorder()
rError.ServeHTTP(respError, reqError)
@@ -80,7 +80,7 @@ func TestUpdateHandler_Check(t *testing.T) {
rClientError := gin.New()
rClientError.GET("/api/v1/update", hClientError.Check)
reqClientError := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
reqClientError := httptest.NewRequest(http.MethodGet, "/api/v1/update", http.NoBody)
respClientError := httptest.NewRecorder()
rClientError.ServeHTTP(respClientError, reqClientError)

View File

@@ -52,7 +52,7 @@ func TestUptimeHandler_List(t *testing.T) {
}
db.Create(&monitor)
req, _ := http.NewRequest("GET", "/api/v1/uptime", nil)
req, _ := http.NewRequest("GET", "/api/v1/uptime", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -88,7 +88,7 @@ func TestUptimeHandler_GetHistory(t *testing.T) {
CreatedAt: time.Now(),
})
req, _ := http.NewRequest("GET", "/api/v1/uptime/"+monitorID+"/history", nil)
req, _ := http.NewRequest("GET", "/api/v1/uptime/"+monitorID+"/history", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -108,7 +108,7 @@ func TestUptimeHandler_CheckMonitor(t *testing.T) {
monitor := models.UptimeMonitor{ID: "check-mon-1", Name: "Check Monitor", Type: "http", URL: "http://example.com"}
db.Create(&monitor)
req, _ := http.NewRequest("POST", "/api/v1/uptime/check-mon-1/check", nil)
req, _ := http.NewRequest("POST", "/api/v1/uptime/check-mon-1/check", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -118,7 +118,7 @@ func TestUptimeHandler_CheckMonitor(t *testing.T) {
func TestUptimeHandler_CheckMonitor_NotFound(t *testing.T) {
r, _ := setupUptimeHandlerTest(t)
req, _ := http.NewRequest("POST", "/api/v1/uptime/nonexistent/check", nil)
req, _ := http.NewRequest("POST", "/api/v1/uptime/nonexistent/check", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -193,7 +193,7 @@ func TestUptimeHandler_DeleteAndSync(t *testing.T) {
monitor := models.UptimeMonitor{ID: "mon-delete", Name: "ToDelete", Type: "http", URL: "http://example.com"}
db.Create(&monitor)
req, _ := http.NewRequest("DELETE", "/api/v1/uptime/mon-delete", nil)
req, _ := http.NewRequest("DELETE", "/api/v1/uptime/mon-delete", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -209,7 +209,7 @@ func TestUptimeHandler_DeleteAndSync(t *testing.T) {
host := models.ProxyHost{UUID: "ph-up-1", Name: "Test Host", DomainNames: "sync.example.com", ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true}
db.Create(&host)
req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", nil)
req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -244,7 +244,7 @@ func TestUptimeHandler_DeleteAndSync(t *testing.T) {
func TestUptimeHandler_Sync_Success(t *testing.T) {
r, _ := setupUptimeHandlerTest(t)
req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", nil)
req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -259,7 +259,7 @@ func TestUptimeHandler_Delete_Error(t *testing.T) {
r, db := setupUptimeHandlerTest(t)
db.Exec("DROP TABLE IF EXISTS uptime_monitors")
req, _ := http.NewRequest("DELETE", "/api/v1/uptime/nonexistent", nil)
req, _ := http.NewRequest("DELETE", "/api/v1/uptime/nonexistent", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -270,7 +270,7 @@ func TestUptimeHandler_List_Error(t *testing.T) {
r, db := setupUptimeHandlerTest(t)
db.Exec("DROP TABLE IF EXISTS uptime_monitors")
req, _ := http.NewRequest("GET", "/api/v1/uptime", nil)
req, _ := http.NewRequest("GET", "/api/v1/uptime", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -281,7 +281,7 @@ func TestUptimeHandler_GetHistory_Error(t *testing.T) {
r, db := setupUptimeHandlerTest(t)
db.Exec("DROP TABLE IF EXISTS uptime_heartbeats")
req, _ := http.NewRequest("GET", "/api/v1/uptime/monitor-1/history", nil)
req, _ := http.NewRequest("GET", "/api/v1/uptime/monitor-1/history", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

View File

@@ -34,7 +34,7 @@ func TestUserHandler_GetSetupStatus(t *testing.T) {
r.GET("/setup", handler.GetSetupStatus)
// No users -> setup required
req, _ := http.NewRequest("GET", "/setup", nil)
req, _ := http.NewRequest("GET", "/setup", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@@ -110,7 +110,7 @@ func TestUserHandler_RegenerateAPIKey(t *testing.T) {
})
r.POST("/api-key", handler.RegenerateAPIKey)
req, _ := http.NewRequest("POST", "/api-key", nil)
req, _ := http.NewRequest("POST", "/api-key", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -143,7 +143,7 @@ func TestUserHandler_GetProfile(t *testing.T) {
})
r.GET("/profile", handler.GetProfile)
req, _ := http.NewRequest("GET", "/profile", nil)
req, _ := http.NewRequest("GET", "/profile", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -206,18 +206,18 @@ func TestUserHandler_Errors(t *testing.T) {
})
// Test Unauthorized
req, _ := http.NewRequest("GET", "/profile-no-auth", nil)
req, _ := http.NewRequest("GET", "/profile-no-auth", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
req, _ = http.NewRequest("POST", "/api-key-no-auth", nil)
req, _ = http.NewRequest("POST", "/api-key-no-auth", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
// Test Not Found (GetProfile)
req, _ = http.NewRequest("GET", "/profile-not-found", nil)
req, _ = http.NewRequest("GET", "/profile-not-found", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
@@ -229,7 +229,7 @@ func TestUserHandler_Errors(t *testing.T) {
// However, let's see if we can force an error by closing DB? No, shared DB.
// We can drop the table?
db.Migrator().DropTable(&models.User{})
req, _ = http.NewRequest("POST", "/api-key-not-found", nil)
req, _ = http.NewRequest("POST", "/api-key-not-found", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
// If table missing, Update should fail
@@ -360,7 +360,7 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
// 1. Unauthorized (no userID)
r.PUT("/profile-no-auth", handler.UpdateProfile)
req, _ := http.NewRequest("PUT", "/profile-no-auth", nil)
req, _ := http.NewRequest("PUT", "/profile-no-auth", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
@@ -409,7 +409,7 @@ func TestUserHandler_ListUsers_NonAdmin(t *testing.T) {
})
r.GET("/users", handler.ListUsers)
req := httptest.NewRequest("GET", "/users", nil)
req := httptest.NewRequest("GET", "/users", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -433,7 +433,7 @@ func TestUserHandler_ListUsers_Admin(t *testing.T) {
})
r.GET("/users", handler.ListUsers)
req := httptest.NewRequest("GET", "/users", nil)
req := httptest.NewRequest("GET", "/users", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -577,7 +577,7 @@ func TestUserHandler_GetUser_NonAdmin(t *testing.T) {
})
r.GET("/users/:id", handler.GetUser)
req := httptest.NewRequest("GET", "/users/1", nil)
req := httptest.NewRequest("GET", "/users/1", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -594,7 +594,7 @@ func TestUserHandler_GetUser_InvalidID(t *testing.T) {
})
r.GET("/users/:id", handler.GetUser)
req := httptest.NewRequest("GET", "/users/invalid", nil)
req := httptest.NewRequest("GET", "/users/invalid", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -611,7 +611,7 @@ func TestUserHandler_GetUser_NotFound(t *testing.T) {
})
r.GET("/users/:id", handler.GetUser)
req := httptest.NewRequest("GET", "/users/999", nil)
req := httptest.NewRequest("GET", "/users/999", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -632,7 +632,7 @@ func TestUserHandler_GetUser_Success(t *testing.T) {
})
r.GET("/users/:id", handler.GetUser)
req := httptest.NewRequest("GET", "/users/1", nil)
req := httptest.NewRequest("GET", "/users/1", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -759,7 +759,7 @@ func TestUserHandler_DeleteUser_NonAdmin(t *testing.T) {
})
r.DELETE("/users/:id", handler.DeleteUser)
req := httptest.NewRequest("DELETE", "/users/1", nil)
req := httptest.NewRequest("DELETE", "/users/1", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -776,7 +776,7 @@ func TestUserHandler_DeleteUser_InvalidID(t *testing.T) {
})
r.DELETE("/users/:id", handler.DeleteUser)
req := httptest.NewRequest("DELETE", "/users/invalid", nil)
req := httptest.NewRequest("DELETE", "/users/invalid", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -794,7 +794,7 @@ func TestUserHandler_DeleteUser_NotFound(t *testing.T) {
})
r.DELETE("/users/:id", handler.DeleteUser)
req := httptest.NewRequest("DELETE", "/users/999", nil)
req := httptest.NewRequest("DELETE", "/users/999", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -816,7 +816,7 @@ func TestUserHandler_DeleteUser_Success(t *testing.T) {
})
r.DELETE("/users/:id", handler.DeleteUser)
req := httptest.NewRequest("DELETE", "/users/1", nil)
req := httptest.NewRequest("DELETE", "/users/1", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -838,7 +838,7 @@ func TestUserHandler_DeleteUser_CannotDeleteSelf(t *testing.T) {
})
r.DELETE("/users/:id", handler.DeleteUser)
req := httptest.NewRequest("DELETE", "/users/1", nil)
req := httptest.NewRequest("DELETE", "/users/1", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -976,7 +976,7 @@ func TestUserHandler_ValidateInvite_MissingToken(t *testing.T) {
r := gin.New()
r.GET("/invite/validate", handler.ValidateInvite)
req := httptest.NewRequest("GET", "/invite/validate", nil)
req := httptest.NewRequest("GET", "/invite/validate", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -989,7 +989,7 @@ func TestUserHandler_ValidateInvite_InvalidToken(t *testing.T) {
r := gin.New()
r.GET("/invite/validate", handler.ValidateInvite)
req := httptest.NewRequest("GET", "/invite/validate?token=invalidtoken", nil)
req := httptest.NewRequest("GET", "/invite/validate?token=invalidtoken", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -1014,7 +1014,7 @@ func TestUserHandler_ValidateInvite_ExpiredToken(t *testing.T) {
r := gin.New()
r.GET("/invite/validate", handler.ValidateInvite)
req := httptest.NewRequest("GET", "/invite/validate?token=expiredtoken123", nil)
req := httptest.NewRequest("GET", "/invite/validate?token=expiredtoken123", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -1039,7 +1039,7 @@ func TestUserHandler_ValidateInvite_AlreadyAccepted(t *testing.T) {
r := gin.New()
r.GET("/invite/validate", handler.ValidateInvite)
req := httptest.NewRequest("GET", "/invite/validate?token=acceptedtoken123", nil)
req := httptest.NewRequest("GET", "/invite/validate?token=acceptedtoken123", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -1064,7 +1064,7 @@ func TestUserHandler_ValidateInvite_Success(t *testing.T) {
r := gin.New()
r.GET("/invite/validate", handler.ValidateInvite)
req := httptest.NewRequest("GET", "/invite/validate?token=validtoken123", nil)
req := httptest.NewRequest("GET", "/invite/validate?token=validtoken123", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -1333,7 +1333,7 @@ func TestGetBaseURL(t *testing.T) {
c.String(200, url)
})
req := httptest.NewRequest("GET", "/test", nil)
req := httptest.NewRequest("GET", "/test", http.NoBody)
req.Host = "example.com"
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()

View File

@@ -33,7 +33,7 @@ func TestAuthMiddleware_MissingHeader(t *testing.T) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -53,7 +53,7 @@ func TestRequireRole_Success(t *testing.T) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -72,7 +72,7 @@ func TestRequireRole_Forbidden(t *testing.T) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -95,7 +95,7 @@ func TestAuthMiddleware_Cookie(t *testing.T) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req, _ := http.NewRequest("GET", "/test", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -119,7 +119,7 @@ func TestAuthMiddleware_ValidToken(t *testing.T) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req, _ := http.NewRequest("GET", "/test", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -137,7 +137,7 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req, _ := http.NewRequest("GET", "/test", http.NoBody)
req.Header.Set("Authorization", "Bearer invalid-token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -155,7 +155,7 @@ func TestRequireRole_MissingRoleInContext(t *testing.T) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

View File

@@ -27,7 +27,7 @@ func TestRecoveryLogsStacktraceVerbose(t *testing.T) {
panic("test panic")
})
req := httptest.NewRequest(http.MethodGet, "/panic", nil)
req := httptest.NewRequest(http.MethodGet, "/panic", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -62,7 +62,7 @@ func TestRecoveryLogsBriefWhenNotVerbose(t *testing.T) {
panic("brief panic")
})
req := httptest.NewRequest(http.MethodGet, "/panic", nil)
req := httptest.NewRequest(http.MethodGet, "/panic", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -95,7 +95,7 @@ func TestRecoverySanitizesHeadersAndPath(t *testing.T) {
panic("sensitive panic")
})
req := httptest.NewRequest(http.MethodGet, "/panic", nil)
req := httptest.NewRequest(http.MethodGet, "/panic", http.NoBody)
// Add sensitive header that should be redacted
req.Header.Set("Authorization", "Bearer secret-token")
w := httptest.NewRecorder()

View File

@@ -24,7 +24,7 @@ func TestRequestIDAddsHeaderAndLogger(t *testing.T) {
c.String(200, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@@ -23,7 +23,7 @@ func TestRequestLoggerSanitizesPath(t *testing.T) {
router.Use(RequestLogger())
router.GET(longPath, func(c *gin.Context) { c.Status(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, longPath, nil)
req := httptest.NewRequest(http.MethodGet, longPath, http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -56,7 +56,7 @@ func TestRequestLoggerIncludesRequestID(t *testing.T) {
router.Use(RequestLogger())
router.GET("/ok", func(c *gin.Context) { c.String(200, "ok") })
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
req := httptest.NewRequest(http.MethodGet, "/ok", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {

View File

@@ -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{"<redacted>"}, sanitized["Authorization"])
require.Equal(t, []string{"<redacted>"}, sanitized["X-Api-Key"])
require.Equal(t, []string{"<redacted>"}, 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))
}

Some files were not shown because too many files have changed in this diff Show More