diff --git a/.agent/rules/.instructions.md b/.agent/rules/.instructions.md new file mode 100644 index 00000000..d8d132d8 --- /dev/null +++ b/.agent/rules/.instructions.md @@ -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. diff --git a/.codecov.yml b/.codecov.yml index 106f47a0..a6458e44 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -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/**" diff --git a/.dockerignore b/.dockerignore index ec257925..8de6d2f0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..725fefd5 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE/history-rewrite.md b/.github/PULL_REQUEST_TEMPLATE/history-rewrite.md new file mode 100644 index 00000000..98a8ed86 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/history-rewrite.md @@ -0,0 +1,27 @@ + + +## 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. diff --git a/.github/agents/Backend_Dev.agent.md b/.github/agents/Backend_Dev.agent.md index 49689d74..5b3400b9 100644 --- a/.github/agents/Backend_Dev.agent.md +++ b/.github/agents/Backend_Dev.agent.md @@ -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 diff --git a/.github/agents/DevOps.agent.md b/.github/agents/DevOps.agent.md index 4e0ca575..2793d327 100644 --- a/.github/agents/DevOps.agent.md +++ b/.github/agents/DevOps.agent.md @@ -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'] diff --git a/.github/agents/Doc_Writer.agent.md b/.github/agents/Doc_Writer.agent.md index 79bd40e8..5c739cfe 100644 --- a/.github/agents/Doc_Writer.agent.md +++ b/.github/agents/Doc_Writer.agent.md @@ -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. diff --git a/.github/agents/Frontend_Dev.agent.md b/.github/agents/Frontend_Dev.agent.md index 97ecfd47..1e19e94e 100644 --- a/.github/agents/Frontend_Dev.agent.md +++ b/.github/agents/Frontend_Dev.agent.md @@ -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 diff --git a/.github/agents/Manegment.agent.md b/.github/agents/Manegment.agent.md new file mode 100644 index 00000000..435a71a6 --- /dev/null +++ b/.github/agents/Manegment.agent.md @@ -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. + + +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). + + + +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. + + +## 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. + + +- **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. + diff --git a/.github/agents/Planning.agent.md b/.github/agents/Planning.agent.md index 7e998537..78a3ff8c 100644 --- a/.github/agents/Planning.agent.md +++ b/.github/agents/Planning.agent.md @@ -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 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. + diff --git a/.github/agents/QA_Security.agent.md b/.github/agents/QA_Security.agent.md index e0aa239b..62910888 100644 --- a/.github/agents/QA_Security.agent.md +++ b/.github/agents/QA_Security.agent.md @@ -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 - **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) @@ -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. diff --git a/.github/agents/SubagentUsage.md b/.github/agents/SubagentUsage.md new file mode 100644 index 00000000..76185269 --- /dev/null +++ b/.github/agents/SubagentUsage.md @@ -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: "", + description: "", + 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: "", 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. diff --git a/.github/propagate-config.yml b/.github/propagate-config.yml new file mode 100644 index 00000000..2a30914c --- /dev/null +++ b/.github/propagate-config.yml @@ -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 diff --git a/.github/workflows/auto-versioning.yml b/.github/workflows/auto-versioning.yml index f8bb9b08..61f29dd2 100644 --- a/.github/workflows/auto-versioning.yml +++ b/.github/workflows/auto-versioning.yml @@ -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 }} diff --git a/.github/workflows/codecov-upload.yml b/.github/workflows/codecov-upload.yml index 57bf7b09..d16b2192 100644 --- a/.github/workflows/codecov-upload.yml +++ b/.github/workflows/codecov-upload.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bd02dea9..8194f3f0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 }}" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 00000000..66bf4376 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a3b9efd9..c6aded05 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 }} diff --git a/.github/workflows/dry-run-history-rewrite.yml b/.github/workflows/dry-run-history-rewrite.yml new file mode 100644 index 00000000..77a56460 --- /dev/null +++ b/.github/workflows/dry-run-history-rewrite.yml @@ -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 diff --git a/.github/workflows/history-rewrite-tests.yml b/.github/workflows/history-rewrite-tests.yml new file mode 100644 index 00000000..d2f9bf72 --- /dev/null +++ b/.github/workflows/history-rewrite-tests.yml @@ -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 diff --git a/.github/workflows/pr-checklist.yml b/.github/workflows/pr-checklist.yml new file mode 100644 index 00000000..ff914b5c --- /dev/null +++ b/.github/workflows/pr-checklist.yml @@ -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(', ')); + } diff --git a/.github/workflows/propagate-changes.yml b/.github/workflows/propagate-changes.yml index 0c75efe0..1a193bc8 100644 --- a/.github/workflows/propagate-changes.yml +++ b/.github/workflows/propagate-changes.yml @@ -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}`); } diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index d4433b51..c2d98376 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -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]} diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index c142ab8d..9a379cee 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -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 diff --git a/.github/workflows/repo-health.yml b/.github/workflows/repo-health.yml new file mode 100644 index 00000000..462d2021 --- /dev/null +++ b/.github/workflows/repo-health.yml @@ -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 diff --git a/.gitignore b/.gitignore index 80f998d7..b9e900ad 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 81acf9b7..92087696 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index dc0405a6..22194c2e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1231d3cd..27aa77c7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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" + } + } + ] + } diff --git a/BULK_ACL_FEATURE.md b/BULK_ACL_FEATURE.md new file mode 100644 index 00000000..0eebe8fb --- /dev/null +++ b/BULK_ACL_FEATURE.md @@ -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 +``` + +#### 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) diff --git a/Dockerfile b/Dockerfile index af4fe847..f08a8653 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index cefd5d23..7db14981 100644 --- a/Makefile +++ b/Makefile @@ -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)..." diff --git a/SECURITY_IMPLEMENTATION_PLAN.md b/SECURITY_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..1909458d --- /dev/null +++ b/SECURITY_IMPLEMENTATION_PLAN.md @@ -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). diff --git a/backend/.env.example b/backend/.env.example index 7b6f098e..a2559f92 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,6 +3,8 @@ CHARON_HTTP_PORT=8080 CHARON_DB_PATH=./data/charon.db CHARON_CADDY_ADMIN_API=http://localhost:2019 CHARON_CADDY_CONFIG_DIR=./data/caddy +# HUB_BASE_URL overrides the CrowdSec hub endpoint used when cscli is unavailable (defaults to https://hub-data.crowdsec.net) +# HUB_BASE_URL=https://hub-data.crowdsec.net CERBERUS_SECURITY_CERBERUS_ENABLED=false CHARON_SECURITY_CERBERUS_ENABLED=false CPM_SECURITY_CERBERUS_ENABLED=false diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 27de4874..9f46e9d2 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -20,6 +20,9 @@ linters: enabled-tags: - diagnostic - performance + - style + - opinionated + - experimental disabled-checks: - whyNoLint - wrapperFunc diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 64df9f1a..c12c02d8 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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") diff --git a/backend/go.mod b/backend/go.mod index 53db9249..37cee78f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index 516d18a8..8c6c6c8a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/integration/crowdsec_integration_test.go b/backend/integration/crowdsec_integration_test.go new file mode 100644 index 00000000..a0a1351a --- /dev/null +++ b/backend/integration/crowdsec_integration_test.go @@ -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") + } +} diff --git a/backend/internal/api/handlers/access_list_handler_coverage_test.go b/backend/internal/api/handlers/access_list_handler_coverage_test.go index c234b7b1..ad50fd9f 100644 --- a/backend/internal/api/handlers/access_list_handler_coverage_test.go +++ b/backend/internal/api/handlers/access_list_handler_coverage_test.go @@ -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) diff --git a/backend/internal/api/handlers/access_list_handler_test.go b/backend/internal/api/handlers/access_list_handler_test.go index ad795183..51a84ea1 100644 --- a/backend/internal/api/handlers/access_list_handler_test.go +++ b/backend/internal/api/handlers/access_list_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/additional_coverage_test.go b/backend/internal/api/handlers/additional_coverage_test.go index 660b59c8..15aa1a5b 100644 --- a/backend/internal/api/handlers/additional_coverage_test.go +++ b/backend/internal/api/handlers/additional_coverage_test.go @@ -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) diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 878821ba..77340c13 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/backup_handler_sanitize_test.go b/backend/internal/api/handlers/backup_handler_sanitize_test.go index 0e772525..ecfb1fec 100644 --- a/backend/internal/api/handlers/backup_handler_sanitize_test.go +++ b/backend/internal/api/handlers/backup_handler_sanitize_test.go @@ -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) diff --git a/backend/internal/api/handlers/backup_handler_test.go b/backend/internal/api/handlers/backup_handler_test.go index a13ba871..5daa4f37 100644 --- a/backend/internal/api/handlers/backup_handler_test.go +++ b/backend/internal/api/handlers/backup_handler_test.go @@ -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 diff --git a/backend/internal/api/handlers/benchmark_test.go b/backend/internal/api/handlers/benchmark_test.go index 9b96c0ed..1bee57d8 100644 --- a/backend/internal/api/handlers/benchmark_test.go +++ b/backend/internal/api/handlers/benchmark_test.go @@ -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 { diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index c9cacc76..08cb6bf7 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -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"}) diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go index 21f93025..8151c588 100644 --- a/backend/internal/api/handlers/certificate_handler_coverage_test.go +++ b/backend/internal/api/handlers/certificate_handler_coverage_test.go @@ -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) diff --git a/backend/internal/api/handlers/certificate_handler_security_test.go b/backend/internal/api/handlers/certificate_handler_security_test.go new file mode 100644 index 00000000..275a5cfa --- /dev/null +++ b/backend/internal/api/handlers/certificate_handler_security_test.go @@ -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 +} diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index 4b5f6e55..2559f5a9 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -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. diff --git a/backend/internal/api/handlers/coverage_quick_test.go b/backend/internal/api/handlers/coverage_quick_test.go index d8d5cc35..9e067aa2 100644 --- a/backend/internal/api/handlers/coverage_quick_test.go +++ b/backend/internal/api/handlers/coverage_quick_test.go @@ -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) diff --git a/backend/internal/api/handlers/crowdsec_decisions_test.go b/backend/internal/api/handlers/crowdsec_decisions_test.go new file mode 100644 index 00000000..26ba34bf --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_decisions_test.go @@ -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") +} diff --git a/backend/internal/api/handlers/crowdsec_exec.go b/backend/internal/api/handlers/crowdsec_exec.go index 5852018d..7214f418 100644 --- a/backend/internal/api/handlers/crowdsec_exec.go +++ b/backend/internal/api/handlers/crowdsec_exec.go @@ -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 } diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 2647bac1..2dd5e629 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -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) } diff --git a/backend/internal/api/handlers/crowdsec_handler_coverage_test.go b/backend/internal/api/handlers/crowdsec_handler_coverage_test.go index 9b3bacf4..e6e4216a 100644 --- a/backend/internal/api/handlers/crowdsec_handler_coverage_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_coverage_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index df43b58d..fdbc617b 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -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") } } diff --git a/backend/internal/api/handlers/crowdsec_presets_handler_test.go b/backend/internal/api/handlers/crowdsec_presets_handler_test.go new file mode 100644 index 00000000..d60d73d7 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_presets_handler_test.go @@ -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") +} diff --git a/backend/internal/api/handlers/doc.go b/backend/internal/api/handlers/doc.go new file mode 100644 index 00000000..29205a6d --- /dev/null +++ b/backend/internal/api/handlers/doc.go @@ -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 diff --git a/backend/internal/api/handlers/docker_handler_test.go b/backend/internal/api/handlers/docker_handler_test.go index bab438db..0ac6c1cd 100644 --- a/backend/internal/api/handlers/docker_handler_test.go +++ b/backend/internal/api/handlers/docker_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/domain_handler_test.go b/backend/internal/api/handlers/domain_handler_test.go index 57a9f519..e4f94f11 100644 --- a/backend/internal/api/handlers/domain_handler_test.go +++ b/backend/internal/api/handlers/domain_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/feature_flags_handler.go b/backend/internal/api/handlers/feature_flags_handler.go index 4f4c9d6a..2afdd6f3 100644 --- a/backend/internal/api/handlers/feature_flags_handler.go +++ b/backend/internal/api/handlers/feature_flags_handler.go @@ -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) diff --git a/backend/internal/api/handlers/feature_flags_handler_coverage_test.go b/backend/internal/api/handlers/feature_flags_handler_coverage_test.go index 63c95c76..5e84f978 100644 --- a/backend/internal/api/handlers/feature_flags_handler_coverage_test.go +++ b/backend/internal/api/handlers/feature_flags_handler_coverage_test.go @@ -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) } diff --git a/backend/internal/api/handlers/feature_flags_handler_test.go b/backend/internal/api/handlers/feature_flags_handler_test.go index 4000a0b6..d994a8de 100644 --- a/backend/internal/api/handlers/feature_flags_handler_test.go +++ b/backend/internal/api/handlers/feature_flags_handler_test.go @@ -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 { diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go index 9a568076..a27132ac 100644 --- a/backend/internal/api/handlers/handlers_test.go +++ b/backend/internal/api/handlers/handlers_test.go @@ -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) } diff --git a/backend/internal/api/handlers/health_handler_test.go b/backend/internal/api/handlers/health_handler_test.go index b890cb59..2ed9e5f0 100644 --- a/backend/internal/api/handlers/health_handler_test.go +++ b/backend/internal/api/handlers/health_handler_test.go @@ -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 +} diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index 4b8e1203..f8495f12 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -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 diff --git a/backend/internal/api/handlers/import_handler_sanitize_test.go b/backend/internal/api/handlers/import_handler_sanitize_test.go index f4a405b2..2140ca0b 100644 --- a/backend/internal/api/handlers/import_handler_sanitize_test.go +++ b/backend/internal/api/handlers/import_handler_sanitize_test.go @@ -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() diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index cac5b128..0ca1c3d6 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -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() diff --git a/backend/internal/api/handlers/logs_handler.go b/backend/internal/api/handlers/logs_handler.go index 66994212..0e39828a 100644 --- a/backend/internal/api/handlers/logs_handler.go +++ b/backend/internal/api/handlers/logs_handler.go @@ -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()) diff --git a/backend/internal/api/handlers/logs_handler_coverage_test.go b/backend/internal/api/handlers/logs_handler_coverage_test.go index 96bf452f..d8c6b91f 100644 --- a/backend/internal/api/handlers/logs_handler_coverage_test.go +++ b/backend/internal/api/handlers/logs_handler_coverage_test.go @@ -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) diff --git a/backend/internal/api/handlers/logs_handler_test.go b/backend/internal/api/handlers/logs_handler_test.go index b88dea78..8ebf8d53 100644 --- a/backend/internal/api/handlers/logs_handler_test.go +++ b/backend/internal/api/handlers/logs_handler_test.go @@ -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 diff --git a/backend/internal/api/handlers/misc_coverage_test.go b/backend/internal/api/handlers/misc_coverage_test.go index a515712b..a9684ba8 100644 --- a/backend/internal/api/handlers/misc_coverage_test.go +++ b/backend/internal/api/handlers/misc_coverage_test.go @@ -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) diff --git a/backend/internal/api/handlers/notification_coverage_test.go b/backend/internal/api/handlers/notification_coverage_test.go index 2c26b5b8..8c6d2e03 100644 --- a/backend/internal/api/handlers/notification_coverage_test.go +++ b/backend/internal/api/handlers/notification_coverage_test.go @@ -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) diff --git a/backend/internal/api/handlers/notification_handler_test.go b/backend/internal/api/handlers/notification_handler_test.go index 3d78996a..0d602d25 100644 --- a/backend/internal/api/handlers/notification_handler_test.go +++ b/backend/internal/api/handlers/notification_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/notification_provider_handler_test.go b/backend/internal/api/handlers/notification_provider_handler_test.go index 8961de0d..30a6bcc8 100644 --- a/backend/internal/api/handlers/notification_provider_handler_test.go +++ b/backend/internal/api/handlers/notification_provider_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/notification_template_handler_test.go b/backend/internal/api/handlers/notification_template_handler_test.go index 13b51e0e..5a0adfd1 100644 --- a/backend/internal/api/handlers/notification_template_handler_test.go +++ b/backend/internal/api/handlers/notification_template_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/perf_assert_test.go b/backend/internal/api/handlers/perf_assert_test.go index 49d5cd9a..678f34e5 100644 --- a/backend/internal/api/handlers/perf_assert_test.go +++ b/backend/internal/api/handlers/perf_assert_test.go @@ -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) } diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 2f70fa49..10c949ef 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -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 diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index a5510d52..dc0ddc97 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/remote_server_handler.go b/backend/internal/api/handlers/remote_server_handler.go index 2518504f..b1831500 100644 --- a/backend/internal/api/handlers/remote_server_handler.go +++ b/backend/internal/api/handlers/remote_server_handler.go @@ -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 diff --git a/backend/internal/api/handlers/remote_server_handler_test.go b/backend/internal/api/handlers/remote_server_handler_test.go index e2250987..5cf501dc 100644 --- a/backend/internal/api/handlers/remote_server_handler_test.go +++ b/backend/internal/api/handlers/remote_server_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/security_handler_additional_test.go b/backend/internal/api/handlers/security_handler_additional_test.go index a9fae5c6..92d195f2 100644 --- a/backend/internal/api/handlers/security_handler_additional_test.go +++ b/backend/internal/api/handlers/security_handler_additional_test.go @@ -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) diff --git a/backend/internal/api/handlers/security_handler_audit_test.go b/backend/internal/api/handlers/security_handler_audit_test.go index cd0896d7..b969cc86 100644 --- a/backend/internal/api/handlers/security_handler_audit_test.go +++ b/backend/internal/api/handlers/security_handler_audit_test.go @@ -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) diff --git a/backend/internal/api/handlers/security_handler_clean_test.go b/backend/internal/api/handlers/security_handler_clean_test.go index 760bb800..e494884a 100644 --- a/backend/internal/api/handlers/security_handler_clean_test.go +++ b/backend/internal/api/handlers/security_handler_clean_test.go @@ -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) diff --git a/backend/internal/api/handlers/security_handler_coverage_test.go b/backend/internal/api/handlers/security_handler_coverage_test.go index 613c07be..7959599a 100644 --- a/backend/internal/api/handlers/security_handler_coverage_test.go +++ b/backend/internal/api/handlers/security_handler_coverage_test.go @@ -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 diff --git a/backend/internal/api/handlers/security_handler_rules_decisions_test.go b/backend/internal/api/handlers/security_handler_rules_decisions_test.go index 3953891c..0c46954d 100644 --- a/backend/internal/api/handlers/security_handler_rules_decisions_test.go +++ b/backend/internal/api/handlers/security_handler_rules_decisions_test.go @@ -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) diff --git a/backend/internal/api/handlers/security_handler_settings_test.go b/backend/internal/api/handlers/security_handler_settings_test.go index 013f5670..e48f7ff2 100644 --- a/backend/internal/api/handlers/security_handler_settings_test.go +++ b/backend/internal/api/handlers/security_handler_settings_test.go @@ -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) diff --git a/backend/internal/api/handlers/security_handler_test_fixed.go b/backend/internal/api/handlers/security_handler_test_fixed.go index aaf1694a..23bf1efb 100644 --- a/backend/internal/api/handlers/security_handler_test_fixed.go +++ b/backend/internal/api/handlers/security_handler_test_fixed.go @@ -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) diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go index ba55faf7..e2bf788d 100644 --- a/backend/internal/api/handlers/settings_handler_test.go +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/system_handler_test.go b/backend/internal/api/handlers/system_handler_test.go index 10647bdb..4c8c6b17 100644 --- a/backend/internal/api/handlers/system_handler_test.go +++ b/backend/internal/api/handlers/system_handler_test.go @@ -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) + } + }) } diff --git a/backend/internal/api/handlers/testdb.go b/backend/internal/api/handlers/testdb.go index 7e932f42..3b5799ac 100644 --- a/backend/internal/api/handlers/testdb.go +++ b/backend/internal/api/handlers/testdb.go @@ -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 { diff --git a/backend/internal/api/handlers/update_handler_test.go b/backend/internal/api/handlers/update_handler_test.go index 1405a231..5c50f730 100644 --- a/backend/internal/api/handlers/update_handler_test.go +++ b/backend/internal/api/handlers/update_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/uptime_handler_test.go b/backend/internal/api/handlers/uptime_handler_test.go index c840e3c3..11bb8c2d 100644 --- a/backend/internal/api/handlers/uptime_handler_test.go +++ b/backend/internal/api/handlers/uptime_handler_test.go @@ -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) diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 52e2a404..0c870feb 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -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() diff --git a/backend/internal/api/middleware/auth_test.go b/backend/internal/api/middleware/auth_test.go index 7dc3edcb..7fb4e077 100644 --- a/backend/internal/api/middleware/auth_test.go +++ b/backend/internal/api/middleware/auth_test.go @@ -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) diff --git a/backend/internal/api/middleware/recovery_test.go b/backend/internal/api/middleware/recovery_test.go index fbe12240..64675fdd 100644 --- a/backend/internal/api/middleware/recovery_test.go +++ b/backend/internal/api/middleware/recovery_test.go @@ -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() diff --git a/backend/internal/api/middleware/request_id_test.go b/backend/internal/api/middleware/request_id_test.go index 69598b13..816c4f09 100644 --- a/backend/internal/api/middleware/request_id_test.go +++ b/backend/internal/api/middleware/request_id_test.go @@ -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) diff --git a/backend/internal/api/middleware/request_logger_test.go b/backend/internal/api/middleware/request_logger_test.go index 8282c81e..8ff8a494 100644 --- a/backend/internal/api/middleware/request_logger_test.go +++ b/backend/internal/api/middleware/request_logger_test.go @@ -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 { diff --git a/backend/internal/api/middleware/sanitize_test.go b/backend/internal/api/middleware/sanitize_test.go new file mode 100644 index 00000000..dc581479 --- /dev/null +++ b/backend/internal/api/middleware/sanitize_test.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeHeaders(t *testing.T) { + t.Run("nil headers", func(t *testing.T) { + require.Nil(t, SanitizeHeaders(nil)) + }) + + t.Run("redacts sensitive headers", func(t *testing.T) { + headers := http.Header{} + headers.Set("Authorization", "secret") + headers.Set("X-Api-Key", "token") + headers.Set("Cookie", "sessionid=abc") + + sanitized := SanitizeHeaders(headers) + + require.Equal(t, []string{""}, sanitized["Authorization"]) + require.Equal(t, []string{""}, sanitized["X-Api-Key"]) + require.Equal(t, []string{""}, sanitized["Cookie"]) + }) + + t.Run("sanitizes and truncates values", func(t *testing.T) { + headers := http.Header{} + headers.Add("X-Trace", "line1\nline2\r\t") + headers.Add("X-Custom", strings.Repeat("a", 210)) + + sanitized := SanitizeHeaders(headers) + + traceValue := sanitized["X-Trace"][0] + require.NotContains(t, traceValue, "\n") + require.NotContains(t, traceValue, "\r") + require.NotContains(t, traceValue, "\t") + + customValue := sanitized["X-Custom"][0] + require.Equal(t, 200, len(customValue)) + require.True(t, strings.HasPrefix(customValue, strings.Repeat("a", 200))) + }) +} + +func TestSanitizePath(t *testing.T) { + paddedPath := "/api/v1/resource/" + strings.Repeat("x", 210) + "?token=secret" + + sanitized := SanitizePath(paddedPath) + + require.NotContains(t, sanitized, "?") + require.False(t, strings.ContainsAny(sanitized, "\n\r\t")) + require.Equal(t, 200, len(sanitized)) +} diff --git a/backend/internal/api/middleware/security_test.go b/backend/internal/api/middleware/security_test.go index d83cf7bf..99d5f6de 100644 --- a/backend/internal/api/middleware/security_test.go +++ b/backend/internal/api/middleware/security_test.go @@ -117,7 +117,7 @@ func TestSecurityHeaders(t *testing.T) { c.String(http.StatusOK, "OK") }) - req := httptest.NewRequest(http.MethodGet, "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) @@ -141,7 +141,7 @@ func TestSecurityHeadersCustomCSP(t *testing.T) { c.String(http.StatusOK, "OK") }) - req := httptest.NewRequest(http.MethodGet, "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 756924d4..eccba76d 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -23,6 +24,9 @@ import ( // Register wires up API routes and performs automatic migrations. func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { + // Enable gzip compression for API responses (reduces payload size ~70%) + router.Use(gzip.Gzip(gzip.DefaultCompression)) + // Apply security headers middleware globally // This sets CSP, HSTS, X-Frame-Options, etc. securityHeadersCfg := middleware.SecurityHeadersConfig{ @@ -54,6 +58,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.SecurityAudit{}, &models.SecurityRuleSet{}, &models.UserPermittedHost{}, // Join table for user permissions + &models.CrowdsecPresetEvent{}, ); err != nil { return fmt.Errorf("auto migrate: %w", err) } @@ -242,15 +247,32 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { go func() { // Wait a bit for server to start time.Sleep(30 * time.Second) - // Initial sync - if err := uptimeService.SyncMonitors(); err != nil { - logger.Log().WithError(err).Error("Failed to sync monitors") + + // Initial sync if enabled + var s models.Setting + enabled := true + if err := db.Where("key = ?", "feature.uptime.enabled").First(&s).Error; err == nil { + enabled = s.Value == "true" + } + + if enabled { + if err := uptimeService.SyncMonitors(); err != nil { + logger.Log().WithError(err).Error("Failed to sync monitors") + } } ticker := time.NewTicker(1 * time.Minute) for range ticker.C { - _ = uptimeService.SyncMonitors() - uptimeService.CheckAll() + // Check feature flag each tick + enabled := true + if err := db.Where("key = ?", "feature.uptime.enabled").First(&s).Error; err == nil { + enabled = s.Value == "true" + } + + if enabled { + _ = uptimeService.SyncMonitors() + uptimeService.CheckAll() + } } }() @@ -284,6 +306,27 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { crowdsecExec := handlers.NewDefaultCrowdsecExecutor() crowdsecHandler := handlers.NewCrowdsecHandler(db, crowdsecExec, "crowdsec", crowdsecDataDir) crowdsecHandler.RegisterRoutes(protected) + + // Access Lists + accessListHandler := handlers.NewAccessListHandler(db) + protected.GET("/access-lists/templates", accessListHandler.GetTemplates) + protected.GET("/access-lists", accessListHandler.List) + protected.POST("/access-lists", accessListHandler.Create) + protected.GET("/access-lists/:id", accessListHandler.Get) + protected.PUT("/access-lists/:id", accessListHandler.Update) + protected.DELETE("/access-lists/:id", accessListHandler.Delete) + protected.POST("/access-lists/:id/test", accessListHandler.TestIP) + + // Certificate routes + // Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage + // where ACME and certificates are stored (e.g. /data). + caddyDataDir := cfg.CaddyConfigDir + "/data" + logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan") + certService := services.NewCertificateService(caddyDataDir, db) + certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService) + protected.GET("/certificates", certHandler.List) + protected.POST("/certificates", certHandler.Upload) + protected.DELETE("/certificates/:id", certHandler.Delete) } // Caddy Manager already created above @@ -294,27 +337,6 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService) remoteServerHandler.RegisterRoutes(api) - // Access Lists - accessListHandler := handlers.NewAccessListHandler(db) - protected.GET("/access-lists/templates", accessListHandler.GetTemplates) - protected.GET("/access-lists", accessListHandler.List) - protected.POST("/access-lists", accessListHandler.Create) - protected.GET("/access-lists/:id", accessListHandler.Get) - protected.PUT("/access-lists/:id", accessListHandler.Update) - protected.DELETE("/access-lists/:id", accessListHandler.Delete) - protected.POST("/access-lists/:id/test", accessListHandler.TestIP) - - // Certificate routes - // Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage - // where ACME and certificates are stored (e.g. /data). - caddyDataDir := cfg.CaddyConfigDir + "/data" - logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan") - certService := services.NewCertificateService(caddyDataDir, db) - certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService) - api.GET("/certificates", certHandler.List) - api.POST("/certificates", certHandler.Upload) - api.DELETE("/certificates/:id", certHandler.Delete) - // Initial Caddy Config Sync go func() { // Wait for Caddy to be ready (max 30 seconds) diff --git a/backend/internal/api/tests/integration_test.go b/backend/internal/api/tests/integration_test.go index 71574633..5bb0224f 100644 --- a/backend/internal/api/tests/integration_test.go +++ b/backend/internal/api/tests/integration_test.go @@ -38,7 +38,7 @@ func TestIntegration_WAF_BlockAndMonitor(t *testing.T) { // Block mode should reject suspicious payload on an API route covered by middleware rBlock, _ := newServer("block") - req := httptest.NewRequest(http.MethodGet, "/api/v1/remote-servers?test=