diff --git a/.github/agents/Management.agent.md b/.github/agents/Management.agent.md index d01d687d6..fc38d8adf 100644 --- a/.github/agents/Management.agent.md +++ b/.github/agents/Management.agent.md @@ -43,7 +43,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can - **Identify Goal**: Understand the user's request. - **STOP**: Do not look at the code. Do not run `list_dir`. 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. Include a Commit Slicing Strategy section that decides whether to split work into multiple PRs and, when split, defines PR-1/PR-2/PR-3 scope, dependencies, and acceptance criteria. Review and suggest updaetes to `.gitignore`, `codecov.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete." + - *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. Include a Commit Slicing Strategy section that organizes work into logical commits within a single PR — one feature = one PR, with ordered commits (Commit 1, Commit 2, …) each defining scope, files, dependencies, and validation gates. Review and suggest updaetes to `.gitignore`, `codecov.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. @@ -59,15 +59,13 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can - **Ask**: "Plan created. Shall I authorize the construction?" 4. **Phase 4: Execution (Waterfall)**: - - **Single-PR or Multi-PR Decision**: Read the Commit Slicing Strategy in `docs/plans/current_spec.md`. - - **If single PR**: + - **Read Commit Slicing Strategy**: Read the Commit Slicing Strategy in `docs/plans/current_spec.md` to understand the ordered commits. + - **Single PR, Multiple Commits**: All work ships as one PR. Each commit maps to a phase in the plan. - **Backend**: Call `Backend_Dev` with the plan file. - **Frontend**: Call `Frontend_Dev` with the plan file. - - **If multi-PR**: - - Execute in PR slices, one slice at a time, in dependency order. - - Require each slice to pass review + QA gates before starting the next slice. - - Keep every slice deployable and independently testable. - - **MANDATORY**: Implementation agents must perform linting and type checks locally before declaring their slice "DONE". This is a critical step that must not be skipped to avoid broken commits and security issues. + - Execute commits in dependency order. Each commit must pass its validation gates before the next commit begins. + - The PR is merged only when all commits are complete and all DoD gates pass. + - **MANDATORY**: Implementation agents must perform linting and type checks locally before declaring their commit "DONE". This is a critical step that must not be skipped to avoid broken commits and security issues. 5. **Phase 5: Review**: - **Supervisor**: Call `Supervisor` to review the implementation against the plan. Provide feedback and ensure alignment with best practices. @@ -80,7 +78,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can - **Docs**: Call `Docs_Writer`. - **Manual Testing**: create a new test plan in `docs/issues/*.md` for tracking manual testing focused on finding potential bugs of the implemented features. - **Final Report**: Summarize the successful subagent runs. - - **PR Roadmap**: If split mode was used, include a concise roadmap of completed and remaining PR slices. + - **Commit Roadmap**: Include a concise summary of completed and remaining commits within the PR. **Mandatory Commit Message**: When you reach a stopping point, provide a copy and paste code block commit message at the END of the response on format laid out in `.github/instructions/commit-message.instructions.md` - **STRICT RULES**: diff --git a/.github/agents/Planning.agent.md b/.github/agents/Planning.agent.md index 561e9fdfb..5e7d8ae9a 100644 --- a/.github/agents/Planning.agent.md +++ b/.github/agents/Planning.agent.md @@ -38,7 +38,7 @@ You are a PRINCIPAL ARCHITECT responsible for technical planning and system desi - Specify database schema changes - Document component interactions and data flow - Identify potential risks and mitigation strategies - - Determine PR sizing and whether to split the work into multiple PRs for safer and faster review + - Determine commit sizing and how to organize work into logical commits within a single PR for safer and faster review 3. **Documentation**: - Write plan to `docs/plans/current_spec.md` @@ -46,10 +46,10 @@ You are a PRINCIPAL ARCHITECT responsible for technical planning and system desi - Break down into implementable tasks using examples, diagrams, and tables - Estimate complexity for each component - Add a **Commit Slicing Strategy** section with: - - Decision: single PR or multiple PRs + - Decision: single PR with ordered logical commits (one feature = one PR) - Trigger reasons (scope, risk, cross-domain changes, review size) - - Ordered PR slices (`PR-1`, `PR-2`, ...), each with scope, files, dependencies, and validation gates - - Rollback and contingency notes per slice + - Ordered commits (`Commit 1`, `Commit 2`, ...), each with scope, files, dependencies, and validation gates + - Rollback and contingency notes for the PR as a whole 4. **Handoff**: - Once plan is approved, delegate to `Supervisor` agent for review. diff --git a/.github/instructions/subagent.instructions.md b/.github/instructions/subagent.instructions.md index b2c0f2370..ef0af637b 100644 --- a/.github/instructions/subagent.instructions.md +++ b/.github/instructions/subagent.instructions.md @@ -23,21 +23,21 @@ runSubagent({ - Validate: `plan_file` exists and contains a `Handoff Contract` JSON. - Kickoff: call `Planning` to create the plan if not present. -- Decide: check if work should be split into multiple PRs (size, risk, cross-domain impact). +- Decide: check how to organize work into logical commits within a single PR (size, risk, cross-domain impact). - 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. 2.1) Multi-Commit Slicing Protocol -- If a task is large or high-risk, split into PR slices and execute in order. -- Each slice must have: +- All work for a single feature ships as one PR with ordered logical commits. +- Each commit must have: - Scope boundary (what is included/excluded) - - Dependency on previous slices - - Validation gates (tests/scans required for that slice) - - Explicit rollback notes -- Do not start the next slice until the current slice is complete and verified. -- Keep each slice independently reviewable and deployable. + - Dependency on previous commits + - Validation gates (tests/scans required for that commit) + - Explicit rollback notes for the PR as a whole +- Do not start the next commit until the current commit is complete and verified. +- Keep each commit independently reviewable within the PR. 3) Return Contract that all subagents must return @@ -55,7 +55,7 @@ runSubagent({ - 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`. -- For multi-PR execution, mark failed slice as blocked and stop downstream slices until resolved. +- For multi-commit execution, mark failed commit as blocked and stop downstream commits until resolved. 5) Example: Run a full Feature Implementation diff --git a/.github/skills/examples/gorm-scanner-ci-workflow.yml b/.github/skills/examples/gorm-scanner-ci-workflow.yml index a44bc49b3..3144c1cd4 100644 --- a/.github/skills/examples/gorm-scanner-ci-workflow.yml +++ b/.github/skills/examples/gorm-scanner-ci-workflow.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: "1.26.2" @@ -56,7 +56,7 @@ jobs: - name: Comment on PR if: always() && github.event_name == 'pull_request' - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const critical = ${{ steps.parse-report.outputs.critical }}; @@ -89,7 +89,7 @@ jobs: - name: Upload GORM Scan Report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: gorm-security-report-${{ github.run_id }} path: docs/reports/gorm-scan-ci-*.txt diff --git a/.github/workflows/auto-versioning.yml b/.github/workflows/auto-versioning.yml index 42786152a..b1a394a60 100644 --- a/.github/workflows/auto-versioning.yml +++ b/.github/workflows/auto-versioning.yml @@ -89,7 +89,7 @@ jobs: - name: Create GitHub Release (creates tag via API) if: ${{ steps.semver.outputs.changed == 'true' && steps.check_release.outputs.exists == 'false' }} - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: tag_name: ${{ steps.determine_tag.outputs.tag }} name: Release ${{ steps.determine_tag.outputs.tag }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fd7d46dfc..57e446519 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: run: bash scripts/ci/check-codeql-parity.sh - name: Initialize CodeQL - uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -92,10 +92,10 @@ jobs: run: mkdir -p sarif-results - name: Autobuild - uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 with: category: "/language:${{ matrix.language }}" output: sarif-results/${{ matrix.language }} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 61868627a..106830979 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -568,7 +568,7 @@ jobs: - name: Upload Trivy results if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: 'trivy-results.sarif' category: '.github/workflows/docker-build.yml:build-and-push' @@ -727,14 +727,14 @@ jobs: - name: Upload Trivy scan results if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: 'trivy-pr-results.sarif' category: 'docker-pr-image' - name: Upload Trivy compatibility results (docker-build category) if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: 'trivy-pr-results.sarif' category: '.github/workflows/docker-build.yml:build-and-push' @@ -742,7 +742,7 @@ jobs: - name: Upload Trivy compatibility results (docker-publish alias) if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: 'trivy-pr-results.sarif' category: '.github/workflows/docker-publish.yml:build-and-push' @@ -750,7 +750,7 @@ jobs: - name: Upload Trivy compatibility results (nightly alias) if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: 'trivy-pr-results.sarif' category: 'trivy-nightly' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 82a2dc900..15a35f24d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -352,7 +352,7 @@ jobs: # Step 4: Upload the built site - name: 📤 Upload artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: '_site' diff --git a/.github/workflows/e2e-tests-split.yml b/.github/workflows/e2e-tests-split.yml index c4db168c1..ed81d8c60 100644 --- a/.github/workflows/e2e-tests-split.yml +++ b/.github/workflows/e2e-tests-split.yml @@ -158,7 +158,7 @@ jobs: - name: Cache npm dependencies if: steps.resolve-image.outputs.image_source == 'build' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.npm key: npm-${{ hashFiles('package-lock.json') }} diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index f9151350b..b4dd3a1f3 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -468,7 +468,7 @@ jobs: trivyignores: '.trivyignore' - name: Upload Trivy results - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: 'trivy-nightly.sarif' category: 'trivy-nightly' diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 1797f7514..e8b2e9d26 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -33,7 +33,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Run Renovate - uses: renovatebot/github-action@b67590ea780158ccd13192c22a3655a5231f869d # v46.1.8 + uses: renovatebot/github-action@eb932558ad942cccfd8211cf535f17ff183a9f74 # v46.1.9 with: configurationFile: .github/renovate.json token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security-weekly-rebuild.yml b/.github/workflows/security-weekly-rebuild.yml index 54cbe7397..45d4cd95d 100644 --- a/.github/workflows/security-weekly-rebuild.yml +++ b/.github/workflows/security-weekly-rebuild.yml @@ -116,7 +116,7 @@ jobs: version: 'v0.69.3' - name: Upload Trivy results to GitHub Security - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: 'trivy-weekly-results.sarif' diff --git a/.github/workflows/supply-chain-pr.yml b/.github/workflows/supply-chain-pr.yml index d9d2382fd..cc7c12675 100644 --- a/.github/workflows/supply-chain-pr.yml +++ b/.github/workflows/supply-chain-pr.yml @@ -362,7 +362,7 @@ jobs: - name: Upload SARIF to GitHub Security if: steps.check-artifact.outputs.artifact_found == 'true' - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 continue-on-error: true with: sarif_file: grype-results.sarif diff --git a/.gitignore b/.gitignore index f9747c9da..7b2f0a3b0 100644 --- a/.gitignore +++ b/.gitignore @@ -314,3 +314,5 @@ validation-evidence/** .github/agents/# Tools Configuration.md docs/reports/codecove_patch_report.md vuln-results.json +test_output.txt +coverage_results.txt diff --git a/Dockerfile b/Dockerfile index a950b76b4..efc4040fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,9 +43,9 @@ ARG CADDY_CANDIDATE_VERSION=2.11.2 ARG CADDY_USE_CANDIDATE=0 ARG CADDY_PATCH_SCENARIO=B # renovate: datasource=go depName=github.com/greenpau/caddy-security -ARG CADDY_SECURITY_VERSION=1.1.61 +ARG CADDY_SECURITY_VERSION=1.1.62 # renovate: datasource=go depName=github.com/corazawaf/coraza-caddy -ARG CORAZA_CADDY_VERSION=2.4.0 +ARG CORAZA_CADDY_VERSION=2.5.0 ## When an official caddy image tag isn't available on the host, use a ## plain Alpine base image and overwrite its caddy binary with our ## xcaddy-built binary in the later COPY step. This avoids relying on @@ -131,7 +131,7 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"] ARG TARGETPLATFORM ARG TARGETARCH # hadolint ignore=DL3018 -RUN apk add --no-cache clang lld +RUN apk add --no-cache git clang lld # hadolint ignore=DL3059 # hadolint ignore=DL3018 # Install musl (headers + runtime) and gcc for cross-compilation linker @@ -345,7 +345,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ rm -rf /tmp/buildenv_* /tmp/caddy-initial' # ---- CrowdSec Builder ---- -# Build CrowdSec from source to ensure we use Go 1.26.1+ and avoid stdlib vulnerabilities +# Build CrowdSec from source to ensure we use Go 1.26.2+ and avoid stdlib vulnerabilities # (CVE-2025-58183, CVE-2025-58186, CVE-2025-58187, CVE-2025-61729) FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS crowdsec-builder COPY --from=xx / / @@ -469,7 +469,7 @@ WORKDIR /app RUN apk add --no-cache \ bash ca-certificates sqlite-libs sqlite tzdata gettext libcap libcap-utils \ c-ares busybox-extras \ - && apk upgrade --no-cache zlib + && apk upgrade --no-cache zlib libcrypto3 libssl3 musl musl-utils # Copy gosu binary from gosu-builder (built with Go 1.26+ to avoid stdlib CVEs) COPY --from=gosu-builder /gosu-out/gosu /usr/sbin/gosu @@ -516,7 +516,7 @@ COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy # Allow non-root to bind privileged ports (80/443) securely RUN setcap 'cap_net_bind_service=+ep' /usr/bin/caddy -# Copy CrowdSec binaries from the crowdsec-builder stage (built with Go 1.26.1+) +# Copy CrowdSec binaries from the crowdsec-builder stage (built with Go 1.26.2+) # This ensures we don't have stdlib vulnerabilities from older Go versions COPY --from=crowdsec-builder /crowdsec-out/crowdsec /usr/local/bin/crowdsec COPY --from=crowdsec-builder /crowdsec-out/cscli /usr/local/bin/cscli diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 5bc854090..f48467fd2 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -255,7 +255,11 @@ func main() { cerb := cerberus.New(cfg.Security, db) // Pass config to routes for auth service and certificate service - if err := routes.RegisterWithDeps(router, db, cfg, caddyManager, cerb); err != nil { + // Lifecycle context cancelled on shutdown to stop background goroutines + appCtx, appCancel := context.WithCancel(context.Background()) + defer appCancel() + + if err := routes.RegisterWithDeps(appCtx, router, db, cfg, caddyManager, cerb); err != nil { log.Fatalf("register routes: %v", err) } @@ -291,6 +295,9 @@ func main() { sig := <-quit logger.Log().Infof("Received signal %v, initiating graceful shutdown...", sig) + // Cancel the app-wide context to stop background goroutines (e.g. cert expiry checker) + appCancel() + // Graceful shutdown with timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/backend/go.mod b/backend/go.mod index 5c7ff0ef4..b2a8e1677 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,6 +23,7 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 + software.sslmate.com/src/go-pkcs12 v0.7.1 ) require ( @@ -82,7 +83,7 @@ require ( github.com/stretchr/objx v0.5.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 6aba58d7b..6f23218c1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -173,8 +173,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro= +go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= @@ -269,3 +269,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= +software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index 658bd6a97..dffbcb529 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -2,14 +2,18 @@ package handlers import ( "fmt" + "io" "net/http" "strconv" "sync" "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" "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" ) @@ -28,9 +32,10 @@ type CertificateHandler struct { service *services.CertificateService backupService BackupServiceInterface notificationService *services.NotificationService + db *gorm.DB // Rate limiting for notifications notificationMu sync.Mutex - lastNotificationTime map[uint]time.Time + lastNotificationTime map[string]time.Time } func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler { @@ -38,10 +43,18 @@ func NewCertificateHandler(service *services.CertificateService, backupService B service: service, backupService: backupService, notificationService: ns, - lastNotificationTime: make(map[uint]time.Time), + lastNotificationTime: make(map[string]time.Time), } } +// SetDB sets the database connection for user lookups (export re-auth). +func (h *CertificateHandler) SetDB(db *gorm.DB) { + h.db = db +} + +// maxFileSize is 1MB for certificate file uploads. +const maxFileSize = 1 << 20 + func (h *CertificateHandler) List(c *gin.Context) { certs, err := h.service.ListCertificates() if err != nil { @@ -53,34 +66,41 @@ func (h *CertificateHandler) List(c *gin.Context) { c.JSON(http.StatusOK, certs) } -type UploadCertificateRequest struct { - Name string `form:"name" binding:"required"` - Certificate string `form:"certificate"` // PEM content - PrivateKey string `form:"private_key"` // PEM content +func (h *CertificateHandler) Get(c *gin.Context) { + certUUID := c.Param("uuid") + if certUUID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"}) + return + } + + detail, err := h.service.GetCertificate(certUUID) + if err != nil { + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).Error("failed to get certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get certificate"}) + return + } + + c.JSON(http.StatusOK, detail) } func (h *CertificateHandler) Upload(c *gin.Context) { - // Handle multipart form name := c.PostForm("name") if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } - // Read files + // Read certificate file certFile, err := c.FormFile("certificate_file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"}) return } - keyFile, err := c.FormFile("key_file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"}) - return - } - - // Open and read content certSrc, err := certFile.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"}) @@ -92,35 +112,75 @@ func (h *CertificateHandler) Upload(c *gin.Context) { } }() - keySrc, err := keyFile.Open() + certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize)) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"}) return } - defer func() { - if errClose := keySrc.Close(); errClose != nil { - logger.Log().WithError(errClose).Warn("failed to close key file") + certPEM := string(certBytes) + + // Read private key file (optional — format detection is content-based in the service) + var keyPEM string + keyFile, err := c.FormFile("key_file") + if err == nil { + keySrc, errOpen := keyFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"}) + return } - }() + defer func() { + if errClose := keySrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close key file") + } + }() + + keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"}) + return + } + keyPEM = string(keyBytes) + } - // Read to string - // Limit size to avoid DoS (e.g. 1MB) - certBytes := make([]byte, 1024*1024) - n, _ := certSrc.Read(certBytes) - certPEM := string(certBytes[:n]) + // Read chain file (optional) + var chainPEM string + chainFile, err := c.FormFile("chain_file") + if err == nil { + chainSrc, errOpen := chainFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"}) + return + } + defer func() { + if errClose := chainSrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close chain file") + } + }() + + chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"}) + return + } + chainPEM = string(chainBytes) + } - keyBytes := make([]byte, 1024*1024) - n, _ = keySrc.Read(keyBytes) - keyPEM := string(keyBytes[:n]) + // Require key_file for non-PFX formats (PFX embeds the private key) + if keyPEM == "" { + format := services.DetectFormat(certBytes) + if format != services.FormatPFX { + c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required for PEM/DER certificate uploads"}) + return + } + } - cert, err := h.service.UploadCertificate(name, certPEM, keyPEM) + cert, err := h.service.UploadCertificate(name, certPEM, keyPEM, chainPEM) if err != nil { logger.Log().WithError(err).Error("failed to upload certificate") - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload certificate"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Send Notification if h.notificationService != nil { h.notificationService.SendExternal(c.Request.Context(), "cert", @@ -137,24 +197,255 @@ func (h *CertificateHandler) Upload(c *gin.Context) { c.JSON(http.StatusCreated, cert) } -func (h *CertificateHandler) Delete(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.ParseUint(idStr, 10, 32) +type updateCertificateRequest struct { + Name string `json:"name" binding:"required"` +} + +func (h *CertificateHandler) Update(c *gin.Context) { + certUUID := c.Param("uuid") + if certUUID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"}) + return + } + + var req updateCertificateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + info, err := h.service.UpdateCertificate(certUUID, req.Name) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).Error("failed to update certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update certificate"}) + return + } + + c.JSON(http.StatusOK, info) +} + +func (h *CertificateHandler) Validate(c *gin.Context) { + // Read certificate file + certFile, err := c.FormFile("certificate_file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"}) + return + } + + certSrc, err := certFile.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"}) return } + defer func() { + if errClose := certSrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close certificate file") + } + }() + + certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"}) + return + } + + // Read optional key file + var keyPEM string + keyFile, err := c.FormFile("key_file") + if err == nil { + keySrc, errOpen := keyFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"}) + return + } + defer func() { + if errClose := keySrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close key file") + } + }() + + keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"}) + return + } + keyPEM = string(keyBytes) + } + + // Read optional chain file + var chainPEM string + chainFile, err := c.FormFile("chain_file") + if err == nil { + chainSrc, errOpen := chainFile.Open() + if errOpen != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"}) + return + } + defer func() { + if errClose := chainSrc.Close(); errClose != nil { + logger.Log().WithError(errClose).Warn("failed to close chain file") + } + }() - // Validate ID range - if id == 0 { + chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize)) + if errRead != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"}) + return + } + chainPEM = string(chainBytes) + } + + result, err := h.service.ValidateCertificate(string(certBytes), keyPEM, chainPEM) + if err != nil { + logger.Log().WithError(err).Error("failed to validate certificate") + c.JSON(http.StatusBadRequest, gin.H{ + "error": "validation failed", + "errors": []string{err.Error()}, + }) + return + } + + c.JSON(http.StatusOK, result) +} + +type exportCertificateRequest struct { + Format string `json:"format" binding:"required"` + IncludeKey bool `json:"include_key"` + PFXPassword string `json:"pfx_password"` + Password string `json:"password"` +} + +func (h *CertificateHandler) Export(c *gin.Context) { + certUUID := c.Param("uuid") + if certUUID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"}) + return + } + + var req exportCertificateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "format is required"}) + return + } + + // Re-authenticate when requesting private key + if req.IncludeKey { + if req.Password == "" { + c.JSON(http.StatusForbidden, gin.H{"error": "password required to export private key"}) + return + } + + userVal, exists := c.Get("user") + if !exists || h.db == nil { + c.JSON(http.StatusForbidden, gin.H{"error": "authentication required"}) + return + } + + userMap, ok := userVal.(map[string]any) + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"}) + return + } + + userID, ok := userMap["id"] + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"}) + return + } + + var user models.User + if err := h.db.First(&user, userID).Error; err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "user not found"}) + return + } + + if !user.CheckPassword(req.Password) { + c.JSON(http.StatusForbidden, gin.H{"error": "incorrect password"}) + return + } + } + + data, filename, err := h.service.ExportCertificate(certUUID, req.Format, req.IncludeKey, req.PFXPassword) + if err != nil { + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(fmt.Errorf("%s", util.SanitizeForLog(err.Error()))).Error("failed to export certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export certificate"}) + return + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + c.Data(http.StatusOK, "application/octet-stream", data) +} + +func (h *CertificateHandler) Delete(c *gin.Context) { + idStr := c.Param("uuid") + + // Support both numeric ID (legacy) and UUID + if numID, err := strconv.ParseUint(idStr, 10, 32); err == nil && numID > 0 { + inUse, err := h.service.IsCertificateInUse(uint(numID)) + if err != nil { + logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to check certificate usage") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"}) + return + } + if inUse { + c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"}) + return + } + + if h.backupService != nil { + 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 + } + } + + if err := h.service.DeleteCertificateByID(uint(numID)); err != nil { + if err == services.ErrCertInUse { + c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"}) + return + } + logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to delete certificate") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"}) + return + } + + h.sendDeleteNotification(c, fmt.Sprintf("%d", numID)) + c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"}) + return + } + + // UUID path - parse to validate format and produce a canonical, safe string + parsedUUID, parseErr := uuid.Parse(idStr) + if parseErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } + certUUID := parsedUUID.String() - // Check if certificate is in use before proceeding - inUse, err := h.service.IsCertificateInUse(uint(id)) + inUse, err := h.service.IsCertificateInUseByUUID(certUUID) if err != nil { - logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to check certificate usage") + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).WithField("certificate_uuid", util.SanitizeForLog(certUUID)).Error("failed to check certificate usage") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"}) return } @@ -163,13 +454,10 @@ func (h *CertificateHandler) Delete(c *gin.Context) { return } - // 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 } @@ -181,38 +469,62 @@ func (h *CertificateHandler) Delete(c *gin.Context) { } } - // Proceed with deletion - if err := h.service.DeleteCertificate(uint(id)); err != nil { + if err := h.service.DeleteCertificate(certUUID); err != nil { if err == services.ErrCertInUse { c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"}) return } - logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to delete certificate") + if err == services.ErrCertNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + logger.Log().WithError(err).WithField("certificate_uuid", util.SanitizeForLog(certUUID)).Error("failed to delete certificate") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"}) return } - // Send Notification with rate limiting (1 per cert per 10 seconds) - if h.notificationService != nil { - 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]any{ - "ID": id, - "Action": "deleted", - }, - ) - } else { - h.notificationMu.Unlock() - logger.Log().WithField("certificate_id", id).Debug("notification rate limited") - } + h.sendDeleteNotification(c, certUUID) + c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"}) +} + +func (h *CertificateHandler) sendDeleteNotification(c *gin.Context, certRef string) { + if h.notificationService == nil { + return } - c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"}) + // Re-validate to produce a CodeQL-safe value (breaks taint from user input). + // Callers already pass validated data; this is defense-in-depth. + safeRef := sanitizeCertRef(certRef) + + h.notificationMu.Lock() + lastTime, exists := h.lastNotificationTime[certRef] + if exists && time.Since(lastTime) < 10*time.Second { + h.notificationMu.Unlock() + logger.Log().WithField("certificate_ref", safeRef).Debug("notification rate limited") + return + } + h.lastNotificationTime[certRef] = time.Now() + h.notificationMu.Unlock() + + h.notificationService.SendExternal(c.Request.Context(), + "cert", + "Certificate Deleted", + fmt.Sprintf("Certificate %s deleted", safeRef), + map[string]any{ + "Ref": safeRef, + "Action": "deleted", + }, + ) +} + +// sanitizeCertRef re-validates a certificate reference (UUID or numeric ID) +// and returns a safe string representation. Returns a placeholder if invalid. +func sanitizeCertRef(ref string) string { + if parsed, err := uuid.Parse(ref); err == nil { + return parsed.String() + } + if n, err := strconv.ParseUint(ref, 10, 64); err == nil { + return strconv.FormatUint(n, 10) + } + return "[invalid-ref]" } diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go index acf70e3dd..7baea67da 100644 --- a/backend/internal/api/handlers/certificate_handler_coverage_test.go +++ b/backend/internal/api/handlers/certificate_handler_coverage_test.go @@ -1,12 +1,18 @@ package handlers import ( + "bytes" + "encoding/json" + "mime/multipart" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" @@ -18,7 +24,7 @@ func TestCertificateHandler_List_DBError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -34,9 +40,9 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/invalid", http.NoBody) w := httptest.NewRecorder() @@ -50,9 +56,9 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/9999", http.NoBody) w := httptest.NewRecorder() @@ -70,11 +76,11 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // No backup service h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -95,9 +101,9 @@ func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -115,7 +121,7 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -135,9 +141,9 @@ func TestCertificateHandler_Delete_ZeroID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/0", http.NoBody) w := httptest.NewRecorder() @@ -169,7 +175,7 @@ func TestCertificateHandler_DBSetupOrdering(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -179,3 +185,395 @@ func TestCertificateHandler_DBSetupOrdering(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } + +// --- Get handler tests --- + +func TestCertificateHandler_Get_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + expiry := time.Now().Add(30 * 24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "get-uuid-1", Name: "Get Test", Provider: "custom", Domains: "get.example.com", ExpiresAt: &expiry}) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.GET("/api/certificates/:uuid", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/get-uuid-1", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "get-uuid-1") + assert.Contains(t, w.Body.String(), "Get Test") +} + +func TestCertificateHandler_Get_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.GET("/api/certificates/:uuid", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/nonexistent-uuid", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Get_EmptyUUID(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + // Route with empty uuid param won't match, test the handler directly with blank uuid + r.GET("/api/certificates/", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Empty uuid should return 400 or 404 depending on router handling + assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound) +} + +// --- SetDB test --- + +func TestCertificateHandler_SetDB(t *testing.T) { + db := OpenTestDBWithMigrations(t) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + assert.Nil(t, h.db) + + h.SetDB(db) + assert.NotNil(t, h.db) +} + +// --- Update handler tests --- + +func TestCertificateHandler_Update_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + expiry := time.Now().Add(30 * 24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "upd-uuid-1", Name: "Old Name", Provider: "custom", Domains: "update.example.com", ExpiresAt: &expiry}) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/upd-uuid-1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "New Name") +} + +func TestCertificateHandler_Update_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/nonexistent-uuid", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Update_BadJSON(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + req := httptest.NewRequest(http.MethodPut, "/api/certificates/some-uuid", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Update_MissingName(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/some-uuid", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// --- Validate handler tests --- + +func TestCertificateHandler_Validate_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte(certPEM)) + part2, _ := writer.CreateFormFile("key_file", "key.pem") + _, _ = part2.Write([]byte(keyPEM)) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "valid") +} + +func TestCertificateHandler_Validate_NoCertFile(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", strings.NewReader("")) + req.Header.Set("Content-Type", "multipart/form-data") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Validate_CertOnly(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte(certPEM)) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +// --- Export handler tests --- + +func TestCertificateHandler_Export_EmptyUUID(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + // Use a route that provides :uuid param as empty would not match normal routing + req := httptest.NewRequest(http.MethodPost, "/api/certificates//export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Router won't match empty uuid, so 404 or redirect + assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusMovedPermanently || w.Code == http.StatusBadRequest) +} + +func TestCertificateHandler_Export_BadJSON(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/some-uuid/export", strings.NewReader("{bad")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Export_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/nonexistent-uuid/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Export_PEMSuccess(t *testing.T) { + db := OpenTestDBWithMigrations(t) + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + cert := models.SSLCertificate{UUID: "export-uuid-1", Name: "Export Test", Provider: "custom", Domains: "export.example.com", Certificate: certPEM} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-1/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Disposition"), "Export Test.pem") +} + +func TestCertificateHandler_Export_IncludeKeyNoPassword(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "export-uuid-2", Name: "Key Test", Provider: "custom", Domains: "key.example.com"} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem", "include_key": true}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-2/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "password required") +} + +func TestCertificateHandler_Export_IncludeKeyNoDBSet(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "export-uuid-3", Name: "No DB Test", Provider: "custom", Domains: "nodb.example.com"} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + // h.db is nil - not set via SetDB + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem", "include_key": true, "password": "test123"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-3/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "authentication required") +} + +// --- Delete via UUID path tests --- + +func TestCertificateHandler_Delete_UUIDPath_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.DELETE("/api/certificates/:uuid", h.Delete) + + // Valid UUID format but does not exist + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/00000000-0000-0000-0000-000000000001", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Delete_UUIDPath_InUse(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "11111111-1111-1111-1111-111111111111", Name: "InUse UUID", Provider: "custom", Domains: "uuid-inuse.example.com"} + db.Create(&cert) + + ph := models.ProxyHost{UUID: "ph-uuid-del", Name: "Proxy", DomainNames: "uuid-inuse.example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID} + db.Create(&ph) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/11111111-1111-1111-1111-111111111111", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +// --- sanitizeCertRef tests --- + +func TestSanitizeCertRef(t *testing.T) { + assert.Equal(t, "00000000-0000-0000-0000-000000000001", sanitizeCertRef("00000000-0000-0000-0000-000000000001")) + assert.Equal(t, "123", sanitizeCertRef("123")) + assert.Equal(t, "[invalid-ref]", sanitizeCertRef("not-valid")) + assert.Equal(t, "0", sanitizeCertRef("0")) +} diff --git a/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go b/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go new file mode 100644 index 000000000..44e0c4677 --- /dev/null +++ b/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go @@ -0,0 +1,707 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" +) + +// --- Delete UUID path with backup service --- + +func TestDelete_UUID_WithBackup_Success(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + db.Create(&models.SSLCertificate{UUID: certUUID, Name: "backup-uuid", Provider: "custom", Domains: "backup.test"}) + + svc := services.NewCertificateService(tmpDir, db, nil) + mock := &mockBackupService{ + createFunc: func() (string, error) { return "/tmp/backup.tar.gz", nil }, + availableSpaceFunc: func() (int64, error) { return 1024 * 1024 * 1024, nil }, + } + h := NewCertificateHandler(svc, mock, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDelete_UUID_NotFound(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + nonExistentUUID := uuid.New().String() + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+nonExistentUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestDelete_UUID_InUse(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + cert := models.SSLCertificate{UUID: certUUID, Name: "inuse-uuid", Provider: "custom", Domains: "inuse.test"} + db.Create(&cert) + db.Create(&models.ProxyHost{UUID: "ph-uuid-inuse", Name: "ph", DomainNames: "inuse.test", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID}) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestDelete_UUID_BackupLowSpace(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + db.Create(&models.SSLCertificate{UUID: certUUID, Name: "low-space", Provider: "custom", Domains: "lowspace.test"}) + + svc := services.NewCertificateService(tmpDir, db, nil) + mock := &mockBackupService{ + availableSpaceFunc: func() (int64, error) { return 1024, nil }, // 1KB - too low + } + h := NewCertificateHandler(svc, mock, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInsufficientStorage, w.Code) +} + +func TestDelete_UUID_BackupSpaceCheckError(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + db.Create(&models.SSLCertificate{UUID: certUUID, Name: "space-err", Provider: "custom", Domains: "spaceerr.test"}) + + svc := services.NewCertificateService(tmpDir, db, nil) + mock := &mockBackupService{ + availableSpaceFunc: func() (int64, error) { return 0, fmt.Errorf("disk error") }, + createFunc: func() (string, error) { return "/tmp/backup.tar.gz", nil }, + } + h := NewCertificateHandler(svc, mock, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + // Space check error → proceeds with backup → succeeds + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDelete_UUID_BackupCreateError(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + db.Create(&models.SSLCertificate{UUID: certUUID, Name: "backup-fail", Provider: "custom", Domains: "backupfail.test"}) + + svc := services.NewCertificateService(tmpDir, db, nil) + mock := &mockBackupService{ + availableSpaceFunc: func() (int64, error) { return 1024 * 1024 * 1024, nil }, + createFunc: func() (string, error) { return "", fmt.Errorf("backup creation failed") }, + } + h := NewCertificateHandler(svc, mock, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// --- Delete UUID with notification service --- + +func TestDelete_UUID_WithNotification(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.Notification{}, &models.NotificationProvider{})) + + certUUID := uuid.New().String() + db.Create(&models.SSLCertificate{UUID: certUUID, Name: "notify-cert", Provider: "custom", Domains: "notify.test"}) + + svc := services.NewCertificateService(tmpDir, db, nil) + notifSvc := services.NewNotificationService(db, nil) + h := NewCertificateHandler(svc, nil, notifSvc) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + +// --- Validate handler --- + +func TestValidate_Success(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("certificate_file", "cert.pem") + require.NoError(t, err) + _, err = part.Write([]byte(certPEM)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/validate", h.Validate) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestValidate_InvalidCert(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("certificate_file", "cert.pem") + require.NoError(t, err) + _, err = part.Write([]byte("not a certificate")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/validate", h.Validate) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "unrecognized certificate format") +} + +func TestValidate_NoCertFile(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/validate", h.Validate) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", http.NoBody) + req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestValidate_WithKeyAndChain(t *testing.T) { + tmpDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + certPart, err := writer.CreateFormFile("certificate_file", "cert.pem") + require.NoError(t, err) + _, err = certPart.Write([]byte(certPEM)) + require.NoError(t, err) + + keyPart, err := writer.CreateFormFile("key_file", "key.pem") + require.NoError(t, err) + _, err = keyPart.Write([]byte(keyPEM)) + require.NoError(t, err) + + chainPart, err := writer.CreateFormFile("chain_file", "chain.pem") + require.NoError(t, err) + _, err = chainPart.Write([]byte(certPEM)) // self-signed chain + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/validate", h.Validate) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + +// --- Get handler DB error (non-NotFound) --- + +func TestGet_DBError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + // Deliberately don't migrate - any query will fail with "no such table" + + svc := services.NewCertificateService(t.TempDir(), db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.GET("/api/certificates/:uuid", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/"+uuid.New().String(), http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + // Should be 500 since the table doesn't exist + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// --- Export handler: re-auth and service error paths --- + +func TestExport_IncludeKey_MissingPassword(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"pem","include_key":true}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExport_IncludeKey_NoUserContext(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + r := gin.New() // no middleware — "user" key absent + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExport_IncludeKey_InvalidClaimsType(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user", "not-a-map"); c.Next() }) + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExport_IncludeKey_UserIDNotInClaims(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user", map[string]any{}); c.Next() }) // no "id" key + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExport_IncludeKey_UserNotFoundInDB(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user", map[string]any{"id": float64(9999)}); c.Next() }) + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"somepass"}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExport_IncludeKey_WrongPassword(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + + u := &models.User{UUID: uuid.New().String(), Email: "export@example.com", Name: "Export User"} + require.NoError(t, u.SetPassword("correctpass")) + require.NoError(t, db.Create(u).Error) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("user", map[string]any{"id": float64(u.ID)}); c.Next() }) + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"pem","include_key":true,"password":"wrongpass"}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExport_CertNotFound(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"pem"}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+uuid.New().String()+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestExport_ServiceError(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + cert := models.SSLCertificate{UUID: certUUID, Name: "test", Domains: "test.example.com", Provider: "custom"} + require.NoError(t, db.Create(&cert).Error) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/:uuid/export", h.Export) + + body := bytes.NewBufferString(`{"format":"unsupported_xyz"}`) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+certUUID+"/export", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// --- Delete numeric ID paths --- + +func TestDelete_NumericID_UsageCheckError(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) // no ProxyHost → IsCertificateInUse fails + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/1", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestDelete_NumericID_LowDiskSpace(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cert := models.SSLCertificate{UUID: uuid.New().String(), Name: "low-space", Domains: "lowspace.example.com", Provider: "custom"} + require.NoError(t, db.Create(&cert).Error) + + svc := services.NewCertificateService(tmpDir, db, nil) + backup := &mockBackupService{ + availableSpaceFunc: func() (int64, error) { return 1024, nil }, // < 100 MB + createFunc: func() (string, error) { return "", nil }, + } + h := NewCertificateHandler(svc, backup, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInsufficientStorage, w.Code) +} + +func TestDelete_NumericID_BackupError(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cert := models.SSLCertificate{UUID: uuid.New().String(), Name: "backup-err", Domains: "backuperr.example.com", Provider: "custom"} + require.NoError(t, db.Create(&cert).Error) + + svc := services.NewCertificateService(tmpDir, db, nil) + backup := &mockBackupService{ + availableSpaceFunc: func() (int64, error) { return 1 << 30, nil }, // 1 GB — plenty + createFunc: func() (string, error) { return "", fmt.Errorf("backup create failed") }, + } + h := NewCertificateHandler(svc, backup, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestDelete_NumericID_DeleteError(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{})) // no SSLCertificate → DeleteCertificateByID fails + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/42", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// --- Delete UUID: internal usage-check error --- + +func TestDelete_UUID_UsageCheckInternalError(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) // no ProxyHost → IsCertificateInUse fails + + certUUID := uuid.New().String() + cert := models.SSLCertificate{UUID: certUUID, Name: "uuid-err", Domains: "uuiderr.example.com", Provider: "custom"} + require.NoError(t, db.Create(&cert).Error) + + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// --- sendDeleteNotification: rate limit --- + +func TestSendDeleteNotification_RateLimit(t *testing.T) { + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + ns := services.NewNotificationService(db, nil) + svc := services.NewCertificateService(t.TempDir(), db, nil) + h := NewCertificateHandler(svc, nil, ns) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = httptest.NewRequest(http.MethodDelete, "/", http.NoBody) + + certRef := uuid.New().String() + h.sendDeleteNotification(ctx, certRef) // first call — sets timestamp + h.sendDeleteNotification(ctx, certRef) // second call — hits rate limit branch +} + +// --- Update: empty UUID param (lines 207-209) --- + +func TestUpdate_EmptyUUID(t *testing.T) { + svc := services.NewCertificateService(t.TempDir(), nil, nil) + h := NewCertificateHandler(svc, nil, nil) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = httptest.NewRequest(http.MethodPut, "/api/certificates/", bytes.NewBufferString(`{"name":"test"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + // No Params set — c.Param("uuid") returns "" + h.Update(ctx) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// --- Update: DB error (non-ErrCertNotFound) → lines 223-225 --- + +func TestUpdate_DBError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + // Deliberately no AutoMigrate → ssl_certificates table absent → "no such table" error + + svc := services.NewCertificateService(t.TempDir(), db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{"name": "new-name"}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/"+uuid.New().String(), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} diff --git a/backend/internal/api/handlers/certificate_handler_security_test.go b/backend/internal/api/handlers/certificate_handler_security_test.go index a118fa7ff..95f77f69a 100644 --- a/backend/internal/api/handlers/certificate_handler_security_test.go +++ b/backend/internal/api/handlers/certificate_handler_security_test.go @@ -30,9 +30,9 @@ func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) { r.Use(func(c *gin.Context) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) }) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/1", http.NoBody) w := httptest.NewRecorder() @@ -59,7 +59,7 @@ func TestCertificateHandler_List_RequiresAuth(t *testing.T) { r.Use(func(c *gin.Context) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) }) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -88,7 +88,7 @@ func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) { r.Use(func(c *gin.Context) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) }) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -125,7 +125,7 @@ func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock backup service that reports low disk space mockBackup := &mockBackupService{ @@ -135,7 +135,7 @@ func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) { } h := NewCertificateHandler(svc, mockBackup, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -177,7 +177,7 @@ func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) mockBackup := &mockBackupService{ createFunc: func() (string, error) { @@ -186,7 +186,7 @@ func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) { } h := NewCertificateHandler(svc, mockBackup, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) // Delete first cert req1 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert1.ID), http.NoBody) diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index 7971bcbc6..261d50b5a 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -39,9 +39,9 @@ func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) return r } @@ -111,7 +111,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService backupCalled := false @@ -123,7 +123,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) { } h := NewCertificateHandler(svc, mockBackupService, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -164,7 +164,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService that fails mockBackupService := &mockBackupService{ @@ -174,7 +174,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) { } h := NewCertificateHandler(svc, mockBackupService, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -217,7 +217,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService backupCalled := false @@ -229,7 +229,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) { } h := NewCertificateHandler(svc, mockBackupService, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -295,7 +295,7 @@ func TestCertificateHandler_List(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.GET("/api/certificates", h.List) @@ -321,7 +321,7 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -348,7 +348,7 @@ func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -378,7 +378,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -404,10 +404,15 @@ func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) + certPEM, _, genErr := generateSelfSignedCertPEM() + if genErr != nil { + t.Fatalf("failed to generate self-signed cert: %v", genErr) + } + var body bytes.Buffer writer := multipart.NewWriter(&body) _ = writer.WriteField("name", "testcert") @@ -415,7 +420,7 @@ func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T if createErr != nil { t.Fatalf("failed to create form file: %v", createErr) } - _, _ = part.Write([]byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----")) + _, _ = part.Write([]byte(certPEM)) _ = writer.Close() req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body) @@ -426,7 +431,7 @@ func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 Bad Request, got %d, body=%s", w.Code, w.Body.String()) } - if !strings.Contains(w.Body.String(), "key_file") { + if !strings.Contains(w.Body.String(), "key_file is required") { t.Fatalf("expected error message about key_file, got: %s", w.Body.String()) } } @@ -447,7 +452,7 @@ func TestCertificateHandler_Upload_Success(t *testing.T) { // Create a mock CertificateService that returns a created certificate // Create a temporary services.CertificateService with a temp dir and DB tmpDir := t.TempDir() - svc := services.NewCertificateService(tmpDir, db) + svc := services.NewCertificateService(tmpDir, db, nil) h := NewCertificateHandler(svc, nil, nil) r.POST("/api/certificates", h.Upload) @@ -519,7 +524,7 @@ func TestCertificateHandler_Upload_WithNotificationService(t *testing.T) { r.Use(mockAuthMiddleware()) tmpDir := t.TempDir() - svc := services.NewCertificateService(tmpDir, db) + svc := services.NewCertificateService(tmpDir, db, nil) ns := services.NewNotificationService(db, nil) h := NewCertificateHandler(svc, nil, ns) r.POST("/api/certificates", h.Upload) @@ -555,9 +560,9 @@ func TestDeleteCertificate_InvalidID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/invalid", http.NoBody) w := httptest.NewRecorder() @@ -580,9 +585,9 @@ func TestDeleteCertificate_ZeroID(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/0", http.NoBody) w := httptest.NewRecorder() @@ -611,7 +616,7 @@ func TestDeleteCertificate_LowDiskSpace(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService with low disk space mockBackupService := &mockBackupService{ @@ -621,7 +626,7 @@ func TestDeleteCertificate_LowDiskSpace(t *testing.T) { } h := NewCertificateHandler(svc, mockBackupService, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -659,7 +664,7 @@ func TestDeleteCertificate_DiskSpaceCheckError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) // Mock BackupService with space check error but backup succeeds mockBackupService := &mockBackupService{ @@ -672,7 +677,7 @@ func TestDeleteCertificate_DiskSpaceCheckError(t *testing.T) { } h := NewCertificateHandler(svc, mockBackupService, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -717,7 +722,7 @@ func TestDeleteCertificate_ExpiredLetsEncrypt_NotInUse(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) mockBS := &mockBackupService{ createFunc: func() (string, error) { @@ -726,7 +731,7 @@ func TestDeleteCertificate_ExpiredLetsEncrypt_NotInUse(t *testing.T) { } h := NewCertificateHandler(svc, mockBS, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -775,7 +780,7 @@ func TestDeleteCertificate_ValidLetsEncrypt_NotInUse(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) mockBS := &mockBackupService{ createFunc: func() (string, error) { @@ -784,7 +789,7 @@ func TestDeleteCertificate_ValidLetsEncrypt_NotInUse(t *testing.T) { } h := NewCertificateHandler(svc, mockBS, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -820,9 +825,9 @@ func TestDeleteCertificate_UsageCheckError(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) h := NewCertificateHandler(svc, nil, nil) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody) w := httptest.NewRecorder() @@ -857,7 +862,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) { r := gin.New() r.Use(mockAuthMiddleware()) - svc := services.NewCertificateService("/tmp", db) + svc := services.NewCertificateService("/tmp", db, nil) ns := services.NewNotificationService(db, nil) mockBackupService := &mockBackupService{ @@ -867,7 +872,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) { } h := NewCertificateHandler(svc, mockBackupService, ns) - r.DELETE("/api/certificates/:id", h.Delete) + r.DELETE("/api/certificates/:uuid", h.Delete) // Delete first certificate req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert1.ID), http.NoBody) diff --git a/backend/internal/api/handlers/certificate_handler_upload_export_test.go b/backend/internal/api/handlers/certificate_handler_upload_export_test.go new file mode 100644 index 000000000..a91f02b2e --- /dev/null +++ b/backend/internal/api/handlers/certificate_handler_upload_export_test.go @@ -0,0 +1,382 @@ +package handlers + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/crypto" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" +) + +// --- Upload: with chain file (covers chain_file multipart branch) --- + +func TestCertificateHandler_Upload_WithChainFile(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates", h.Upload) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("name", "chain-cert") + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte(certPEM)) + part2, _ := writer.CreateFormFile("key_file", "key.pem") + _, _ = part2.Write([]byte(keyPEM)) + part3, _ := writer.CreateFormFile("chain_file", "chain.pem") + _, _ = part3.Write([]byte(certPEM)) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code, "body: %s", w.Body.String()) +} + +// --- Upload: invalid cert data --- + +func TestCertificateHandler_Upload_InvalidCertData(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates", h.Upload) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("name", "bad-cert") + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte("not-a-cert")) + part2, _ := writer.CreateFormFile("key_file", "key.pem") + _, _ = part2.Write([]byte("not-a-key")) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// --- Export re-authentication flow --- + +func setupExportRouter(t *testing.T, db *gorm.DB) (*gin.Engine, *CertificateHandler) { + t.Helper() + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + r := gin.New() + return r, h +} + +func newTestEncSvc(t *testing.T) *crypto.EncryptionService { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + svc, err := crypto.NewEncryptionService(base64.StdEncoding.EncodeToString(key)) + require.NoError(t, err) + return svc +} + +func TestCertificateHandler_Export_IncludeKeySuccess(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + + user := models.User{UUID: "export-user-1", Email: "export@test.com", Name: "Exporter"} + require.NoError(t, user.SetPassword("correctpassword")) + require.NoError(t, db.Create(&user).Error) + + encSvc := newTestEncSvc(t) + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, encSvc) + h := NewCertificateHandler(svc, nil, nil) + h.SetDB(db) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + info, err := svc.UploadCertificate("export-cert", certPEM, keyPEM, "") + require.NoError(t, err) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user", map[string]any{"id": user.ID}) + c.Next() + }) + r.POST("/api/certificates/:uuid/export", h.Export) + + payload, _ := json.Marshal(map[string]any{ + "format": "pem", + "include_key": true, + "password": "correctpassword", + }) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+info.UUID+"/export", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Header().Get("Content-Disposition"), "export-cert.pem") +} + +func TestCertificateHandler_Export_IncludeKeyWrongPassword(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + + r, h := setupExportRouter(t, db) + + user := models.User{UUID: "wrong-pw-user", Email: "wrong@test.com", Name: "Wrong"} + require.NoError(t, user.SetPassword("rightpass")) + require.NoError(t, db.Create(&user).Error) + + r.Use(func(c *gin.Context) { + c.Set("user", map[string]any{"id": user.ID}) + c.Next() + }) + r.POST("/api/certificates/:uuid/export", h.Export) + + payload, _ := json.Marshal(map[string]any{ + "format": "pem", + "include_key": true, + "password": "wrongpass", + }) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "incorrect password") +} + +func TestCertificateHandler_Export_NoUserInContext(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + + r, h := setupExportRouter(t, db) + r.POST("/api/certificates/:uuid/export", h.Export) + + payload, _ := json.Marshal(map[string]any{ + "format": "pem", + "include_key": true, + "password": "anything", + }) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "authentication required") +} + +func TestCertificateHandler_Export_InvalidSession(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + + r, h := setupExportRouter(t, db) + r.Use(func(c *gin.Context) { + c.Set("user", "not-a-map") + c.Next() + }) + r.POST("/api/certificates/:uuid/export", h.Export) + + payload, _ := json.Marshal(map[string]any{ + "format": "pem", + "include_key": true, + "password": "anything", + }) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "invalid session") +} + +func TestCertificateHandler_Export_MissingUserID(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + + r, h := setupExportRouter(t, db) + r.Use(func(c *gin.Context) { + c.Set("user", map[string]any{"name": "test"}) + c.Next() + }) + r.POST("/api/certificates/:uuid/export", h.Export) + + payload, _ := json.Marshal(map[string]any{ + "format": "pem", + "include_key": true, + "password": "anything", + }) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "invalid session") +} + +func TestCertificateHandler_Export_UserNotFound(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{})) + + r, h := setupExportRouter(t, db) + r.Use(func(c *gin.Context) { + c.Set("user", map[string]any{"id": uint(9999)}) + c.Next() + }) + r.POST("/api/certificates/:uuid/export", h.Export) + + payload, _ := json.Marshal(map[string]any{ + "format": "pem", + "include_key": true, + "password": "anything", + }) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "user not found") +} + +// --- Validate handler with key and chain --- + +func TestCertificateHandler_Validate_WithKeyAndChain(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/validate", h.Validate) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte(certPEM)) + part2, _ := writer.CreateFormFile("key_file", "key.pem") + _, _ = part2.Write([]byte(keyPEM)) + part3, _ := writer.CreateFormFile("chain_file", "chain.pem") + _, _ = part3.Write([]byte(certPEM)) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) +} + +func TestCertificateHandler_Validate_InvalidCert(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/validate", h.Validate) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte("not-a-cert")) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + errList, ok := resp["errors"].([]any) + assert.True(t, ok) + assert.Greater(t, len(errList), 0, "expected validation errors in response") +} + +func TestCertificateHandler_Validate_MissingCertFile(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + + r := gin.New() + r.Use(mockAuthMiddleware()) + r.POST("/api/certificates/validate", h.Validate) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("name", "test") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "certificate_file is required") +} diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 6149e47dd..686a8c77c 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -248,6 +248,38 @@ func (h *ProxyHostHandler) resolveSecurityHeaderProfileReference(value any) (*ui return &id, nil } +func (h *ProxyHostHandler) resolveCertificateReference(value any) (*uint, error) { + if value == nil { + return nil, nil + } + + parsedID, _, parseErr := parseNullableUintField(value, "certificate_id") + if parseErr == nil { + return parsedID, nil + } + + uuidValue, isString := value.(string) + if !isString { + return nil, parseErr + } + + trimmed := strings.TrimSpace(uuidValue) + if trimmed == "" { + return nil, nil + } + + var cert models.SSLCertificate + if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("certificate not found") + } + return nil, fmt.Errorf("failed to resolve certificate") + } + + id := cert.ID + return &id, nil +} + func parseForwardPortField(value any) (int, error) { switch v := value.(type) { case float64: @@ -342,6 +374,15 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { payload["security_header_profile_id"] = resolvedSecurityHeaderID } + if rawCertRef, ok := payload["certificate_id"]; ok { + resolvedCertID, resolveErr := h.resolveCertificateReference(rawCertRef) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) + return + } + payload["certificate_id"] = resolvedCertID + } + payloadBytes, marshalErr := json.Marshal(payload) if marshalErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) @@ -523,12 +564,12 @@ func (h *ProxyHostHandler) Update(c *gin.Context) { // Nullable foreign keys if v, ok := payload["certificate_id"]; ok { - parsedID, _, parseErr := parseNullableUintField(v, "certificate_id") - if parseErr != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": parseErr.Error()}) + resolvedCertID, resolveErr := h.resolveCertificateReference(v) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) return } - host.CertificateID = parsedID + host.CertificateID = resolvedCertID } if v, ok := payload["access_list_id"]; ok { resolvedAccessListID, resolveErr := h.resolveAccessListReference(v) diff --git a/backend/internal/api/handlers/proxy_host_handler_patch_coverage_test.go b/backend/internal/api/handlers/proxy_host_handler_patch_coverage_test.go new file mode 100644 index 000000000..5b8dc56e6 --- /dev/null +++ b/backend/internal/api/handlers/proxy_host_handler_patch_coverage_test.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateForwardHostWarnings_PrivateIP(t *testing.T) { + warnings := generateForwardHostWarnings("192.168.1.100") + require.Len(t, warnings, 1) + assert.Equal(t, "forward_host", warnings[0].Field) +} + +func TestBulkUpdateSecurityHeaders_AllFail_Rollback(t *testing.T) { + r, _ := setupTestRouterForSecurityHeaders(t) + + body, err := json.Marshal(map[string]any{ + "host_uuids": []string{ + uuid.New().String(), + uuid.New().String(), + uuid.New().String(), + }, + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-security-headers", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestBulkUpdateSecurityHeaders_ProfileDB_NonNotFoundError(t *testing.T) { + r, db := setupTestRouterForSecurityHeaders(t) + + // Drop the security_header_profiles table so the lookup returns a non-NotFound DB error + require.NoError(t, db.Exec("DROP TABLE security_header_profiles").Error) + + profileID := uint(1) + body, err := json.Marshal(map[string]any{ + "host_uuids": []string{uuid.New().String()}, + "security_header_profile_id": profileID, + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-security-headers", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestGenerateForwardHostWarnings_DockerBridgeIP(t *testing.T) { + warnings := generateForwardHostWarnings("172.17.0.1") + require.Len(t, warnings, 1) + assert.Equal(t, "forward_host", warnings[0].Field) +} + +func TestParseNullableUintField_DefaultType(t *testing.T) { + id, exists, err := parseNullableUintField(true, "test_field") + assert.Nil(t, id) + assert.True(t, exists) + assert.Error(t, err) +} + +func TestParseForwardPortField_StringEmpty(t *testing.T) { + _, err := parseForwardPortField("") + assert.Error(t, err) +} + +func TestParseForwardPortField_StringNonNumeric(t *testing.T) { + _, err := parseForwardPortField("notaport") + assert.Error(t, err) +} + +func TestParseForwardPortField_StringValid(t *testing.T) { + port, err := parseForwardPortField("8080") + require.NoError(t, err) + assert.Equal(t, 8080, port) +} + +func TestParseForwardPortField_DefaultType(t *testing.T) { + _, err := parseForwardPortField(true) + assert.Error(t, err) +} + +func TestCreate_InvalidCertificateRef(t *testing.T) { + r, _ := setupTestRouterForSecurityHeaders(t) + + body, err := json.Marshal(map[string]any{ + "domain_names": "cert-ref.example.com", + "forward_host": "localhost", + "forward_port": 8080, + "certificate_id": uuid.New().String(), + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCreate_InvalidSecurityHeaderProfileRef(t *testing.T) { + r, _ := setupTestRouterForSecurityHeaders(t) + + body, err := json.Marshal(map[string]any{ + "domain_names": "shp-ref.example.com", + "forward_host": "localhost", + "forward_port": 8080, + "security_header_profile_id": uuid.New().String(), + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} diff --git a/backend/internal/api/routes/endpoint_inventory_test.go b/backend/internal/api/routes/endpoint_inventory_test.go index e6c233143..6019c6faa 100644 --- a/backend/internal/api/routes/endpoint_inventory_test.go +++ b/backend/internal/api/routes/endpoint_inventory_test.go @@ -1,6 +1,7 @@ package routes_test import ( + "context" "testing" "github.com/gin-gonic/gin" @@ -20,7 +21,7 @@ func TestEndpointInventory_FrontendCanonicalSaveImportContractsExistInBackend(t require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile") assertStrictMethodPathMatrix(t, router.Routes(), backendImportSaveInventoryCanonical(), "backend canonical save/import inventory") @@ -33,7 +34,7 @@ func TestEndpointInventory_FrontendParityMatchesCurrentContract(t *testing.T) { require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile") assertStrictMethodPathMatrix(t, router.Routes(), frontendObservedImportSaveInventory(), "frontend observed save/import inventory") @@ -46,7 +47,7 @@ func TestEndpointInventory_FrontendParityDetectsActualMismatch(t *testing.T) { require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile") contractWithMismatch := append([]endpointInventoryEntry{}, frontendObservedImportSaveInventory()...) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index dc6d09250..0a085c297 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -61,7 +61,7 @@ func migrateViewerToPassthrough(db *gorm.DB) { } // Register wires up API routes and performs automatic migrations. -func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { +func Register(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Caddy Manager - created early so it can be used by settings handlers for config reload caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) @@ -69,11 +69,11 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Cerberus middleware applies the optional security suite checks (WAF, ACL, CrowdSec) cerb := cerberus.New(cfg.Security, db) - return RegisterWithDeps(router, db, cfg, caddyManager, cerb) + return RegisterWithDeps(ctx, router, db, cfg, caddyManager, cerb) } // RegisterWithDeps wires up API routes and performs automatic migrations with prebuilt dependencies. -func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyManager *caddy.Manager, cerb *cerberus.Cerberus) error { +func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg config.Config, caddyManager *caddy.Manager, cerb *cerberus.Cerberus) error { // Emergency bypass must be registered FIRST. // When a valid X-Emergency-Token is present from an authorized source, // it sets an emergency context flag and strips the token header so downstream @@ -152,6 +152,14 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) caddyManager = caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) } + + // Wire encryption service to Caddy manager for decrypting certificate private keys + if cfg.EncryptionKey != "" { + if svc, err := crypto.NewEncryptionService(cfg.EncryptionKey); err == nil { + caddyManager.SetEncryptionService(svc) + } + } + if cerb == nil { cerb = cerberus.New(cfg.Security, db) } @@ -666,11 +674,38 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM // 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) + var certEncSvc *crypto.EncryptionService + if cfg.EncryptionKey != "" { + svc, err := crypto.NewEncryptionService(cfg.EncryptionKey) + if err != nil { + logger.Log().WithError(err).Warn("Failed to initialize encryption service for certificate key storage") + } else { + certEncSvc = svc + } + } + certService := services.NewCertificateService(caddyDataDir, db, certEncSvc) certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService) + certHandler.SetDB(db) + + // Migrate unencrypted private keys + if err := certService.MigratePrivateKeys(); err != nil { + logger.Log().WithError(err).Warn("Failed to migrate certificate private keys") + } + management.GET("/certificates", certHandler.List) management.POST("/certificates", certHandler.Upload) - management.DELETE("/certificates/:id", certHandler.Delete) + management.POST("/certificates/validate", certHandler.Validate) + management.GET("/certificates/:uuid", certHandler.Get) + management.PUT("/certificates/:uuid", certHandler.Update) + management.POST("/certificates/:uuid/export", certHandler.Export) + management.DELETE("/certificates/:uuid", certHandler.Delete) + + // Start certificate expiry checker + warningDays := 30 + if cfg.CertExpiryWarningDays > 0 { + warningDays = cfg.CertExpiryWarningDays + } + go certService.StartExpiryChecker(ctx, notificationService, warningDays) // Proxy Hosts & Remote Servers proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) diff --git a/backend/internal/api/routes/routes_coverage_test.go b/backend/internal/api/routes/routes_coverage_test.go index 57939ce75..4ab8e62d1 100644 --- a/backend/internal/api/routes/routes_coverage_test.go +++ b/backend/internal/api/routes/routes_coverage_test.go @@ -1,6 +1,7 @@ package routes import ( + "context" "errors" "testing" @@ -34,7 +35,10 @@ func TestRegister_NotifyOnlyProviderMigrationErrorReturns(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + err = Register(ctx, router, db, cfg) require.Error(t, err) require.Contains(t, err.Error(), "notify-only provider migration") } @@ -61,7 +65,10 @@ func TestRegister_LegacyMigrationErrorIsNonFatal(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + err = Register(ctx, router, db, cfg) require.NoError(t, err) hasHealth := false @@ -96,7 +103,10 @@ func TestRegister_UptimeFeatureFlagDefaultErrorIsNonFatal(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + err = Register(ctx, router, db, cfg) require.NoError(t, err) } @@ -122,6 +132,9 @@ func TestRegister_SecurityHeaderPresetInitErrorIsNonFatal(t *testing.T) { cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + err = Register(ctx, router, db, cfg) require.NoError(t, err) } diff --git a/backend/internal/api/routes/routes_save_contract_test.go b/backend/internal/api/routes/routes_save_contract_test.go index 33e9afd49..e5be04847 100644 --- a/backend/internal/api/routes/routes_save_contract_test.go +++ b/backend/internal/api/routes/routes_save_contract_test.go @@ -1,6 +1,7 @@ package routes_test import ( + "context" "testing" "github.com/gin-gonic/gin" @@ -19,7 +20,7 @@ func TestRegister_StrictSaveRouteMatrixUsedByImportWorkflows(t *testing.T) { require.NoError(t, err) router := gin.New() - require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"})) + require.NoError(t, routes.Register(context.Background(), router, db, config.Config{JWTSecret: "test-secret"})) assertStrictMethodPathMatrix(t, router.Routes(), saveRouteMatrixForImportWorkflows(), "save") } diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 9f8f8dfc5..d8714b0a7 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -1,6 +1,7 @@ package routes import ( + "context" "io" "net/http" "net/http/httptest" @@ -41,7 +42,7 @@ func TestRegister(t *testing.T) { JWTSecret: "test-secret", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) // Verify some routes are registered @@ -70,7 +71,7 @@ func TestRegister_WithDevelopmentEnvironment(t *testing.T) { Environment: "development", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) } @@ -86,7 +87,7 @@ func TestRegister_WithProductionEnvironment(t *testing.T) { Environment: "production", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) } @@ -107,7 +108,7 @@ func TestRegister_AutoMigrateFailure(t *testing.T) { JWTSecret: "test-secret", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "auto migrate") } @@ -148,7 +149,7 @@ func TestRegister_RoutesRegistration(t *testing.T) { JWTSecret: "test-secret", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) routes := router.Routes() @@ -181,7 +182,7 @@ func TestRegister_ProxyHostsRequireAuth(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") @@ -200,7 +201,7 @@ func TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist(t *testi require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) mutatingMethods := map[string]bool{ http.MethodPost: true, @@ -264,7 +265,7 @@ func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyMissing(t *testing. require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: ""} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) for _, r := range router.Routes() { assert.NotContains(t, r.Path, "/api/v1/dns-providers") @@ -279,7 +280,7 @@ func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyInvalid(t *testing. require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: "not-base64"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) for _, r := range router.Routes() { assert.NotContains(t, r.Path, "/api/v1/dns-providers") @@ -295,7 +296,7 @@ func TestRegister_DNSProviders_RegisteredWhenEncryptionKeyValid(t *testing.T) { // 32-byte all-zero key in base64 cfg := config.Config{JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) paths := make(map[string]bool) for _, r := range router.Routes() { @@ -317,7 +318,7 @@ func TestRegister_AllRoutesRegistered(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string][]string) // path -> methods @@ -384,7 +385,7 @@ func TestRegister_MiddlewareApplied(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Test that security headers middleware is applied w := httptest.NewRecorder() @@ -413,7 +414,7 @@ func TestRegister_AuthenticatedRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Test that protected routes require authentication protectedPaths := []struct { @@ -449,7 +450,7 @@ func TestRegister_StateChangingRoutesRequireAuthentication(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) stateChangingPaths := []struct { method string @@ -488,7 +489,7 @@ func TestRegister_AdminRoutes(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Admin routes should exist and require auth adminPaths := []string{ @@ -513,7 +514,7 @@ func TestRegister_PublicRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Public routes should be accessible without auth (route exists, not 404) publicPaths := []struct { @@ -545,7 +546,7 @@ func TestRegister_HealthEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) @@ -563,7 +564,7 @@ func TestRegister_MetricsEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/metrics", nil) @@ -582,7 +583,7 @@ func TestRegister_DBHealthEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/health/db", nil) @@ -600,7 +601,7 @@ func TestRegister_LoginEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Test login endpoint exists and accepts POST body := `{"username": "test", "password": "test"}` @@ -621,7 +622,7 @@ func TestRegister_SetupEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // GET /setup should return setup status w := httptest.NewRecorder() @@ -646,7 +647,7 @@ func TestRegister_WithEncryptionRoutes(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Check if encryption routes are registered (may depend on env) routes := router.Routes() @@ -668,7 +669,7 @@ func TestRegister_UptimeCheckEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Uptime check route should exist and require auth w := httptest.NewRecorder() @@ -687,7 +688,7 @@ func TestRegister_CrowdSecRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // CrowdSec routes should exist routes := router.Routes() @@ -713,7 +714,7 @@ func TestRegister_SecurityRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -740,7 +741,7 @@ func TestRegister_AccessListRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -763,7 +764,7 @@ func TestRegister_CertificateRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -773,7 +774,7 @@ func TestRegister_CertificateRoutes(t *testing.T) { // Certificate routes assert.True(t, routeMap["/api/v1/certificates"]) - assert.True(t, routeMap["/api/v1/certificates/:id"]) + assert.True(t, routeMap["/api/v1/certificates/:uuid"]) } // TestRegister_NilHandlers verifies registration behavior with minimal/nil components @@ -792,7 +793,7 @@ func TestRegister_NilHandlers(t *testing.T) { EncryptionKey: "", // No encryption key - DNS providers won't be registered } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) assert.NoError(t, err) // Verify that routes still work without DNS provider features @@ -823,7 +824,7 @@ func TestRegister_MiddlewareOrder(t *testing.T) { Environment: "development", } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) // Test that security headers are applied (they should come first) @@ -848,7 +849,7 @@ func TestRegister_GzipCompression(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request with Accept-Encoding: gzip w := httptest.NewRecorder() @@ -875,7 +876,7 @@ func TestRegister_CerberusMiddleware(t *testing.T) { }, } - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) // API routes should have Cerberus middleware applied @@ -896,7 +897,7 @@ func TestRegister_FeatureFlagsEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Feature flags should require auth w := httptest.NewRecorder() @@ -915,7 +916,7 @@ func TestRegister_WebSocketRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -939,7 +940,7 @@ func TestRegister_NotificationRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -967,7 +968,7 @@ func TestRegister_DomainRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -989,7 +990,7 @@ func TestRegister_VerifyAuthEndpoint(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Verify endpoint is public (for Caddy forward auth) w := httptest.NewRecorder() @@ -1009,7 +1010,7 @@ func TestRegister_SMTPRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1064,7 +1065,7 @@ func TestRegister_EncryptionRoutesWithValidKey(t *testing.T) { JWTSecret: "test-secret", EncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1091,7 +1092,7 @@ func TestRegister_WAFExclusionRoutes(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1113,7 +1114,7 @@ func TestRegister_BreakGlassRoute(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1134,7 +1135,7 @@ func TestRegister_RateLimitPresetsRoute(t *testing.T) { require.NoError(t, err) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) routes := router.Routes() routeMap := make(map[string]bool) @@ -1166,7 +1167,7 @@ func TestEmergencyEndpoint_BypassACL(t *testing.T) { CerberusEnabled: true, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Note: We don't need to create ACL settings here because the emergency endpoint // bypass happens at middleware level before Cerberus checks @@ -1210,7 +1211,7 @@ func TestEmergencyBypass_MiddlewareOrder(t *testing.T) { ManagementCIDRs: []string{"127.0.0.0/8"}, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request with emergency token should set bypass flag w := httptest.NewRecorder() @@ -1239,7 +1240,7 @@ func TestEmergencyBypass_InvalidToken(t *testing.T) { CerberusEnabled: true, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request with WRONG emergency token w := httptest.NewRecorder() @@ -1271,7 +1272,7 @@ func TestEmergencyBypass_UnauthorizedIP(t *testing.T) { ManagementCIDRs: []string{"192.168.1.0/24"}, }, } - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) // Request from public IP (not in management network) w := httptest.NewRecorder() @@ -1295,7 +1296,7 @@ func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) { t.Setenv("CHARON_CADDY_ACCESS_LOG", logFilePath) cfg := config.Config{JWTSecret: "test-secret"} - require.NoError(t, Register(router, db, cfg)) + require.NoError(t, Register(context.Background(), router, db, cfg)) _, statErr := os.Stat(logFilePath) assert.NoError(t, statErr) @@ -1341,7 +1342,7 @@ func TestRegister_CleansLetsEncryptCertAssignments(t *testing.T) { require.NoError(t, db.Create(&host).Error) cfg := config.Config{JWTSecret: "test-secret"} - err = Register(router, db, cfg) + err = Register(context.Background(), router, db, cfg) require.NoError(t, err) var reloaded models.ProxyHost diff --git a/backend/internal/api/tests/integration_test.go b/backend/internal/api/tests/integration_test.go index 6cc21b9af..2488744d2 100644 --- a/backend/internal/api/tests/integration_test.go +++ b/backend/internal/api/tests/integration_test.go @@ -2,6 +2,7 @@ package tests import ( + "context" "net/http" "net/http/httptest" "strings" @@ -33,7 +34,7 @@ func TestIntegration_WAF_BlockAndMonitor(t *testing.T) { } cfg.Security.WAFMode = mode r := gin.New() - if err := routes.Register(r, db, cfg); err != nil { + if err := routes.Register(context.Background(), r, db, cfg); err != nil { t.Fatalf("register: %v", err) } return r, db diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 63a8b8930..75863f70e 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/pkg/dnsprovider" @@ -15,7 +16,7 @@ import ( // GenerateConfig creates a Caddy JSON configuration from proxy hosts. // This is the core transformation layer from our database model to Caddy config. -func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { +func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { // Define log file paths for Caddy access logs. // When CrowdSec is enabled, we use /var/log/caddy/access.log which is the standard // location that CrowdSec's acquis.yaml is configured to monitor. @@ -427,16 +428,47 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir } if len(customCerts) > 0 { + // Resolve encryption service from variadic parameter + var certEncSvc *crypto.EncryptionService + if len(encSvc) > 0 && encSvc[0] != nil { + certEncSvc = encSvc[0] + } + var loadPEM []LoadPEMConfig for _, cert := range customCerts { - // Validate that custom cert has both certificate and key - if cert.Certificate == "" || cert.PrivateKey == "" { - logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate or key, skipping") + // Determine private key: prefer encrypted, fall back to plaintext for migration + var keyPEM string + if cert.PrivateKeyEncrypted != "" && certEncSvc != nil { + decrypted, err := certEncSvc.Decrypt(cert.PrivateKeyEncrypted) + if err != nil { + logger.Log().WithField("cert", cert.Name).WithError(err).Warn("Failed to decrypt private key, skipping certificate") + continue + } + keyPEM = string(decrypted) + } else if cert.PrivateKeyEncrypted != "" { + logger.Log().WithField("cert", cert.Name).Warn("Certificate has encrypted key but no encryption service available, skipping") + continue + } else if cert.PrivateKey != "" { + keyPEM = cert.PrivateKey + } else { + logger.Log().WithField("cert", cert.Name).Warn("Custom certificate has no encrypted key, skipping") continue } + + if cert.Certificate == "" { + logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate PEM, skipping") + continue + } + + // Concatenate chain with leaf certificate + fullCert := cert.Certificate + if cert.CertificateChain != "" { + fullCert = fullCert + "\n" + cert.CertificateChain + } + loadPEM = append(loadPEM, LoadPEMConfig{ - Certificate: cert.Certificate, - Key: cert.PrivateKey, + Certificate: fullCert, + Key: keyPEM, Tags: []string{cert.UUID}, }) } diff --git a/backend/internal/caddy/config_customcert_test.go b/backend/internal/caddy/config_customcert_test.go new file mode 100644 index 000000000..3d40d7eb2 --- /dev/null +++ b/backend/internal/caddy/config_customcert_test.go @@ -0,0 +1,166 @@ +package caddy + +import ( + "encoding/base64" + "testing" + + "github.com/Wikid82/charon/backend/internal/crypto" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestEncSvc(t *testing.T) *crypto.EncryptionService { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + svc, err := crypto.NewEncryptionService(base64.StdEncoding.EncodeToString(key)) + require.NoError(t, err) + return svc +} + +// Test: encrypted key with encryption service → decrypt success → cert loaded +func TestGenerateConfig_CustomCert_EncryptedKey(t *testing.T) { + encSvc := newTestEncSvc(t) + encKey, err := encSvc.Encrypt([]byte("-----BEGIN PRIVATE KEY-----\nfake-key-data\n-----END PRIVATE KEY-----")) + require.NoError(t, err) + + certID := uint(10) + hosts := []models.ProxyHost{ + { + UUID: "h-enc", DomainNames: "enc.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true, + CertificateID: &certID, + Certificate: &models.SSLCertificate{ + ID: certID, UUID: "c-enc", Name: "EncCert", Provider: "custom", + Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----", + PrivateKeyEncrypted: encKey, + }, + }, + } + + cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil, encSvc) + require.NoError(t, err) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Apps.TLS) + require.NotNil(t, cfg.Apps.TLS.Certificates) + assert.NotEmpty(t, cfg.Apps.TLS.Certificates.LoadPEM) +} + +// Test: encrypted key with no encryption service → skip +func TestGenerateConfig_CustomCert_EncryptedKeyNoEncSvc(t *testing.T) { + certID := uint(11) + hosts := []models.ProxyHost{ + { + UUID: "h-noenc", DomainNames: "noenc.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true, + CertificateID: &certID, + Certificate: &models.SSLCertificate{ + ID: certID, UUID: "c-noenc", Name: "NoEncSvcCert", Provider: "custom", + Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----", + PrivateKeyEncrypted: "encrypted-data-here", + }, + }, + } + + cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, cfg) + // Cert should be skipped - no TLS certs loaded + if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil { + assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM) + } +} + +// Test: no key at all → skip +func TestGenerateConfig_CustomCert_NoKey(t *testing.T) { + certID := uint(12) + hosts := []models.ProxyHost{ + { + UUID: "h-nokey", DomainNames: "nokey.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true, + CertificateID: &certID, + Certificate: &models.SSLCertificate{ + ID: certID, UUID: "c-nokey", Name: "NoKeyCert", Provider: "custom", + Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----", + }, + }, + } + + cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, cfg) + if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil { + assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM) + } +} + +// Test: missing cert PEM → skip +func TestGenerateConfig_CustomCert_NoCertPEM(t *testing.T) { + certID := uint(13) + hosts := []models.ProxyHost{ + { + UUID: "h-nocert", DomainNames: "nocert.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true, + CertificateID: &certID, + Certificate: &models.SSLCertificate{ + ID: certID, UUID: "c-nocert", Name: "NoCertPEM", Provider: "custom", + PrivateKey: "some-key", + }, + }, + } + + cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, cfg) + if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil { + assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM) + } +} + +// Test: cert with chain → chain concatenated +func TestGenerateConfig_CustomCert_WithChain(t *testing.T) { + certID := uint(14) + hosts := []models.ProxyHost{ + { + UUID: "h-chain", DomainNames: "chain.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true, + CertificateID: &certID, + Certificate: &models.SSLCertificate{ + ID: certID, UUID: "c-chain", Name: "ChainCert", Provider: "custom", + Certificate: "-----BEGIN CERTIFICATE-----\nleaf-cert\n-----END CERTIFICATE-----", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nkey-data\n-----END PRIVATE KEY-----", + CertificateChain: "-----BEGIN CERTIFICATE-----\nca-cert\n-----END CERTIFICATE-----", + }, + }, + } + + cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, cfg) + require.NotNil(t, cfg.Apps.TLS) + require.NotNil(t, cfg.Apps.TLS.Certificates) + require.NotEmpty(t, cfg.Apps.TLS.Certificates.LoadPEM) + assert.Contains(t, cfg.Apps.TLS.Certificates.LoadPEM[0].Certificate, "ca-cert") +} + +// Test: decrypt failure → skip +func TestGenerateConfig_CustomCert_DecryptFailure(t *testing.T) { + encSvc := newTestEncSvc(t) + certID := uint(15) + hosts := []models.ProxyHost{ + { + UUID: "h-decfail", DomainNames: "decfail.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true, + CertificateID: &certID, + Certificate: &models.SSLCertificate{ + ID: certID, UUID: "c-decfail", Name: "DecryptFail", Provider: "custom", + Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----", + PrivateKeyEncrypted: "not-valid-encrypted-data", + }, + }, + } + + cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil, encSvc) + require.NoError(t, err) + require.NotNil(t, cfg) + if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil { + assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM) + } +} diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index c2cfab9d3..4b6c6af12 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -73,6 +73,7 @@ type Manager struct { frontendDir string acmeStaging bool securityCfg config.SecurityConfig + encSvc *crypto.EncryptionService } // NewManager creates a configuration manager. @@ -87,6 +88,11 @@ func NewManager(client CaddyClient, db *gorm.DB, configDir, frontendDir string, } } +// SetEncryptionService configures the encryption service for decrypting private keys in Caddy config generation. +func (m *Manager) SetEncryptionService(svc *crypto.EncryptionService) { + m.encSvc = svc +} + // ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure. func (m *Manager) ApplyConfig(ctx context.Context) error { // Fetch all proxy hosts from database @@ -418,7 +424,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { } } - generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs) + generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs, m.encSvc) if err != nil { return fmt.Errorf("generate config: %w", err) } diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go index 4dd488462..41881de73 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" @@ -422,7 +423,7 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) { // stub generateConfigFunc to always return error orig := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { return nil, fmt.Errorf("generate fail") } defer func() { generateConfigFunc = orig }() @@ -600,7 +601,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T) // Stub generateConfigFunc to capture adminWhitelist var capturedAdmin string orig := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedAdmin = adminWhitelist // return minimal config return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil @@ -651,7 +652,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) { var capturedRules []models.SecurityRuleSet orig := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedRules = rulesets return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil } @@ -706,7 +707,7 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) { var capturedWafEnabled bool var capturedRulesets []models.SecurityRuleSet origGen := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedWafEnabled = wafEnabled capturedRulesets = rulesets return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs) @@ -811,7 +812,7 @@ func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) { // Capture rulesetPaths from GenerateConfig var capturedPaths map[string]string origGen := generateConfigFunc - generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { capturedPaths = rulesetPaths return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs) } diff --git a/backend/internal/caddy/manager_patch_coverage_test.go b/backend/internal/caddy/manager_patch_coverage_test.go index 5939b3222..370ecec5e 100644 --- a/backend/internal/caddy/manager_patch_coverage_test.go +++ b/backend/internal/caddy/manager_patch_coverage_test.go @@ -57,7 +57,7 @@ func TestManagerApplyConfig_DNSProviders_NoKey_SkipsDecryption(t *testing.T) { generateConfigFunc = origGen validateConfigFunc = origVal }() - generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) { capturedLen = len(dnsProviderConfigs) return &Config{}, nil } @@ -111,7 +111,7 @@ func TestManagerApplyConfig_DNSProviders_UsesFallbackEnvKeys(t *testing.T) { generateConfigFunc = origGen validateConfigFunc = origVal }() - generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) { captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...) return &Config{}, nil } @@ -175,7 +175,7 @@ func TestManagerApplyConfig_DNSProviders_SkipsDecryptOrJSONFailures(t *testing.T generateConfigFunc = origGen validateConfigFunc = origVal }() - generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { + generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) { captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...) return &Config{}, nil } diff --git a/backend/internal/caddy/manager_ssl_provider_test.go b/backend/internal/caddy/manager_ssl_provider_test.go index 39f8b8a98..f5c5072d0 100644 --- a/backend/internal/caddy/manager_ssl_provider_test.go +++ b/backend/internal/caddy/manager_ssl_provider_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,8 +18,8 @@ import ( ) // mockGenerateConfigFunc creates a mock config generator that captures parameters -func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig) (*Config, error) { - return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) { +func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig, ...*crypto.EncryptionService) (*Config, error) { + return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) { *capturedProvider = sslProvider *capturedStaging = acmeStaging return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a21db711e..fe09bce32 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { CaddyLogDir string CrowdSecLogDir string Debug bool + CertExpiryWarningDays int Security SecurityConfig Emergency EmergencyConfig } @@ -109,6 +110,13 @@ func Load() (Config, error) { Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true", } + cfg.CertExpiryWarningDays = 30 + if days := getEnvAny("", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" { + if n, err := strconv.Atoi(days); err == nil && n > 0 { + cfg.CertExpiryWarningDays = n + } + } + // Set JWTSecret using os.Getenv directly so no string literal flows into the // field — prevents CodeQL go/parse-jwt-with-hardcoded-key taint from any fallback. cfg.JWTSecret = os.Getenv("CHARON_JWT_SECRET") diff --git a/backend/internal/models/ssl_certificate.go b/backend/internal/models/ssl_certificate.go index 8734a7896..058786341 100644 --- a/backend/internal/models/ssl_certificate.go +++ b/backend/internal/models/ssl_certificate.go @@ -7,15 +7,24 @@ import ( // SSLCertificate represents TLS certificates managed by Charon. // Can be Let's Encrypt auto-generated or custom uploaded certs. type SSLCertificate struct { - ID uint `json:"-" gorm:"primaryKey"` - UUID string `json:"uuid" gorm:"uniqueIndex"` - Name string `json:"name" gorm:"index"` - Provider string `json:"provider" gorm:"index"` // "letsencrypt", "letsencrypt-staging", "custom" - Domains string `json:"domains" gorm:"index"` // comma-separated list of domains - Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate - PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key - ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"` - AutoRenew bool `json:"auto_renew" gorm:"default:false"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Name string `json:"name" gorm:"index"` + Provider string `json:"provider" gorm:"index"` + Domains string `json:"domains" gorm:"index"` + CommonName string `json:"common_name"` + Certificate string `json:"-" gorm:"type:text"` + CertificateChain string `json:"-" gorm:"type:text"` + PrivateKeyEncrypted string `json:"-" gorm:"column:private_key_enc;type:text"` + PrivateKey string `json:"-" gorm:"-"` + KeyVersion int `json:"-" gorm:"default:1"` + Fingerprint string `json:"fingerprint"` + SerialNumber string `json:"serial_number"` + IssuerOrg string `json:"issuer_org"` + KeyType string `json:"key_type"` + ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"` + NotBefore *time.Time `json:"not_before,omitempty"` + AutoRenew bool `json:"auto_renew" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/network/internal_service_client_test.go b/backend/internal/network/internal_service_client_test.go index 2f48e5ab5..5ebdc498a 100644 --- a/backend/internal/network/internal_service_client_test.go +++ b/backend/internal/network/internal_service_client_test.go @@ -27,6 +27,7 @@ func TestNewInternalServiceHTTPClient(t *testing.T) { client := NewInternalServiceHTTPClient(tt.timeout) if client == nil { t.Fatal("NewInternalServiceHTTPClient() returned nil") + return } if client.Timeout != tt.timeout { t.Errorf("expected timeout %v, got %v", tt.timeout, client.Timeout) diff --git a/backend/internal/network/safeclient_test.go b/backend/internal/network/safeclient_test.go index 1216f2e2c..c9a62b19e 100644 --- a/backend/internal/network/safeclient_test.go +++ b/backend/internal/network/safeclient_test.go @@ -179,6 +179,7 @@ func TestNewSafeHTTPClient_DefaultOptions(t *testing.T) { client := NewSafeHTTPClient() if client == nil { t.Fatal("NewSafeHTTPClient() returned nil") + return } if client.Timeout != 10*time.Second { t.Errorf("expected default timeout of 10s, got %v", client.Timeout) @@ -190,6 +191,7 @@ func TestNewSafeHTTPClient_WithTimeout(t *testing.T) { client := NewSafeHTTPClient(WithTimeout(10 * time.Second)) if client == nil { t.Fatal("NewSafeHTTPClient() returned nil") + return } if client.Timeout != 10*time.Second { t.Errorf("expected timeout of 10s, got %v", client.Timeout) @@ -848,6 +850,7 @@ func TestClientOptions_AllFunctionalOptions(t *testing.T) { if client == nil { t.Fatal("NewSafeHTTPClient() returned nil with all options") + return } if client.Timeout != 15*time.Second { t.Errorf("expected timeout of 15s, got %v", client.Timeout) diff --git a/backend/internal/services/certificate_helpers_test.go b/backend/internal/services/certificate_helpers_test.go new file mode 100644 index 000000000..330ddc59e --- /dev/null +++ b/backend/internal/services/certificate_helpers_test.go @@ -0,0 +1,38 @@ +package services + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "time" +) + +func generateSelfSignedCertPEM() (string, string, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test.example.com"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return "", "", err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return string(certPEM), string(keyPEM), nil +} + diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go index f6806d8a3..d1646e720 100644 --- a/backend/internal/services/certificate_service.go +++ b/backend/internal/services/certificate_service.go @@ -1,15 +1,19 @@ package services import ( + "context" + crand "crypto/rand" "crypto/x509" "encoding/pem" "fmt" + "math/big" "os" "path/filepath" "strings" "sync" "time" + "github.com/Wikid82/charon/backend/internal/crypto" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/util" @@ -22,22 +26,73 @@ import ( // ErrCertInUse is returned when a certificate is linked to one or more proxy hosts. var ErrCertInUse = fmt.Errorf("certificate is in use by one or more proxy hosts") -// CertificateInfo represents parsed certificate details. +// ErrCertNotFound is returned when a certificate cannot be found by UUID. +var ErrCertNotFound = fmt.Errorf("certificate not found") + +// CertificateInfo represents parsed certificate details for list responses. type CertificateInfo struct { - ID uint `json:"id,omitempty"` - UUID string `json:"uuid,omitempty"` - Name string `json:"name,omitempty"` - Domain string `json:"domain"` + UUID string `json:"uuid"` + Name string `json:"name,omitempty"` + CommonName string `json:"common_name,omitempty"` + Domains string `json:"domains"` + Issuer string `json:"issuer"` + IssuerOrg string `json:"issuer_org,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + KeyType string `json:"key_type,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + NotBefore time.Time `json:"not_before,omitempty"` + Status string `json:"status"` + Provider string `json:"provider"` + ChainDepth int `json:"chain_depth,omitempty"` + HasKey bool `json:"has_key"` + InUse bool `json:"in_use"` +} + +// AssignedHostInfo represents a proxy host assigned to a certificate. +type AssignedHostInfo struct { + UUID string `json:"uuid"` + Name string `json:"name"` + DomainNames string `json:"domain_names"` +} + +// ChainEntry represents a single certificate in the chain. +type ChainEntry struct { + Subject string `json:"subject"` Issuer string `json:"issuer"` ExpiresAt time.Time `json:"expires_at"` - Status string `json:"status"` // "valid", "expiring", "expired", "untrusted" - Provider string `json:"provider"` // "letsencrypt", "letsencrypt-staging", "custom" +} + +// CertificateDetail contains full certificate metadata for detail responses. +type CertificateDetail struct { + UUID string `json:"uuid"` + Name string `json:"name,omitempty"` + CommonName string `json:"common_name,omitempty"` + Domains string `json:"domains"` + Issuer string `json:"issuer"` + IssuerOrg string `json:"issuer_org,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + KeyType string `json:"key_type,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + NotBefore time.Time `json:"not_before,omitempty"` + Status string `json:"status"` + Provider string `json:"provider"` + ChainDepth int `json:"chain_depth,omitempty"` + HasKey bool `json:"has_key"` + InUse bool `json:"in_use"` + AssignedHosts []AssignedHostInfo `json:"assigned_hosts"` + Chain []ChainEntry `json:"chain"` + AutoRenew bool `json:"auto_renew"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CertificateService manages certificate retrieval and parsing. type CertificateService struct { dataDir string db *gorm.DB + encSvc *crypto.EncryptionService cache []CertificateInfo cacheMu sync.RWMutex lastScan time.Time @@ -46,11 +101,12 @@ type CertificateService struct { } // NewCertificateService creates a new certificate service. -func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService { +func NewCertificateService(dataDir string, db *gorm.DB, encSvc *crypto.EncryptionService) *CertificateService { svc := &CertificateService{ dataDir: dataDir, db: db, - scanTTL: 5 * time.Minute, // Only rescan disk every 5 minutes + encSvc: encSvc, + scanTTL: 5 * time.Minute, } return svc } @@ -224,15 +280,18 @@ func (s *CertificateService) refreshCacheFromDB() error { return fmt.Errorf("failed to fetch certs from DB: %w", err) } - // Build a map of domain -> proxy host name for quick lookup + // Build a set of certificate IDs that are in use + certInUse := make(map[uint]bool) var proxyHosts []models.ProxyHost s.db.Find(&proxyHosts) domainToName := make(map[string]string) for _, ph := range proxyHosts { + if ph.CertificateID != nil { + certInUse[*ph.CertificateID] = true + } if ph.Name == "" { continue } - // Handle comma-separated domains domains := strings.Split(ph.DomainNames, ",") for _, d := range domains { d = strings.TrimSpace(strings.ToLower(d)) @@ -244,27 +303,20 @@ func (s *CertificateService) refreshCacheFromDB() error { certs := make([]CertificateInfo, 0, len(dbCerts)) for _, c := range dbCerts { - status := "valid" - - // Staging certificates are untrusted by browsers - if strings.Contains(c.Provider, "staging") { - status = "untrusted" - } else if c.ExpiresAt != nil { - if time.Now().After(*c.ExpiresAt) { - status = "expired" - } else if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) { - status = "expiring" - } - } + status := certStatus(c) expires := time.Time{} if c.ExpiresAt != nil { expires = *c.ExpiresAt } + notBefore := time.Time{} + if c.NotBefore != nil { + notBefore = *c.NotBefore + } + // Try to get name from proxy host, fall back to cert name or domain name := c.Name - // Check all domains in the cert against proxy hosts certDomains := strings.Split(c.Domains, ",") for _, d := range certDomains { d = strings.TrimSpace(strings.ToLower(d)) @@ -274,15 +326,36 @@ func (s *CertificateService) refreshCacheFromDB() error { } } + chainDepth := 0 + if c.CertificateChain != "" { + rest := []byte(c.CertificateChain) + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + chainDepth++ + } + } + certs = append(certs, CertificateInfo{ - ID: c.ID, - UUID: c.UUID, - Name: name, - Domain: c.Domains, - Issuer: c.Provider, - ExpiresAt: expires, - Status: status, - Provider: c.Provider, + UUID: c.UUID, + Name: name, + CommonName: c.CommonName, + Domains: c.Domains, + Issuer: c.Provider, + IssuerOrg: c.IssuerOrg, + Fingerprint: c.Fingerprint, + SerialNumber: c.SerialNumber, + KeyType: c.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: status, + Provider: c.Provider, + ChainDepth: chainDepth, + HasKey: c.PrivateKeyEncrypted != "", + InUse: certInUse[c.ID], }) } @@ -290,6 +363,21 @@ func (s *CertificateService) refreshCacheFromDB() error { return nil } +func certStatus(c models.SSLCertificate) string { + if strings.Contains(c.Provider, "staging") { + return "untrusted" + } + if c.ExpiresAt != nil { + if time.Now().After(*c.ExpiresAt) { + return "expired" + } + if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) { + return "expiring" + } + } + return "valid" +} + // ListCertificates returns cached certificate info. // Fast path: returns from cache if available. // Triggers background rescan if cache is stale. @@ -342,45 +430,205 @@ func (s *CertificateService) InvalidateCache() { s.cacheMu.Unlock() } -// UploadCertificate saves a new custom certificate. -func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM string) (*models.SSLCertificate, error) { - // Validate PEM - block, _ := pem.Decode([]byte(certPEM)) - if block == nil { - return nil, fmt.Errorf("invalid certificate PEM") +// UploadCertificate saves a new custom certificate with full validation and encryption. +func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM, chainPEM string) (*CertificateInfo, error) { + parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "") + if err != nil { + return nil, fmt.Errorf("failed to parse certificate input: %w", err) } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse certificate: %w", err) + // Validate key matches certificate if key is provided + if parsed.PrivateKey != nil { + if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil { + return nil, fmt.Errorf("key validation failed: %w", err) + } } - // Create DB entry + // Extract metadata + meta := ExtractCertificateMetadata(parsed.Leaf) + + domains := meta.CommonName + if len(parsed.Leaf.DNSNames) > 0 { + domains = strings.Join(parsed.Leaf.DNSNames, ",") + } + + notAfter := parsed.Leaf.NotAfter + notBefore := parsed.Leaf.NotBefore + sslCert := &models.SSLCertificate{ - UUID: uuid.New().String(), - Name: name, - Provider: "custom", - Domains: cert.Subject.CommonName, // Or SANs - Certificate: certPEM, - PrivateKey: keyPEM, - ExpiresAt: &cert.NotAfter, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + UUID: uuid.New().String(), + Name: name, + Provider: "custom", + Domains: domains, + CommonName: meta.CommonName, + Certificate: parsed.CertPEM, + CertificateChain: parsed.ChainPEM, + Fingerprint: meta.Fingerprint, + SerialNumber: meta.SerialNumber, + IssuerOrg: meta.IssuerOrg, + KeyType: meta.KeyType, + ExpiresAt: ¬After, + NotBefore: ¬Before, + KeyVersion: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } - // Handle SANs if present - if len(cert.DNSNames) > 0 { - sslCert.Domains = strings.Join(cert.DNSNames, ",") + // Encrypt private key at rest + if parsed.KeyPEM != "" && s.encSvc != nil { + encrypted, err := s.encSvc.Encrypt([]byte(parsed.KeyPEM)) + if err != nil { + return nil, fmt.Errorf("failed to encrypt private key: %w", err) + } + sslCert.PrivateKeyEncrypted = encrypted } if err := s.db.Create(sslCert).Error; err != nil { - return nil, err + return nil, fmt.Errorf("failed to save certificate: %w", err) } - // Invalidate cache so the new cert appears immediately s.InvalidateCache() - return sslCert, nil + chainDepth := len(parsed.Intermediates) + + info := &CertificateInfo{ + UUID: sslCert.UUID, + Name: sslCert.Name, + CommonName: sslCert.CommonName, + Domains: sslCert.Domains, + Issuer: sslCert.Provider, + IssuerOrg: sslCert.IssuerOrg, + Fingerprint: sslCert.Fingerprint, + SerialNumber: sslCert.SerialNumber, + KeyType: sslCert.KeyType, + ExpiresAt: notAfter, + NotBefore: notBefore, + Status: certStatus(*sslCert), + Provider: sslCert.Provider, + ChainDepth: chainDepth, + HasKey: sslCert.PrivateKeyEncrypted != "", + InUse: false, + } + + return info, nil +} + +// GetCertificate returns full certificate detail by UUID. +func (s *CertificateService) GetCertificate(certUUID string) (*CertificateDetail, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("failed to fetch certificate: %w", err) + } + + // Get assigned hosts + var hosts []models.ProxyHost + s.db.Where("certificate_id = ?", cert.ID).Find(&hosts) + assignedHosts := make([]AssignedHostInfo, 0, len(hosts)) + for _, h := range hosts { + assignedHosts = append(assignedHosts, AssignedHostInfo{ + UUID: h.UUID, + Name: h.Name, + DomainNames: h.DomainNames, + }) + } + + // Parse chain entries + chain := buildChainEntries(cert.Certificate, cert.CertificateChain) + + expires := time.Time{} + if cert.ExpiresAt != nil { + expires = *cert.ExpiresAt + } + notBefore := time.Time{} + if cert.NotBefore != nil { + notBefore = *cert.NotBefore + } + + detail := &CertificateDetail{ + UUID: cert.UUID, + Name: cert.Name, + CommonName: cert.CommonName, + Domains: cert.Domains, + Issuer: cert.Provider, + IssuerOrg: cert.IssuerOrg, + Fingerprint: cert.Fingerprint, + SerialNumber: cert.SerialNumber, + KeyType: cert.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: certStatus(cert), + Provider: cert.Provider, + ChainDepth: len(chain), + HasKey: cert.PrivateKeyEncrypted != "", + InUse: len(hosts) > 0, + AssignedHosts: assignedHosts, + Chain: chain, + AutoRenew: cert.AutoRenew, + CreatedAt: cert.CreatedAt, + UpdatedAt: cert.UpdatedAt, + } + + return detail, nil +} + +// ValidateCertificate validates certificate data without storing. +func (s *CertificateService) ValidateCertificate(certPEM, keyPEM, chainPEM string) (*ValidationResult, error) { + result := &ValidationResult{ + Warnings: []string{}, + Errors: []string{}, + } + + parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "") + if err != nil { + result.Errors = append(result.Errors, err.Error()) + return result, nil + } + + meta := ExtractCertificateMetadata(parsed.Leaf) + result.CommonName = meta.CommonName + result.Domains = meta.Domains + result.IssuerOrg = meta.IssuerOrg + result.ExpiresAt = meta.NotAfter + result.ChainDepth = len(parsed.Intermediates) + + // Key match check + if parsed.PrivateKey != nil { + if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("key mismatch: %s", err.Error())) + } else { + result.KeyMatch = true + } + } + + // Chain validation (best-effort, warn on failure) + if len(parsed.Intermediates) > 0 { + if err := ValidateChain(parsed.Leaf, parsed.Intermediates); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("chain validation: %s", err.Error())) + } else { + result.ChainValid = true + } + } else { + // Try verifying with system roots + if err := ValidateChain(parsed.Leaf, nil); err != nil { + result.Warnings = append(result.Warnings, "certificate could not be verified against system roots") + } else { + result.ChainValid = true + } + } + + // Expiry warnings + daysUntilExpiry := time.Until(parsed.Leaf.NotAfter).Hours() / 24 + if daysUntilExpiry < 0 { + result.Warnings = append(result.Warnings, "Certificate has expired") + } else if daysUntilExpiry < 30 { + result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.0f days", daysUntilExpiry)) + } + + result.Valid = len(result.Errors) == 0 + return result, nil } // IsCertificateInUse checks if a certificate is referenced by any proxy host. @@ -392,10 +640,30 @@ func (s *CertificateService) IsCertificateInUse(id uint) (bool, error) { return count > 0, nil } -// DeleteCertificate removes a certificate. -func (s *CertificateService) DeleteCertificate(id uint) error { +// IsCertificateInUseByUUID checks if a certificate is referenced by any proxy host, looked up by UUID. +func (s *CertificateService) IsCertificateInUseByUUID(certUUID string) (bool, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return false, ErrCertNotFound + } + return false, fmt.Errorf("failed to look up certificate: %w", err) + } + return s.IsCertificateInUse(cert.ID) +} + +// DeleteCertificate removes a certificate by UUID. +func (s *CertificateService) DeleteCertificate(certUUID string) error { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return ErrCertNotFound + } + return fmt.Errorf("failed to look up certificate: %w", err) + } + // Prevent deletion if the certificate is referenced by any proxy host - inUse, err := s.IsCertificateInUse(id) + inUse, err := s.IsCertificateInUse(cert.ID) if err != nil { return err } @@ -403,30 +671,22 @@ func (s *CertificateService) DeleteCertificate(id uint) error { return ErrCertInUse } - var cert models.SSLCertificate - if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil { - return err - } - - if cert.Provider == "letsencrypt" { + if cert.Provider == "letsencrypt" || cert.Provider == "letsencrypt-staging" { // Best-effort file deletion certRoot := filepath.Join(s.dataDir, "certificates") _ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") { if info.Name() == cert.Domains+".crt" { - // Found it logger.Log().WithField("path", path).Info("CertificateService: deleting ACME cert file") if err := os.Remove(path); err != nil { logger.Log().WithError(err).Error("CertificateService: failed to delete cert file") } - // Try to delete key as well keyPath := strings.TrimSuffix(path, ".crt") + ".key" if _, err := os.Stat(keyPath); err == nil { if err := os.Remove(keyPath); err != nil { logger.Log().WithError(err).Warn("Failed to remove key file") } } - // Also try to delete the json meta file jsonPath := strings.TrimSuffix(path, ".crt") + ".json" if _, err := os.Stat(jsonPath); err == nil { if err := os.Remove(jsonPath); err != nil { @@ -439,10 +699,348 @@ func (s *CertificateService) DeleteCertificate(id uint) error { }) } - if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error; err != nil { - return err + if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", cert.ID).Error; err != nil { + return fmt.Errorf("failed to delete certificate: %w", err) } - // Invalidate cache so the deleted cert disappears immediately s.InvalidateCache() return nil } + +// ExportCertificate exports a certificate in the requested format. +// Returns the file data, suggested filename, and any error. +func (s *CertificateService) ExportCertificate(certUUID string, format string, includeKey bool, pfxPassword string) ([]byte, string, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, "", ErrCertNotFound + } + return nil, "", fmt.Errorf("failed to fetch certificate: %w", err) + } + + baseName := cert.Name + if baseName == "" { + baseName = "certificate" + } + + switch strings.ToLower(format) { + case "pem": + var buf strings.Builder + buf.WriteString(cert.Certificate) + if cert.CertificateChain != "" { + buf.WriteString("\n") + buf.WriteString(cert.CertificateChain) + } + if includeKey { + keyPEM, err := s.GetDecryptedPrivateKey(&cert) + if err != nil { + return nil, "", fmt.Errorf("failed to decrypt private key: %w", err) + } + buf.WriteString("\n") + buf.WriteString(keyPEM) + } + return []byte(buf.String()), baseName + ".pem", nil + + case "der": + derData, err := ConvertPEMToDER(cert.Certificate) + if err != nil { + return nil, "", fmt.Errorf("failed to convert to DER: %w", err) + } + return derData, baseName + ".der", nil + + case "pfx", "p12": + keyPEM, err := s.GetDecryptedPrivateKey(&cert) + if err != nil { + return nil, "", fmt.Errorf("failed to decrypt private key for PFX: %w", err) + } + pfxData, err := ConvertPEMToPFX(cert.Certificate, keyPEM, cert.CertificateChain, pfxPassword) + if err != nil { + return nil, "", fmt.Errorf("failed to create PFX: %w", err) + } + return pfxData, baseName + ".pfx", nil + + default: + return nil, "", fmt.Errorf("unsupported export format: %s", format) + } +} + +// GetDecryptedPrivateKey decrypts and returns the private key PEM for internal use. +func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error) { + if cert.PrivateKeyEncrypted == "" { + return "", fmt.Errorf("no encrypted private key stored") + } + if s.encSvc == nil { + return "", fmt.Errorf("encryption service not configured") + } + + decrypted, err := s.encSvc.Decrypt(cert.PrivateKeyEncrypted) + if err != nil { + return "", fmt.Errorf("failed to decrypt private key: %w", err) + } + + return string(decrypted), nil +} + +// MigratePrivateKeys encrypts existing plaintext private keys. +// Idempotent — skips already-migrated rows. +func (s *CertificateService) MigratePrivateKeys() error { + if s.encSvc == nil { + logger.Log().Warn("CertificateService: encryption service not configured, skipping key migration") + return nil + } + + // Use raw SQL because PrivateKey has gorm:"-" tag + type rawCert struct { + ID uint + PrivateKey string + PrivateKeyEnc string `gorm:"column:private_key_enc"` + } + + var certs []rawCert + if err := s.db.Raw("SELECT id, private_key, private_key_enc FROM ssl_certificates WHERE private_key != '' AND (private_key_enc = '' OR private_key_enc IS NULL)").Scan(&certs).Error; err != nil { + return fmt.Errorf("failed to query certificates for migration: %w", err) + } + + if len(certs) == 0 { + logger.Log().Info("CertificateService: no private keys to migrate") + return nil + } + + logger.Log().WithField("count", len(certs)).Info("CertificateService: migrating plaintext private keys") + + for _, c := range certs { + encrypted, err := s.encSvc.Encrypt([]byte(c.PrivateKey)) + if err != nil { + logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to encrypt key during migration") + continue + } + + if err := s.db.Exec("UPDATE ssl_certificates SET private_key_enc = ?, key_version = 1, private_key = '' WHERE id = ?", encrypted, c.ID).Error; err != nil { + logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to update migrated key") + continue + } + + logger.Log().WithField("cert_id", c.ID).Info("CertificateService: migrated private key") + } + + return nil +} + +// DeleteCertificateByID removes a certificate by numeric ID (legacy compatibility). +func (s *CertificateService) DeleteCertificateByID(id uint) error { + var cert models.SSLCertificate + if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil { + return fmt.Errorf("failed to look up certificate: %w", err) + } + return s.DeleteCertificate(cert.UUID) +} + +// UpdateCertificate updates certificate metadata (name only) by UUID. +func (s *CertificateService) UpdateCertificate(certUUID string, name string) (*CertificateInfo, error) { + var cert models.SSLCertificate + if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("failed to fetch certificate: %w", err) + } + + cert.Name = name + if err := s.db.Save(&cert).Error; err != nil { + return nil, fmt.Errorf("failed to update certificate: %w", err) + } + + s.InvalidateCache() + + expires := time.Time{} + if cert.ExpiresAt != nil { + expires = *cert.ExpiresAt + } + notBefore := time.Time{} + if cert.NotBefore != nil { + notBefore = *cert.NotBefore + } + + var chainDepth int + if cert.CertificateChain != "" { + certs, _ := parsePEMCertificates([]byte(cert.CertificateChain)) + chainDepth = len(certs) + } + + inUse, _ := s.IsCertificateInUse(cert.ID) + + return &CertificateInfo{ + UUID: cert.UUID, + Name: cert.Name, + CommonName: cert.CommonName, + Domains: cert.Domains, + Issuer: cert.Provider, + IssuerOrg: cert.IssuerOrg, + Fingerprint: cert.Fingerprint, + SerialNumber: cert.SerialNumber, + KeyType: cert.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: certStatus(cert), + Provider: cert.Provider, + ChainDepth: chainDepth, + HasKey: cert.PrivateKeyEncrypted != "", + InUse: inUse, + }, nil +} + +// CheckExpiringCertificates returns certificates that are expiring within the given number of days. +func (s *CertificateService) CheckExpiringCertificates(warningDays int) ([]CertificateInfo, error) { + var certs []models.SSLCertificate + threshold := time.Now().Add(time.Duration(warningDays) * 24 * time.Hour) + + if err := s.db.Where("provider = ? AND expires_at IS NOT NULL AND expires_at <= ?", "custom", threshold).Find(&certs).Error; err != nil { + return nil, fmt.Errorf("failed to query expiring certificates: %w", err) + } + + result := make([]CertificateInfo, 0, len(certs)) + for _, cert := range certs { + expires := time.Time{} + if cert.ExpiresAt != nil { + expires = *cert.ExpiresAt + } + notBefore := time.Time{} + if cert.NotBefore != nil { + notBefore = *cert.NotBefore + } + + result = append(result, CertificateInfo{ + UUID: cert.UUID, + Name: cert.Name, + CommonName: cert.CommonName, + Domains: cert.Domains, + Issuer: cert.Provider, + IssuerOrg: cert.IssuerOrg, + Fingerprint: cert.Fingerprint, + SerialNumber: cert.SerialNumber, + KeyType: cert.KeyType, + ExpiresAt: expires, + NotBefore: notBefore, + Status: certStatus(cert), + Provider: cert.Provider, + HasKey: cert.PrivateKeyEncrypted != "", + }) + } + + return result, nil +} + +// StartExpiryChecker runs a background goroutine that periodically checks for expiring certificates. +func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) { + // Startup delay: avoid notification bursts during frequent restarts + startupDelay := 5 * time.Minute + select { + case <-ctx.Done(): + return + case <-time.After(startupDelay): + } + + // Add random jitter (0-60 minutes) using crypto/rand + maxJitter := int64(60 * time.Minute) + n, errRand := crand.Int(crand.Reader, big.NewInt(maxJitter)) + if errRand != nil { + n = big.NewInt(maxJitter / 2) + } + jitter := time.Duration(n.Int64()) + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + s.checkExpiry(ctx, notificationSvc, warningDays) + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.checkExpiry(ctx, notificationSvc, warningDays) + } + } +} + +func (s *CertificateService) checkExpiry(ctx context.Context, notificationSvc *NotificationService, warningDays int) { + if notificationSvc == nil { + return + } + + certs, err := s.CheckExpiringCertificates(warningDays) + if err != nil { + logger.Log().WithError(err).Error("CertificateService: failed to check expiring certificates") + return + } + + for _, cert := range certs { + daysLeft := time.Until(cert.ExpiresAt).Hours() / 24 + + if daysLeft < 0 { + // Expired + if _, err := notificationSvc.Create( + models.NotificationTypeError, + "Certificate Expired", + fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains), + ); err != nil { + logger.Log().WithError(err).Error("CertificateService: failed to create expiry notification") + } + notificationSvc.SendExternal(ctx, + "cert_expiry", + "Certificate Expired", + fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains), + map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "status": "expired"}, + ) + } else { + // Expiring soon + if _, err := notificationSvc.Create( + models.NotificationTypeWarning, + "Certificate Expiring Soon", + fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft), + ); err != nil { + logger.Log().WithError(err).Error("CertificateService: failed to create expiry warning notification") + } + notificationSvc.SendExternal(ctx, + "cert_expiry", + "Certificate Expiring Soon", + fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft), + map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "days_left": int(daysLeft)}, + ) + } + } +} + +func buildChainEntries(certPEM, chainPEM string) []ChainEntry { + var entries []ChainEntry + + // Parse leaf + if certPEM != "" { + certs, _ := parsePEMCertificates([]byte(certPEM)) + for _, c := range certs { + entries = append(entries, ChainEntry{ + Subject: c.Subject.CommonName, + Issuer: c.Issuer.CommonName, + ExpiresAt: c.NotAfter, + }) + } + } + + // Parse chain + if chainPEM != "" { + certs, _ := parsePEMCertificates([]byte(chainPEM)) + for _, c := range certs { + entries = append(entries, ChainEntry{ + Subject: c.Subject.CommonName, + Issuer: c.Issuer.CommonName, + ExpiresAt: c.NotAfter, + }) + } + } + + return entries +} diff --git a/backend/internal/services/certificate_service_checkexpiry_test.go b/backend/internal/services/certificate_service_checkexpiry_test.go new file mode 100644 index 000000000..c92c3ac44 --- /dev/null +++ b/backend/internal/services/certificate_service_checkexpiry_test.go @@ -0,0 +1,172 @@ +package services + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// TestCheckExpiry_QueryFails covers lines 977-979: CheckExpiringCertificates fails. +func TestCheckExpiry_QueryFails(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.Notification{}, &models.NotificationProvider{})) + + // Drop ssl_certificates so CheckExpiringCertificates returns an error + require.NoError(t, db.Exec("DROP TABLE ssl_certificates").Error) + + ns := NewNotificationService(db, nil) + svc := NewCertificateService(t.TempDir(), db, nil) + + // Should not panic — logs the error and returns + svc.checkExpiry(context.Background(), ns, 30) +} + +// TestCheckExpiry_ExpiredCert_Success covers lines 981-998: expired cert notification success path. +func TestCheckExpiry_ExpiredCert_Success(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.Notification{}, &models.NotificationProvider{})) + + past := time.Now().Add(-48 * time.Hour) + certUUID := uuid.New().String() + require.NoError(t, db.Create(&models.SSLCertificate{ + UUID: certUUID, + Name: "expired-cert", + Provider: "custom", + Domains: "expired.example.com", + ExpiresAt: &past, + }).Error) + + ns := NewNotificationService(db, nil) + svc := NewCertificateService(t.TempDir(), db, nil) + + svc.checkExpiry(context.Background(), ns, 30) + + var notifications []models.Notification + require.NoError(t, db.Find(¬ifications).Error) + assert.NotEmpty(t, notifications) +} + +// TestCheckExpiry_ExpiringSoonCert_Success covers lines 999-1014: expiring-soon cert notification success path. +func TestCheckExpiry_ExpiringSoonCert_Success(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.Notification{}, &models.NotificationProvider{})) + + soon := time.Now().Add(7 * 24 * time.Hour) + certUUID := uuid.New().String() + require.NoError(t, db.Create(&models.SSLCertificate{ + UUID: certUUID, + Name: "expiring-soon-cert", + Provider: "custom", + Domains: "soon.example.com", + ExpiresAt: &soon, + }).Error) + + ns := NewNotificationService(db, nil) + svc := NewCertificateService(t.TempDir(), db, nil) + + svc.checkExpiry(context.Background(), ns, 30) + + var notifications []models.Notification + require.NoError(t, db.Find(¬ifications).Error) + assert.NotEmpty(t, notifications) +} + +// TestCheckExpiry_NotificationFails covers lines 991-992 and 1006-1007: +// Create() fails for both expired and expiring-soon certs. +func TestCheckExpiry_NotificationFails(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.Notification{}, &models.NotificationProvider{})) + + past := time.Now().Add(-48 * time.Hour) + soon := time.Now().Add(7 * 24 * time.Hour) + + require.NoError(t, db.Create(&models.SSLCertificate{ + UUID: uuid.New().String(), + Name: "expired-cert", + Provider: "custom", + Domains: "expired2.example.com", + ExpiresAt: &past, + }).Error) + require.NoError(t, db.Create(&models.SSLCertificate{ + UUID: uuid.New().String(), + Name: "soon-cert", + Provider: "custom", + Domains: "soon2.example.com", + ExpiresAt: &soon, + }).Error) + + // Drop notifications table so Create() fails + require.NoError(t, db.Exec("DROP TABLE notifications").Error) + + ns := NewNotificationService(db, nil) + svc := NewCertificateService(t.TempDir(), db, nil) + + // Should not panic — logs errors and continues + svc.checkExpiry(context.Background(), ns, 30) +} + +func TestUploadCertificate_KeyMismatch(t *testing.T) { + cert1PEM, _ := generateTestCertAndKey(t, "cert1.example.com", time.Now().Add(24*time.Hour)) + _, key2PEM := generateTestCertAndKey(t, "cert2.example.com", time.Now().Add(24*time.Hour)) + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) + + svc := NewCertificateService(t.TempDir(), db, nil) + + _, err = svc.UploadCertificate("mismatch-test", string(cert1PEM), string(key2PEM), "") + require.Error(t, err) + assert.Contains(t, err.Error(), "key validation failed") +} + +func TestUploadCertificate_DBError(t *testing.T) { + certPEM, keyPEM := generateTestCertAndKey(t, "db-err.example.com", time.Now().Add(24*time.Hour)) + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + // No AutoMigrate → ssl_certificates table absent → db.Create fails + + svc := NewCertificateService(t.TempDir(), db, nil) + + _, err = svc.UploadCertificate("db-error-test", string(certPEM), string(keyPEM), "") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to save certificate") +} + +func TestGetCertificate_DBError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + // No AutoMigrate → ssl_certificates table absent → First() returns error + + svc := NewCertificateService(t.TempDir(), db, nil) + + _, err = svc.GetCertificate(uuid.New().String()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch certificate") +} + +func TestUpdateCertificate_DBError(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + // No AutoMigrate → ssl_certificates table absent → First() returns non-ErrRecordNotFound error + + svc := NewCertificateService(t.TempDir(), db, nil) + + _, err = svc.UpdateCertificate(uuid.New().String(), "new-name") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch certificate") +} diff --git a/backend/internal/services/certificate_service_coverage_test.go b/backend/internal/services/certificate_service_coverage_test.go new file mode 100644 index 000000000..3a96909e2 --- /dev/null +++ b/backend/internal/services/certificate_service_coverage_test.go @@ -0,0 +1,520 @@ +package services + +import ( + "context" + "encoding/base64" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/crypto" + "github.com/Wikid82/charon/backend/internal/models" +) + +// newTestEncryptionService creates a real EncryptionService for tests. +func newTestEncryptionService(t *testing.T) *crypto.EncryptionService { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + keyB64 := base64.StdEncoding.EncodeToString(key) + svc, err := crypto.NewEncryptionService(keyB64) + require.NoError(t, err) + return svc +} + +func newTestCertServiceWithEnc(t *testing.T, dataDir string, db *gorm.DB) *CertificateService { + t.Helper() + encSvc := newTestEncryptionService(t) + return &CertificateService{ + dataDir: dataDir, + db: db, + encSvc: encSvc, + scanTTL: 5 * time.Minute, + } +} + +func seedCertWithKey(t *testing.T, db *gorm.DB, encSvc *crypto.EncryptionService, uuid, name, domain string, expiry time.Time) models.SSLCertificate { + t.Helper() + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) + + encKey, err := encSvc.Encrypt(keyPEM) + require.NoError(t, err) + + cert := models.SSLCertificate{ + UUID: uuid, + Name: name, + Provider: "custom", + Domains: domain, + CommonName: domain, + Certificate: string(certPEM), + PrivateKeyEncrypted: encKey, + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + return cert +} + +func TestCertificateService_GetCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.GetCertificate("nonexistent-uuid") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("found with no hosts", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + notBefore := time.Now().Add(-time.Hour) + cert := models.SSLCertificate{ + UUID: "get-cert-1", + Name: "Test Cert", + Provider: "custom", + Domains: "get.example.com", + CommonName: "get.example.com", + ExpiresAt: &expiry, + NotBefore: ¬Before, + } + require.NoError(t, db.Create(&cert).Error) + + detail, err := cs.GetCertificate("get-cert-1") + require.NoError(t, err) + assert.Equal(t, "get-cert-1", detail.UUID) + assert.Equal(t, "Test Cert", detail.Name) + assert.Equal(t, "get.example.com", detail.CommonName) + assert.False(t, detail.InUse) + assert.Empty(t, detail.AssignedHosts) + }) + + t.Run("found with assigned host", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := models.SSLCertificate{ + UUID: "get-cert-2", + Name: "Assigned Cert", + Provider: "custom", + Domains: "assigned.example.com", + CommonName: "assigned.example.com", + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + + ph := models.ProxyHost{ + UUID: "ph-assigned", + Name: "My Proxy", + DomainNames: "assigned.example.com", + ForwardHost: "localhost", + ForwardPort: 8080, + CertificateID: &cert.ID, + } + require.NoError(t, db.Create(&ph).Error) + + detail, err := cs.GetCertificate("get-cert-2") + require.NoError(t, err) + assert.True(t, detail.InUse) + require.Len(t, detail.AssignedHosts, 1) + assert.Equal(t, "My Proxy", detail.AssignedHosts[0].Name) + }) + + t.Run("nil expiry and not_before", func(t *testing.T) { + cert := models.SSLCertificate{ + UUID: "get-cert-3", + Name: "No Dates", + Provider: "custom", + Domains: "nodates.example.com", + } + require.NoError(t, db.Create(&cert).Error) + + detail, err := cs.GetCertificate("get-cert-3") + require.NoError(t, err) + assert.True(t, detail.ExpiresAt.IsZero()) + assert.True(t, detail.NotBefore.IsZero()) + }) +} + +func TestCertificateService_ValidateCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("valid cert with key", func(t *testing.T) { + certPEM, keyPEM := generateTestCertAndKey(t, "validate.example.com", time.Now().Add(24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), string(keyPEM), "") + require.NoError(t, err) + assert.True(t, result.Valid) + assert.True(t, result.KeyMatch) + assert.Empty(t, result.Errors) + }) + + t.Run("invalid cert data", func(t *testing.T) { + result, err := cs.ValidateCertificate("not-a-cert", "", "") + require.NoError(t, err) + assert.False(t, result.Valid) + assert.NotEmpty(t, result.Errors) + }) + + t.Run("valid cert without key", func(t *testing.T) { + certPEM := generateTestCert(t, "nokey.example.com", time.Now().Add(24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), "", "") + require.NoError(t, err) + assert.True(t, result.Valid) + assert.False(t, result.KeyMatch) + assert.Empty(t, result.Errors) + }) + + t.Run("expired cert", func(t *testing.T) { + certPEM := generateTestCert(t, "expired.example.com", time.Now().Add(-24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), "", "") + require.NoError(t, err) + assert.NotEmpty(t, result.Warnings) + }) +} + +func TestCertificateService_UpdateCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.UpdateCertificate("nonexistent-uuid", "New Name") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("successful rename", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := models.SSLCertificate{ + UUID: "update-cert-1", + Name: "Old Name", + Provider: "custom", + Domains: "update.example.com", + CommonName: "update.example.com", + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + + info, err := cs.UpdateCertificate("update-cert-1", "New Name") + require.NoError(t, err) + assert.Equal(t, "New Name", info.Name) + assert.Equal(t, "update-cert-1", info.UUID) + assert.Equal(t, "custom", info.Provider) + }) + + t.Run("updates persist", func(t *testing.T) { + var cert models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", "update-cert-1").First(&cert).Error) + assert.Equal(t, "New Name", cert.Name) + }) + + t.Run("nil expiry and not_before", func(t *testing.T) { + cert := models.SSLCertificate{ + UUID: "update-cert-2", + Name: "No Dates Cert", + Provider: "custom", + Domains: "nodates-update.example.com", + } + require.NoError(t, db.Create(&cert).Error) + + info, err := cs.UpdateCertificate("update-cert-2", "Renamed No Dates") + require.NoError(t, err) + assert.Equal(t, "Renamed No Dates", info.Name) + assert.True(t, info.ExpiresAt.IsZero()) + }) +} + +func TestCertificateService_IsCertificateInUseByUUID(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.IsCertificateInUseByUUID("nonexistent-uuid") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("not in use", func(t *testing.T) { + cert := models.SSLCertificate{UUID: "inuse-1", Name: "Free Cert", Provider: "custom", Domains: "free.example.com"} + require.NoError(t, db.Create(&cert).Error) + + inUse, err := cs.IsCertificateInUseByUUID("inuse-1") + require.NoError(t, err) + assert.False(t, inUse) + }) + + t.Run("in use", func(t *testing.T) { + cert := models.SSLCertificate{UUID: "inuse-2", Name: "Used Cert", Provider: "custom", Domains: "used.example.com"} + require.NoError(t, db.Create(&cert).Error) + + ph := models.ProxyHost{UUID: "ph-inuse", Name: "Using Proxy", DomainNames: "used.example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID} + require.NoError(t, db.Create(&ph).Error) + + inUse, err := cs.IsCertificateInUseByUUID("inuse-2") + require.NoError(t, err) + assert.True(t, inUse) + }) +} + +func TestCertificateService_DeleteCertificateByID(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + cert := models.SSLCertificate{UUID: "del-by-id-1", Name: "Delete By ID", Provider: "custom", Domains: "delbyid.example.com"} + require.NoError(t, db.Create(&cert).Error) + + err = cs.DeleteCertificateByID(cert.ID) + require.NoError(t, err) + + var found models.SSLCertificate + err = db.Where("uuid = ?", "del-by-id-1").First(&found).Error + assert.Error(t, err) +} + +func TestCertificateService_ExportCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + encSvc := newTestEncryptionService(t) + cs := newTestCertServiceWithEnc(t, tmpDir, db) + + domain := "export.example.com" + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := seedCertWithKey(t, db, encSvc, "export-cert-1", "Export Cert", domain, expiry) + + t.Run("not found", func(t *testing.T) { + _, _, err := cs.ExportCertificate("nonexistent", "pem", false, "") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("pem without key", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", false, "") + require.NoError(t, err) + assert.Equal(t, "Export Cert.pem", filename) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + }) + + t.Run("pem with key", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", true, "") + require.NoError(t, err) + assert.Equal(t, "Export Cert.pem", filename) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + assert.Contains(t, string(data), "PRIVATE KEY") + }) + + t.Run("der format", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "der", false, "") + require.NoError(t, err) + assert.Equal(t, "Export Cert.der", filename) + assert.NotEmpty(t, data) + }) + + t.Run("pfx format", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pfx", false, "") + require.NoError(t, err) + assert.Equal(t, "Export Cert.pfx", filename) + assert.NotEmpty(t, data) + }) + + t.Run("unsupported format", func(t *testing.T) { + _, _, err := cs.ExportCertificate(cert.UUID, "jks", false, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported export format") + }) + + t.Run("empty name uses fallback", func(t *testing.T) { + noNameCert := seedCertWithKey(t, db, encSvc, "export-noname", "", domain, expiry) + _, filename, err := cs.ExportCertificate(noNameCert.UUID, "pem", false, "") + require.NoError(t, err) + assert.Equal(t, "certificate.pem", filename) + }) +} + +func TestCertificateService_GetDecryptedPrivateKey(t *testing.T) { + encSvc := newTestEncryptionService(t) + + t.Run("no encrypted key", func(t *testing.T) { + cs := &CertificateService{encSvc: encSvc} + cert := &models.SSLCertificate{PrivateKeyEncrypted: ""} + _, err := cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no encrypted private key") + }) + + t.Run("no encryption service", func(t *testing.T) { + cs := &CertificateService{encSvc: nil} + cert := &models.SSLCertificate{PrivateKeyEncrypted: "some-data"} + _, err := cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "encryption service not configured") + }) + + t.Run("successful decryption", func(t *testing.T) { + cs := &CertificateService{encSvc: encSvc} + plaintext := "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----" //nolint:gosec // test data, not real credentials + encrypted, err := encSvc.Encrypt([]byte(plaintext)) + require.NoError(t, err) + + cert := &models.SSLCertificate{PrivateKeyEncrypted: encrypted} + result, err := cs.GetDecryptedPrivateKey(cert) + require.NoError(t, err) + assert.Equal(t, plaintext, result) + }) +} + +func TestCertificateService_CheckExpiringCertificates(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + // Create certs with different expiry states + expiringSoon := time.Now().Add(5 * 24 * time.Hour) + expired := time.Now().Add(-24 * time.Hour) + farFuture := time.Now().Add(365 * 24 * time.Hour) + + db.Create(&models.SSLCertificate{UUID: "exp-soon", Name: "Expiring Soon", Provider: "custom", Domains: "soon.example.com", ExpiresAt: &expiringSoon}) + db.Create(&models.SSLCertificate{UUID: "exp-past", Name: "Already Expired", Provider: "custom", Domains: "expired.example.com", ExpiresAt: &expired}) + db.Create(&models.SSLCertificate{UUID: "exp-far", Name: "Far Future", Provider: "custom", Domains: "far.example.com", ExpiresAt: &farFuture}) + // ACME certs should not be included (only custom) + db.Create(&models.SSLCertificate{UUID: "exp-le", Name: "LE Cert", Provider: "letsencrypt", Domains: "le.example.com", ExpiresAt: &expiringSoon}) + + t.Run("30 day window", func(t *testing.T) { + certs, err := cs.CheckExpiringCertificates(30) + require.NoError(t, err) + assert.Len(t, certs, 2) // expiringSoon and expired + + foundSoon := false + foundExpired := false + for _, c := range certs { + if c.UUID == "exp-soon" { + foundSoon = true + } + if c.UUID == "exp-past" { + foundExpired = true + } + } + assert.True(t, foundSoon) + assert.True(t, foundExpired) + }) + + t.Run("1 day window", func(t *testing.T) { + certs, err := cs.CheckExpiringCertificates(1) + require.NoError(t, err) + assert.Len(t, certs, 1) // only the expired one + assert.Equal(t, "exp-past", certs[0].UUID) + }) +} + +func TestCertificateService_CheckExpiry(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.NotificationProvider{}, &models.Notification{})) + + cs := newTestCertificateService(tmpDir, db) + ns := NewNotificationService(db, nil) + + expiringSoon := time.Now().Add(5 * 24 * time.Hour) + expired := time.Now().Add(-24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "chk-soon", Name: "Expiring", Provider: "custom", Domains: "chksoon.example.com", ExpiresAt: &expiringSoon}) + db.Create(&models.SSLCertificate{UUID: "chk-past", Name: "Expired", Provider: "custom", Domains: "chkpast.example.com", ExpiresAt: &expired}) + + t.Run("nil notification service", func(t *testing.T) { + cs.checkExpiry(context.Background(), nil, 30) + }) + + t.Run("creates notifications for expiring certs", func(t *testing.T) { + cs.checkExpiry(context.Background(), ns, 30) + + var notifications []models.Notification + db.Find(¬ifications) + assert.GreaterOrEqual(t, len(notifications), 2) + }) +} + +func TestCertificateService_MigratePrivateKeys(t *testing.T) { + t.Run("no encryption service", func(t *testing.T) { + cs := &CertificateService{encSvc: nil} + err := cs.MigratePrivateKeys() + require.NoError(t, err) + }) + + t.Run("no keys to migrate", func(t *testing.T) { + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + // MigratePrivateKeys uses raw SQL referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + encSvc := newTestEncryptionService(t) + cs := &CertificateService{db: db, encSvc: encSvc} + + err = cs.MigratePrivateKeys() + require.NoError(t, err) + }) + + t.Run("migrates plaintext key", func(t *testing.T) { + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + // MigratePrivateKeys uses raw SQL referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + // Insert cert with plaintext key using raw SQL + require.NoError(t, db.Exec( + "INSERT INTO ssl_certificates (uuid, name, provider, domains, private_key) VALUES (?, ?, ?, ?, ?)", + "migrate-1", "Migrate Test", "custom", "migrate.example.com", "plaintext-key-data", + ).Error) + + encSvc := newTestEncryptionService(t) + cs := &CertificateService{db: db, encSvc: encSvc} + + err = cs.MigratePrivateKeys() + require.NoError(t, err) + + // Verify the key was encrypted and plaintext cleared + type rawRow struct { + PrivateKey string `gorm:"column:private_key"` + PrivateKeyEnc string `gorm:"column:private_key_enc"` + } + var row rawRow + require.NoError(t, db.Raw("SELECT private_key, private_key_enc FROM ssl_certificates WHERE uuid = ?", "migrate-1").Scan(&row).Error) + assert.Empty(t, row.PrivateKey) + assert.NotEmpty(t, row.PrivateKeyEnc) + }) +} diff --git a/backend/internal/services/certificate_service_extra_coverage_test.go b/backend/internal/services/certificate_service_extra_coverage_test.go new file mode 100644 index 000000000..682573b16 --- /dev/null +++ b/backend/internal/services/certificate_service_extra_coverage_test.go @@ -0,0 +1,292 @@ +package services + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// --- buildChainEntries --- + +func TestBuildChainEntries(t *testing.T) { + certPEM := string(generateTestCert(t, "leaf.example.com", time.Now().Add(24*time.Hour))) + chainPEM := string(generateTestCert(t, "ca.example.com", time.Now().Add(365*24*time.Hour))) + + t.Run("leaf only", func(t *testing.T) { + entries := buildChainEntries(certPEM, "") + require.Len(t, entries, 1) + assert.Equal(t, "leaf.example.com", entries[0].Subject) + }) + + t.Run("leaf and chain", func(t *testing.T) { + entries := buildChainEntries(certPEM, chainPEM) + require.Len(t, entries, 2) + assert.Equal(t, "leaf.example.com", entries[0].Subject) + assert.Equal(t, "ca.example.com", entries[1].Subject) + }) + + t.Run("empty cert", func(t *testing.T) { + entries := buildChainEntries("", chainPEM) + require.Len(t, entries, 1) + assert.Equal(t, "ca.example.com", entries[0].Subject) + }) + + t.Run("both empty", func(t *testing.T) { + entries := buildChainEntries("", "") + assert.Empty(t, entries) + }) + + t.Run("invalid PEM ignored", func(t *testing.T) { + entries := buildChainEntries("not-pem", "also-not-pem") + assert.Empty(t, entries) + }) +} + +// --- certStatus --- + +func TestCertStatus(t *testing.T) { + now := time.Now() + + t.Run("valid", func(t *testing.T) { + expiry := now.Add(60 * 24 * time.Hour) + cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"} + assert.Equal(t, "valid", certStatus(cert)) + }) + + t.Run("expired", func(t *testing.T) { + expiry := now.Add(-time.Hour) + cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"} + assert.Equal(t, "expired", certStatus(cert)) + }) + + t.Run("expiring soon", func(t *testing.T) { + expiry := now.Add(15 * 24 * time.Hour) // within 30d window + cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"} + assert.Equal(t, "expiring", certStatus(cert)) + }) + + t.Run("staging provider", func(t *testing.T) { + expiry := now.Add(60 * 24 * time.Hour) + cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "letsencrypt-staging"} + assert.Equal(t, "untrusted", certStatus(cert)) + }) + + t.Run("nil expiry", func(t *testing.T) { + cert := models.SSLCertificate{Provider: "custom"} + assert.Equal(t, "valid", certStatus(cert)) + }) +} + +// --- ListCertificates cache paths --- + +func TestListCertificates_InitializedAndStale(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + // First call initializes + certs1, err := cs.ListCertificates() + require.NoError(t, err) + assert.Empty(t, certs1) + + // Force stale but initialized + cs.cacheMu.Lock() + cs.initialized = true + cs.lastScan = time.Time{} // zero → stale + cs.cacheMu.Unlock() + + // Should still return (stale) cache and trigger background sync + certs2, err := cs.ListCertificates() + require.NoError(t, err) + assert.NotNil(t, certs2) +} + +func TestListCertificates_CacheFresh(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s_fresh?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + cs.cacheMu.Lock() + cs.initialized = true + cs.lastScan = time.Now() + cs.cache = []CertificateInfo{{Name: "cached"}} + cs.scanTTL = 5 * time.Minute + cs.cacheMu.Unlock() + + certs, err := cs.ListCertificates() + require.NoError(t, err) + require.Len(t, certs, 1) + assert.Equal(t, "cached", certs[0].Name) +} + +// --- ValidateCertificate extra branches --- + +func TestValidateCertificate_KeyMismatch(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + // Generate two separate cert/key pairs so key doesn't match cert + certPEM, _ := generateTestCertAndKey(t, "mismatch.example.com", time.Now().Add(24*time.Hour)) + _, keyPEM := generateTestCertAndKey(t, "other.example.com", time.Now().Add(24*time.Hour)) + + result, err := cs.ValidateCertificate(string(certPEM), string(keyPEM), "") + require.NoError(t, err) + // Key mismatch goes to Errors + found := false + for _, e := range result.Errors { + if strings.Contains(e, "mismatch") { + found = true + } + } + assert.True(t, found, "expected key mismatch error, got errors: %v, warnings: %v", result.Errors, result.Warnings) +} + +// --- UploadCertificate with encryption --- + +func TestUploadCertificate_WithEncryption(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertServiceWithEnc(t, tmpDir, db) + + certPEM, keyPEM := generateTestCertAndKey(t, "enc.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("encrypted-cert", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + assert.Equal(t, "encrypted-cert", info.Name) + + // Verify private key was encrypted in DB + var stored models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error) + assert.NotEmpty(t, stored.PrivateKeyEncrypted) + assert.Empty(t, stored.PrivateKey) // should not store plaintext +} + +// --- checkExpiry additional branches --- + +func TestCheckExpiry_NoNotificationService(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.NotificationProvider{})) + + cs := &CertificateService{ + dataDir: tmpDir, + db: db, + scanTTL: 5 * time.Minute, + } + // No notification service set — should not panic + cs.checkExpiry(context.Background(), nil, 30) +} + +// --- DeleteCertificate with backup service --- + +func TestDeleteCertificate_Success(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "delete.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("to-delete", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + err = cs.DeleteCertificate(info.UUID) + assert.NoError(t, err) + + // Verify deleted + _, err = cs.GetCertificate(info.UUID) + assert.ErrorIs(t, err, ErrCertNotFound) +} + +func TestDeleteCertificate_InUse(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "inuse.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("in-use-cert", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + // Find the cert and assign to a host + var stored models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error) + ph := models.ProxyHost{ + UUID: "ph-inuse", + Name: "InUse Host", + DomainNames: "inuse.example.com", + ForwardHost: "localhost", + ForwardPort: 8080, + CertificateID: &stored.ID, + } + require.NoError(t, db.Create(&ph).Error) + + err = cs.DeleteCertificate(info.UUID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "in use") +} + +// --- IsCertificateInUse --- + +func TestIsCertificateInUse(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + cert := models.SSLCertificate{ + UUID: "inuse-test", Name: "In Use Test", Provider: "custom", + Domains: "test.example.com", CommonName: "test.example.com", + } + require.NoError(t, db.Create(&cert).Error) + + t.Run("not in use", func(t *testing.T) { + inUse, err := cs.IsCertificateInUse(cert.ID) + require.NoError(t, err) + assert.False(t, inUse) + }) + + t.Run("in use", func(t *testing.T) { + ph := models.ProxyHost{ + UUID: "ph-check", Name: "Check Host", DomainNames: "test.example.com", + ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID, + } + require.NoError(t, db.Create(&ph).Error) + + inUse, err := cs.IsCertificateInUse(cert.ID) + require.NoError(t, err) + assert.True(t, inUse) + }) +} diff --git a/backend/internal/services/certificate_service_patch_coverage_test.go b/backend/internal/services/certificate_service_patch_coverage_test.go new file mode 100644 index 000000000..b063bc819 --- /dev/null +++ b/backend/internal/services/certificate_service_patch_coverage_test.go @@ -0,0 +1,596 @@ +package services + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// --- ExportCertificate DER format --- + +func TestExportCertificate_DER(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "der-export.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("der-export", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + data, filename, err := cs.ExportCertificate(info.UUID, "der", false, "") + require.NoError(t, err) + assert.NotEmpty(t, data) + assert.Contains(t, filename, ".der") +} + +// --- ExportCertificate PFX format --- + +func TestExportCertificate_PFX(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertServiceWithEnc(t, tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "pfx-export.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("pfx-export", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + data, filename, err := cs.ExportCertificate(info.UUID, "pfx", true, "test-password") + require.NoError(t, err) + assert.NotEmpty(t, data) + assert.Contains(t, filename, ".pfx") +} + +func TestExportCertificate_P12(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertServiceWithEnc(t, tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "p12-export.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("p12-export", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + data, filename, err := cs.ExportCertificate(info.UUID, "p12", true, "password") + require.NoError(t, err) + assert.NotEmpty(t, data) + assert.Contains(t, filename, ".pfx") +} + +func TestExportCertificate_UnsupportedFormat(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "unsupported.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("unsupported-fmt", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + _, _, err = cs.ExportCertificate(info.UUID, "xml", false, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported export format") +} + +func TestExportCertificate_PEMWithKey(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertServiceWithEnc(t, tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "pem-key.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("pem-key-export", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + data, filename, err := cs.ExportCertificate(info.UUID, "pem", true, "") + require.NoError(t, err) + assert.Contains(t, string(data), "PRIVATE KEY") + assert.Contains(t, filename, ".pem") +} + +func TestExportCertificate_NotFound(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + _, _, err = cs.ExportCertificate("nonexistent-uuid", "pem", false, "") + assert.ErrorIs(t, err, ErrCertNotFound) +} + +// --- GetDecryptedPrivateKey --- + +func TestGetDecryptedPrivateKey_NoEncryptedKey(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + cert := &models.SSLCertificate{PrivateKeyEncrypted: ""} + _, err = cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no encrypted private key") +} + +func TestGetDecryptedPrivateKey_NoEncryptionService(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) // no encSvc + cert := &models.SSLCertificate{PrivateKeyEncrypted: "some-encrypted-data"} + _, err = cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "encryption service not configured") +} + +// --- MigratePrivateKeys --- + +func TestMigratePrivateKeys_NoEncryptionService(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + err = cs.MigratePrivateKeys() + assert.NoError(t, err) // should return nil without error +} + +func TestMigratePrivateKeys_NoCertsToMigrate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + // MigratePrivateKeys uses raw SQL against private_key column (gorm:"-"), so add it manually + db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''") + + cs := newTestCertServiceWithEnc(t, tmpDir, db) + err = cs.MigratePrivateKeys() + assert.NoError(t, err) +} + +func TestMigratePrivateKeys_WithPlaintextKey(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + // MigratePrivateKeys uses raw SQL against private_key column (gorm:"-"), so add it manually + db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''") + cs := newTestCertServiceWithEnc(t, tmpDir, db) + _, keyPEM := generateTestCertAndKey(t, "migrate.example.com", time.Now().Add(24*time.Hour)) + + // Insert a cert with plaintext private_key via raw SQL + db.Exec("INSERT INTO ssl_certificates (uuid, name, provider, domains, common_name, private_key) VALUES (?, ?, ?, ?, ?, ?)", + "migrate-uuid", "Migrate Test", "custom", "migrate.example.com", "migrate.example.com", string(keyPEM)) + + err = cs.MigratePrivateKeys() + assert.NoError(t, err) + + // Verify the key was encrypted + var encKey string + db.Raw("SELECT private_key_enc FROM ssl_certificates WHERE uuid = ?", "migrate-uuid").Scan(&encKey) + assert.NotEmpty(t, encKey) + + // Verify plaintext key was cleared + var plainKey string + db.Raw("SELECT private_key FROM ssl_certificates WHERE uuid = ?", "migrate-uuid").Scan(&plainKey) + assert.Empty(t, plainKey) +} + +// --- DeleteCertificateByID --- + +func TestDeleteCertificateByID_Success(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "byid.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("by-id-delete", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + var stored models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error) + + err = cs.DeleteCertificateByID(stored.ID) + assert.NoError(t, err) +} + +func TestDeleteCertificateByID_NotFound(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + err = cs.DeleteCertificateByID(99999) + assert.Error(t, err) +} + +// --- UpdateCertificate --- + +func TestUpdateCertificate_Success(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "update.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("old-name", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + updated, err := cs.UpdateCertificate(info.UUID, "new-name") + require.NoError(t, err) + assert.Equal(t, "new-name", updated.Name) +} + +func TestUpdateCertificate_NotFound(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + _, err = cs.UpdateCertificate("nonexistent", "name") + assert.ErrorIs(t, err, ErrCertNotFound) +} + +// --- IsCertificateInUseByUUID --- + +func TestIsCertificateInUseByUUID_NotFound(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + _, err = cs.IsCertificateInUseByUUID("nonexistent-uuid") + assert.ErrorIs(t, err, ErrCertNotFound) +} + +func TestIsCertificateInUseByUUID_NotInUse(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + certPEM, keyPEM := generateTestCertAndKey(t, "inuse-uuid.example.com", time.Now().Add(24*time.Hour)) + info, err := cs.UploadCertificate("uuid-inuse-test", string(certPEM), string(keyPEM), "") + require.NoError(t, err) + + inUse, err := cs.IsCertificateInUseByUUID(info.UUID) + require.NoError(t, err) + assert.False(t, inUse) +} + +// --- CheckExpiringCertificates --- + +func TestCheckExpiringCertificates_WithExpiring(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + // Create a cert expiring in 10 days + expiry := time.Now().Add(10 * 24 * time.Hour) + notBefore := time.Now().Add(-24 * time.Hour) + db.Create(&models.SSLCertificate{ + UUID: "expiring-uuid", Name: "Expiring Cert", Provider: "custom", + Domains: "expiring.example.com", CommonName: "expiring.example.com", + ExpiresAt: &expiry, NotBefore: ¬Before, + }) + + certs, err := cs.CheckExpiringCertificates(30) + require.NoError(t, err) + assert.Len(t, certs, 1) + assert.Equal(t, "Expiring Cert", certs[0].Name) + assert.Equal(t, "expiring", certs[0].Status) +} + +func TestCheckExpiringCertificates_WithExpired(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + expiry := time.Now().Add(-24 * time.Hour) + db.Create(&models.SSLCertificate{ + UUID: "expired-uuid", Name: "Expired Cert", Provider: "custom", + Domains: "expired.example.com", CommonName: "expired.example.com", + ExpiresAt: &expiry, + }) + + certs, err := cs.CheckExpiringCertificates(30) + require.NoError(t, err) + assert.Len(t, certs, 1) + assert.Equal(t, "expired", certs[0].Status) +} + +func TestCheckExpiringCertificates_NoneExpiring(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + // Cert expiring in 90 days - outside 30 day window + expiry := time.Now().Add(90 * 24 * time.Hour) + db.Create(&models.SSLCertificate{ + UUID: "valid-uuid", Name: "Valid Cert", Provider: "custom", + Domains: "valid.example.com", CommonName: "valid.example.com", + ExpiresAt: &expiry, + }) + + certs, err := cs.CheckExpiringCertificates(30) + require.NoError(t, err) + assert.Empty(t, certs) +} + +// --- checkExpiry with notification service --- + +func TestCheckExpiry_WithExpiringCerts(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.SSLCertificate{}, &models.ProxyHost{}, + &models.Setting{}, &models.NotificationProvider{}, + &models.Notification{}, + )) + + cs := newTestCertificateService(tmpDir, db) + + // Create expiring cert + expiry := time.Now().Add(10 * 24 * time.Hour) + db.Create(&models.SSLCertificate{ + UUID: "notify-expiring", Name: "Notify Cert", Provider: "custom", + Domains: "notify.example.com", CommonName: "notify.example.com", + ExpiresAt: &expiry, + }) + + notifSvc := NewNotificationService(db, nil) + cs.checkExpiry(context.Background(), notifSvc, 30) + + // Verify a notification was created + var count int64 + db.Model(&models.Notification{}).Count(&count) + assert.Greater(t, count, int64(0)) +} + +func TestCheckExpiry_WithExpiredCerts(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.SSLCertificate{}, &models.ProxyHost{}, + &models.Setting{}, &models.NotificationProvider{}, + &models.Notification{}, + )) + + cs := newTestCertificateService(tmpDir, db) + + expiry := time.Now().Add(-24 * time.Hour) + db.Create(&models.SSLCertificate{ + UUID: "notify-expired", Name: "Expired Notify", Provider: "custom", + Domains: "expired-notify.example.com", CommonName: "expired-notify.example.com", + ExpiresAt: &expiry, + }) + + notifSvc := NewNotificationService(db, nil) + cs.checkExpiry(context.Background(), notifSvc, 30) + + var count int64 + db.Model(&models.Notification{}).Count(&count) + assert.Greater(t, count, int64(0)) +} + +// --- ListCertificates with chain and proxy host --- + +func TestListCertificates_WithChainAndProxyHost(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + chainPEM := certPEM + "\n" + certPEM + + expiry := time.Now().Add(90 * 24 * time.Hour) + notBefore := time.Now().Add(-1 * time.Hour) + certID := uint(99) + db.Create(&models.SSLCertificate{ + ID: certID, + UUID: "chain-test-uuid", + Name: "Chain Test", + Provider: "custom", + Domains: "chain.example.com", + CommonName: "chain.example.com", + Certificate: certPEM, + CertificateChain: chainPEM, + ExpiresAt: &expiry, + NotBefore: ¬Before, + }) + + db.Create(&models.ProxyHost{ + Name: "My Proxy", + DomainNames: "chain.example.com", + CertificateID: &certID, + }) + + certs, err := cs.ListCertificates() + require.NoError(t, err) + require.Len(t, certs, 1) + assert.Equal(t, 2, certs[0].ChainDepth) + assert.True(t, certs[0].InUse) + assert.Equal(t, "chain-test-uuid", certs[0].UUID) +} + +// --- UploadCertificate with key --- + +func TestUploadCertificate_WithKey(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertServiceWithEnc(t, tmpDir, db) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + info, err := cs.UploadCertificate("My Upload", certPEM, keyPEM, "") + require.NoError(t, err) + require.NotNil(t, info) + assert.Equal(t, "My Upload", info.Name) + assert.True(t, info.HasKey) + assert.NotEmpty(t, info.UUID) + assert.Equal(t, "custom", info.Provider) +} + +// --- ValidateCertificate with key match --- + +func TestValidateCertificate_WithKeyMatch(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + result, err := cs.ValidateCertificate(certPEM, keyPEM, "") + require.NoError(t, err) + assert.True(t, result.Valid) + assert.True(t, result.KeyMatch) + assert.Empty(t, result.Errors) + assert.Contains(t, result.Warnings, "certificate could not be verified against system roots") +} + +// --- UpdateCertificate with chain depth --- + +func TestUpdateCertificate_WithChainDepth(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + chainPEM := certPEM + "\n" + certPEM + "\n" + certPEM + + expiry := time.Now().Add(90 * 24 * time.Hour) + db.Create(&models.SSLCertificate{ + UUID: "update-chain-uuid", + Name: "Chain Update", + Provider: "custom", + Domains: "update-chain.example.com", + CommonName: "update-chain.example.com", + Certificate: certPEM, + CertificateChain: chainPEM, + ExpiresAt: &expiry, + }) + + info, err := cs.UpdateCertificate("update-chain-uuid", "Renamed Chain") + require.NoError(t, err) + assert.Equal(t, "Renamed Chain", info.Name) + assert.Equal(t, 3, info.ChainDepth) +} + +// --- ExportCertificate PEM with chain --- + +func TestExportCertificate_PEMWithChain(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertServiceWithEnc(t, tmpDir, db) + + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + encSvc := newTestEncryptionService(t) + encKey, err := encSvc.Encrypt([]byte(keyPEM)) + require.NoError(t, err) + + chainPEM := certPEM + + db.Create(&models.SSLCertificate{ + UUID: "export-chain-uuid", + Name: "Export Chain", + Provider: "custom", + Domains: "export-chain.example.com", + CommonName: "export-chain.example.com", + Certificate: certPEM, + CertificateChain: chainPEM, + PrivateKeyEncrypted: encKey, + }) + + data, filename, err := cs.ExportCertificate("export-chain-uuid", "pem", true, "") + require.NoError(t, err) + assert.Equal(t, "Export Chain.pem", filename) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + assert.Contains(t, string(data), "BEGIN") +} diff --git a/backend/internal/services/certificate_service_sync_coverage_test.go b/backend/internal/services/certificate_service_sync_coverage_test.go new file mode 100644 index 000000000..a3a5db6e3 --- /dev/null +++ b/backend/internal/services/certificate_service_sync_coverage_test.go @@ -0,0 +1,236 @@ +package services + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +func TestSyncFromDisk_StagingToProductionUpgrade(t *testing.T) { + tmpDir := t.TempDir() + certRoot := filepath.Join(tmpDir, "certificates") + require.NoError(t, os.MkdirAll(certRoot, 0755)) + + domain := "staging-upgrade.example.com" + certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour)) + + certFile := filepath.Join(certRoot, domain+".crt") + require.NoError(t, os.WriteFile(certFile, certPEM, 0600)) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + existing := models.SSLCertificate{ + UUID: uuid.New().String(), + Name: domain, + Provider: "letsencrypt-staging", + Domains: domain, + Certificate: "old-content", + } + require.NoError(t, db.Create(&existing).Error) + + svc := newTestCertificateService(tmpDir, db) + require.NoError(t, svc.SyncFromDisk()) + + var updated models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", existing.UUID).First(&updated).Error) + assert.Equal(t, "letsencrypt", updated.Provider) +} + +func TestSyncFromDisk_ExpiryOnlyUpdate(t *testing.T) { + tmpDir := t.TempDir() + certRoot := filepath.Join(tmpDir, "certificates") + require.NoError(t, os.MkdirAll(certRoot, 0755)) + + domain := "expiry-only.example.com" + certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour)) + + certFile := filepath.Join(certRoot, domain+".crt") + require.NoError(t, os.WriteFile(certFile, certPEM, 0600)) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + existing := models.SSLCertificate{ + UUID: uuid.New().String(), + Name: domain, + Provider: "letsencrypt", + Domains: domain, + Certificate: string(certPEM), // identical content + } + require.NoError(t, db.Create(&existing).Error) + + svc := newTestCertificateService(tmpDir, db) + require.NoError(t, svc.SyncFromDisk()) + + var updated models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", existing.UUID).First(&updated).Error) + assert.Equal(t, "letsencrypt", updated.Provider) + assert.Equal(t, string(certPEM), updated.Certificate) +} + +func TestSyncFromDisk_CertRootStatPermissionError(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("cannot test permission error as root") + } + + tmpDir := t.TempDir() + certRoot := filepath.Join(tmpDir, "certificates") + require.NoError(t, os.MkdirAll(certRoot, 0755)) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + // Restrict parent dir so os.Stat(certRoot) fails with permission error + require.NoError(t, os.Chmod(tmpDir, 0)) + defer func() { _ = os.Chmod(tmpDir, 0755) }() + + svc := newTestCertificateService(tmpDir, db) + err = svc.SyncFromDisk() + require.NoError(t, err) +} + +func TestListCertificates_StaleCache_TriggersBackgroundSync(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + svc := newTestCertificateService(tmpDir, db) + + // Simulate stale cache + svc.cacheMu.Lock() + svc.initialized = true + svc.lastScan = time.Now().Add(-10 * time.Minute) + before := svc.lastScan + svc.cacheMu.Unlock() + + _, err = svc.ListCertificates() + require.NoError(t, err) + + // Background goroutine should update lastScan via SyncFromDisk + require.Eventually(t, func() bool { + svc.cacheMu.RLock() + defer svc.cacheMu.RUnlock() + return svc.lastScan.After(before) + }, 2*time.Second, 10*time.Millisecond) +} + +func TestGetDecryptedPrivateKey_DecryptFails(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + svc := newTestCertServiceWithEnc(t, tmpDir, db) + + cert := models.SSLCertificate{ + UUID: uuid.New().String(), + Name: "enc-fail", + Domains: "encfail.example.com", + Provider: "custom", + PrivateKeyEncrypted: "corrupted-ciphertext", + } + require.NoError(t, db.Create(&cert).Error) + + _, err = svc.GetDecryptedPrivateKey(&cert) + assert.Error(t, err) +} + +func TestDeleteCertificate_LetsEncryptProvider_FileCleanup(t *testing.T) { + tmpDir := t.TempDir() + certRoot := filepath.Join(tmpDir, "certificates") + require.NoError(t, os.MkdirAll(certRoot, 0755)) + + domain := "le-cleanup.example.com" + certFile := filepath.Join(certRoot, domain+".crt") + keyFile := filepath.Join(certRoot, domain+".key") + jsonFile := filepath.Join(certRoot, domain+".json") + + certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour)) + require.NoError(t, os.WriteFile(certFile, certPEM, 0600)) + require.NoError(t, os.WriteFile(keyFile, []byte("key"), 0600)) + require.NoError(t, os.WriteFile(jsonFile, []byte("{}"), 0600)) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + cert := models.SSLCertificate{ + UUID: certUUID, + Name: domain, + Provider: "letsencrypt", + Domains: domain, + } + require.NoError(t, db.Create(&cert).Error) + + svc := newTestCertificateService(tmpDir, db) + require.NoError(t, svc.DeleteCertificate(certUUID)) + + assert.NoFileExists(t, certFile) + assert.NoFileExists(t, keyFile) + assert.NoFileExists(t, jsonFile) +} + +func TestDeleteCertificate_StagingProvider_FileCleanup(t *testing.T) { + tmpDir := t.TempDir() + certRoot := filepath.Join(tmpDir, "certificates") + require.NoError(t, os.MkdirAll(certRoot, 0755)) + + domain := "le-staging-cleanup.example.com" + certFile := filepath.Join(certRoot, domain+".crt") + + certPEM, _ := generateTestCertAndKey(t, domain, time.Now().Add(24*time.Hour)) + require.NoError(t, os.WriteFile(certFile, certPEM, 0600)) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + certUUID := uuid.New().String() + cert := models.SSLCertificate{ + UUID: certUUID, + Name: domain, + Provider: "letsencrypt-staging", + Domains: domain, + } + require.NoError(t, db.Create(&cert).Error) + + svc := newTestCertificateService(tmpDir, db) + require.NoError(t, svc.DeleteCertificate(certUUID)) + + assert.NoFileExists(t, certFile) +} + +func TestCheckExpiringCertificates_DBError(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + // deliberately do NOT AutoMigrate SSLCertificate + + svc := newTestCertificateService(tmpDir, db) + _, err = svc.CheckExpiringCertificates(30) + assert.Error(t, err) +} diff --git a/backend/internal/services/certificate_service_test.go b/backend/internal/services/certificate_service_test.go index c0336b925..d45b4291b 100644 --- a/backend/internal/services/certificate_service_test.go +++ b/backend/internal/services/certificate_service_test.go @@ -31,6 +31,14 @@ func newTestCertificateService(dataDir string, db *gorm.DB) *CertificateService } } +// certDBID looks up the numeric DB primary key for a certificate by UUID. +func certDBID(t *testing.T, db *gorm.DB, uuid string) uint { + t.Helper() + var cert models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", uuid).First(&cert).Error) + return cert.ID +} + func TestNewCertificateService(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) @@ -43,7 +51,7 @@ func TestNewCertificateService(t *testing.T) { require.NoError(t, os.MkdirAll(certDir, 0o750)) // #nosec G301 -- test directory // Test service creation - svc := NewCertificateService(tmpDir, db) + svc := NewCertificateService(tmpDir, db, nil) assert.NotNil(t, svc) assert.Equal(t, tmpDir, svc.dataDir) assert.Equal(t, db, svc.db) @@ -54,6 +62,11 @@ func TestNewCertificateService(t *testing.T) { } func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte { + certPEM, _ := generateTestCertAndKey(t, domain, expiry) + return certPEM +} + +func generateTestCertAndKey(t *testing.T, domain string, expiry time.Time) ([]byte, []byte) { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate private key: %v", err) @@ -77,7 +90,9 @@ func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte { t.Fatalf("Failed to create certificate: %v", err) } - return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return certPEM, keyPEM } func TestCertificateService_GetCertificateInfo(t *testing.T) { @@ -123,7 +138,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) { assert.NoError(t, err) assert.Len(t, certs, 1) if len(certs) > 0 { - assert.Equal(t, domain, certs[0].Domain) + assert.Equal(t, domain, certs[0].Domains) assert.Equal(t, "valid", certs[0].Status) // Check expiry within a margin assert.WithinDuration(t, expiry, certs[0].ExpiresAt, time.Second) @@ -153,7 +168,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) { // Find the expired one var foundExpired bool for _, c := range certs { - if c.Domain == expiredDomain { + if c.Domains == expiredDomain { assert.Equal(t, "expired", c.Status) foundExpired = true } @@ -174,11 +189,10 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { // Generate Cert domain := "custom.example.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) // Test Upload - cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM), "") require.NoError(t, err) assert.NotNil(t, cert) assert.Equal(t, "My Custom Cert", cert.Name) @@ -190,7 +204,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { require.NoError(t, err) var found bool for _, c := range certs { - if c.ID == cert.ID { + if c.UUID == cert.UUID { found = true assert.Equal(t, "custom", c.Provider) break @@ -199,7 +213,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { assert.True(t, found) // Test Delete - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) require.NoError(t, err) // Verify it's gone @@ -207,7 +221,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) { require.NoError(t, err) found = false for _, c := range certs { - if c.ID == cert.ID { + if c.UUID == cert.UUID { found = true break } @@ -248,7 +262,7 @@ func TestCertificateService_Persistence(t *testing.T) { // Verify it's in the returned list var foundInList bool for _, c := range certs { - if c.Domain == domain { + if c.Domains == domain { foundInList = true assert.Equal(t, "letsencrypt", c.Provider) break @@ -264,7 +278,7 @@ func TestCertificateService_Persistence(t *testing.T) { assert.Equal(t, string(certPEM), dbCert.Certificate) // 4. Delete the certificate via Service (which should delete the file) - err = cs.DeleteCertificate(dbCert.ID) + err = cs.DeleteCertificate(dbCert.UUID) require.NoError(t, err) // Verify file is gone @@ -278,7 +292,7 @@ func TestCertificateService_Persistence(t *testing.T) { // Verify it's NOT in the returned list foundInList = false for _, c := range certs { - if c.Domain == domain { + if c.Domains == domain { foundInList = true break } @@ -301,14 +315,14 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) { cs := newTestCertificateService(tmpDir, db) t.Run("invalid PEM format", func(t *testing.T) { - cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid") + cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid", "") assert.Error(t, err) assert.Nil(t, cert) - assert.Contains(t, err.Error(), "invalid certificate PEM") + assert.Contains(t, err.Error(), "unrecognized certificate format") }) t.Run("empty certificate", func(t *testing.T) { - cert, err := cs.UploadCertificate("Empty", "", "some-key") + cert, err := cs.UploadCertificate("Empty", "", "some-key", "") assert.Error(t, err) assert.Nil(t, cert) }) @@ -318,19 +332,18 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) { expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("No Key", string(certPEM), "") + cert, err := cs.UploadCertificate("No Key", string(certPEM), "", "") assert.NoError(t, err) // Uploading without key is allowed assert.NotNil(t, cert) - assert.Equal(t, "", cert.PrivateKey) + assert.False(t, cert.HasKey) }) t.Run("valid certificate with name", func(t *testing.T) { domain := "valid.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) - cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM), "") assert.NoError(t, err) assert.NotNil(t, cert) assert.Equal(t, "Valid Cert", cert.Name) @@ -341,10 +354,9 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) { t.Run("expired certificate can be uploaded", func(t *testing.T) { domain := "expired-upload.com" expiry := time.Now().Add(-24 * time.Hour) // Already expired - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) - cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM), "") // Should still upload successfully, but status will be expired assert.NoError(t, err) assert.NotNil(t, cert) @@ -430,7 +442,7 @@ func TestCertificateService_ListCertificates_EdgeCases(t *testing.T) { domain2 := "custom.example.com" expiry2 := time.Now().Add(48 * time.Hour) certPEM2 := generateTestCert(t, domain2, expiry2) - _, err = cs.UploadCertificate("Custom", string(certPEM2), "FAKE KEY") + _, err = cs.UploadCertificate("Custom", string(certPEM2), "", "") require.NoError(t, err) certs, err := cs.ListCertificates() @@ -457,20 +469,22 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) { cs := newTestCertificateService(tmpDir, db) t.Run("delete non-existent certificate", func(t *testing.T) { - // IsCertificateInUse will succeed (not in use), then First will fail - err := cs.DeleteCertificate(99999) + // DeleteCertificate takes UUID string; non-existent UUID returns error + err := cs.DeleteCertificate("non-existent-uuid") assert.Error(t, err) - assert.Equal(t, gorm.ErrRecordNotFound, err) }) t.Run("delete certificate in use returns ErrCertInUse", func(t *testing.T) { // Create certificate domain := "in-use.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("In Use", string(certPEM), "FAKE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) + cert, err := cs.UploadCertificate("In Use", string(certPEM), string(keyPEM), "") require.NoError(t, err) + // Look up numeric ID for FK + dbID := certDBID(t, db, cert.UUID) + // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "test-ph", @@ -478,18 +492,18 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) { DomainNames: "in-use.com", ForwardHost: "localhost", ForwardPort: 8080, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) // Attempt to delete certificate - should fail with ErrCertInUse - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) assert.Error(t, err) assert.Equal(t, ErrCertInUse, err) // Verify certificate still exists var dbCert models.SSLCertificate - err = db.First(&dbCert, "id = ?", cert.ID).Error + err = db.First(&dbCert, "id = ?", dbID).Error assert.NoError(t, err) }) @@ -497,21 +511,24 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) { // Create and upload cert domain := "to-delete.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("To Delete", string(certPEM), "FAKE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) + cert, err := cs.UploadCertificate("To Delete", string(certPEM), string(keyPEM), "") require.NoError(t, err) + // Look up numeric ID for verification + dbID := certDBID(t, db, cert.UUID) + // Manually remove the file (custom certs stored by numeric ID) certPath := filepath.Join(tmpDir, "certificates", "custom", "cert.crt") _ = os.Remove(certPath) // Delete should still work (DB cleanup) - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) assert.NoError(t, err) // Verify DB record is gone var dbCert models.SSLCertificate - err = db.First(&dbCert, "id = ?", cert.ID).Error + err = db.First(&dbCert, "id = ?", dbID).Error assert.Error(t, err) }) } @@ -781,9 +798,8 @@ func TestCertificateService_CertificateWithSANs(t *testing.T) { domain := "san.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCertWithSANs(t, domain, []string{"san.example.com", "www.san.example.com", "api.san.example.com"}, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") - cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), "", "") require.NoError(t, err) assert.NotNil(t, cert) // Should have joined SANs @@ -807,10 +823,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "unused.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Unused", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Unused", string(certPEM), "", "") require.NoError(t, err) - inUse, err := cs.IsCertificateInUse(cert.ID) + dbID := certDBID(t, db, cert.UUID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.False(t, inUse) }) @@ -820,9 +837,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "used.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Used", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Used", string(certPEM), "", "") require.NoError(t, err) + dbID := certDBID(t, db, cert.UUID) + // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "ph-1", @@ -830,11 +849,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { DomainNames: "used.com", ForwardHost: "localhost", ForwardPort: 8080, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) - inUse, err := cs.IsCertificateInUse(cert.ID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.True(t, inUse) }) @@ -844,9 +863,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "shared.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Shared", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Shared", string(certPEM), "", "") require.NoError(t, err) + dbID := certDBID(t, db, cert.UUID) + // Create multiple proxy hosts using this certificate for i := 1; i <= 3; i++ { ph := models.ProxyHost{ @@ -855,12 +876,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { DomainNames: fmt.Sprintf("host%d.shared.com", i), ForwardHost: "localhost", ForwardPort: 8080 + i, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) } - inUse, err := cs.IsCertificateInUse(cert.ID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.True(t, inUse) }) @@ -876,9 +897,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { domain := "freed.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) - cert, err := cs.UploadCertificate("Freed", string(certPEM), "FAKE KEY") + cert, err := cs.UploadCertificate("Freed", string(certPEM), "", "") require.NoError(t, err) + dbID := certDBID(t, db, cert.UUID) + // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "ph-freed", @@ -886,12 +909,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { DomainNames: "freed.com", ForwardHost: "localhost", ForwardPort: 8080, - CertificateID: &cert.ID, + CertificateID: &dbID, } require.NoError(t, db.Create(&ph).Error) // Verify in use - inUse, err := cs.IsCertificateInUse(cert.ID) + inUse, err := cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.True(t, inUse) @@ -899,12 +922,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) { require.NoError(t, db.Delete(&ph).Error) // Verify no longer in use - inUse, err = cs.IsCertificateInUse(cert.ID) + inUse, err = cs.IsCertificateInUse(dbID) assert.NoError(t, err) assert.False(t, inUse) // Now deletion should succeed - err = cs.DeleteCertificate(cert.ID) + err = cs.DeleteCertificate(cert.UUID) assert.NoError(t, err) }) } @@ -922,10 +945,9 @@ func TestCertificateService_CacheBehavior(t *testing.T) { // Create a cert domain := "cache.example.com" expiry := time.Now().Add(24 * time.Hour) - certPEM := generateTestCert(t, domain, expiry) - keyPEM := []byte("FAKE PRIVATE KEY") + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) - cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM)) + cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM), "") require.NoError(t, err) require.NotNil(t, cert) @@ -940,7 +962,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) { require.Len(t, certs2, 1) // Both should return the same cert - assert.Equal(t, certs1[0].ID, certs2[0].ID) + assert.Equal(t, certs1[0].UUID, certs2[0].UUID) }) t.Run("invalidate cache forces resync", func(t *testing.T) { @@ -954,7 +976,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) { // Create a cert via upload (auto-invalidates) certPEM := generateTestCert(t, "invalidate.example.com", time.Now().Add(24*time.Hour)) - _, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "") + _, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "", "") require.NoError(t, err) // Get list (should have 1) @@ -1012,7 +1034,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) { certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) - assert.Equal(t, "db.example.com", certs[0].Domain) + assert.Equal(t, "db.example.com", certs[0].Domains) }) } @@ -1032,7 +1054,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -----END CERTIFICATE-----` - cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "") + cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "", "") assert.Error(t, err) assert.Nil(t, cert) assert.Contains(t, err.Error(), "failed to parse certificate") @@ -1047,7 +1069,7 @@ A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl -----END PRIVATE KEY-----` - cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "") + cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "", "") assert.Error(t, err) assert.Nil(t, cert) assert.Contains(t, err.Error(), "failed to parse certificate") @@ -1070,7 +1092,7 @@ hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl require.NoError(t, err) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "") + cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "", "") assert.NoError(t, err) // Upload succeeds assert.NotNil(t, cert) assert.Equal(t, "", cert.Domains) // Empty domains field @@ -1165,7 +1187,7 @@ func TestCertificateService_SyncFromDisk_ErrorHandling(t *testing.T) { certs, err := cs.ListCertificates() assert.NoError(t, err) assert.Len(t, certs, 1) - assert.Equal(t, validDomain, certs[0].Domain) + assert.Equal(t, validDomain, certs[0].Domains) }) } @@ -1233,7 +1255,7 @@ func TestCertificateService_RefreshCacheFromDB_EdgeCases(t *testing.T) { require.Len(t, certs, 1) // Should use proxy host name assert.Equal(t, "Matched Proxy", certs[0].Name) - assert.Contains(t, certs[0].Domain, "www.example.com") + assert.Contains(t, certs[0].Domains, "www.example.com") }) } diff --git a/backend/internal/services/certificate_validator.go b/backend/internal/services/certificate_validator.go new file mode 100644 index 000000000..86455bfd2 --- /dev/null +++ b/backend/internal/services/certificate_validator.go @@ -0,0 +1,524 @@ +package services + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "strings" + "time" + + "software.sslmate.com/src/go-pkcs12" +) + +// CertFormat represents a certificate file format. +type CertFormat string + +const ( + FormatPEM CertFormat = "pem" + FormatDER CertFormat = "der" + FormatPFX CertFormat = "pfx" + FormatUnknown CertFormat = "unknown" +) + +// ParsedCertificate contains the parsed result of certificate input. +type ParsedCertificate struct { + Leaf *x509.Certificate + Intermediates []*x509.Certificate + PrivateKey crypto.PrivateKey + CertPEM string + KeyPEM string + ChainPEM string + Format CertFormat +} + +// CertificateMetadata contains extracted metadata from an x509 certificate. +type CertificateMetadata struct { + CommonName string + Domains []string + Fingerprint string + SerialNumber string + IssuerOrg string + KeyType string + NotBefore time.Time + NotAfter time.Time +} + +// ValidationResult contains the result of a certificate validation. +type ValidationResult struct { + Valid bool `json:"valid"` + CommonName string `json:"common_name"` + Domains []string `json:"domains"` + IssuerOrg string `json:"issuer_org"` + ExpiresAt time.Time `json:"expires_at"` + KeyMatch bool `json:"key_match"` + ChainValid bool `json:"chain_valid"` + ChainDepth int `json:"chain_depth"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` +} + +// DetectFormat determines the certificate format from raw file content. +// Uses trial-parse strategy: PEM → PFX → DER. +func DetectFormat(data []byte) CertFormat { + block, _ := pem.Decode(data) + if block != nil { + return FormatPEM + } + + if _, _, _, err := pkcs12.DecodeChain(data, ""); err == nil { + return FormatPFX + } + // PFX with empty password failed, but it could be password-protected + // If data starts with PKCS12 magic bytes (ASN.1 SEQUENCE), treat as PFX candidate + if len(data) > 2 && data[0] == 0x30 { + // Could be DER or PFX; try DER parse + if _, err := x509.ParseCertificate(data); err == nil { + return FormatDER + } + // If DER parse fails, it's likely PFX + return FormatPFX + } + + if _, err := x509.ParseCertificate(data); err == nil { + return FormatDER + } + + return FormatUnknown +} + +// ParseCertificateInput handles PEM, PFX, and DER input parsing. +func ParseCertificateInput(certData []byte, keyData []byte, chainData []byte, pfxPassword string) (*ParsedCertificate, error) { + if len(certData) == 0 { + return nil, fmt.Errorf("certificate data is empty") + } + + format := DetectFormat(certData) + + switch format { + case FormatPEM: + return parsePEMInput(certData, keyData, chainData) + case FormatPFX: + return parsePFXInput(certData, pfxPassword) + case FormatDER: + return parseDERInput(certData, keyData) + default: + return nil, fmt.Errorf("unrecognized certificate format") + } +} + +func parsePEMInput(certData []byte, keyData []byte, chainData []byte) (*ParsedCertificate, error) { + result := &ParsedCertificate{Format: FormatPEM} + + // Parse leaf certificate + certs, err := parsePEMCertificates(certData) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate PEM: %w", err) + } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found in PEM data") + } + + result.Leaf = certs[0] + result.CertPEM = string(certData) + + // If certData contains multiple certs, treat extras as intermediates + if len(certs) > 1 { + result.Intermediates = certs[1:] + } + + // Parse chain file if provided + if len(chainData) > 0 { + chainCerts, err := parsePEMCertificates(chainData) + if err != nil { + return nil, fmt.Errorf("failed to parse chain PEM: %w", err) + } + result.Intermediates = append(result.Intermediates, chainCerts...) + result.ChainPEM = string(chainData) + } + + // Build chain PEM from intermediates if not set from chain file + if result.ChainPEM == "" && len(result.Intermediates) > 0 { + var chainBuilder strings.Builder + for _, ic := range result.Intermediates { + if err := pem.Encode(&chainBuilder, &pem.Block{Type: "CERTIFICATE", Bytes: ic.Raw}); err != nil { + return nil, fmt.Errorf("failed to encode intermediate certificate: %w", err) + } + } + result.ChainPEM = chainBuilder.String() + } + + // Parse private key + if len(keyData) > 0 { + key, err := parsePEMPrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("failed to parse private key PEM: %w", err) + } + result.PrivateKey = key + result.KeyPEM = string(keyData) + } + + return result, nil +} + +func parsePFXInput(pfxData []byte, password string) (*ParsedCertificate, error) { + privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password) + if err != nil { + return nil, fmt.Errorf("failed to decode PFX/PKCS12: %w", err) + } + + result := &ParsedCertificate{ + Format: FormatPFX, + Leaf: leaf, + Intermediates: caCerts, + PrivateKey: privateKey, + } + + // Convert to PEM for storage + result.CertPEM = encodeCertToPEM(leaf) + + if len(caCerts) > 0 { + var chainBuilder strings.Builder + for _, ca := range caCerts { + chainBuilder.WriteString(encodeCertToPEM(ca)) + } + result.ChainPEM = chainBuilder.String() + } + + keyPEM, err := encodeKeyToPEM(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to encode private key to PEM: %w", err) + } + result.KeyPEM = keyPEM + + return result, nil +} + +func parseDERInput(certData []byte, keyData []byte) (*ParsedCertificate, error) { + cert, err := x509.ParseCertificate(certData) + if err != nil { + return nil, fmt.Errorf("failed to parse DER certificate: %w", err) + } + + result := &ParsedCertificate{ + Format: FormatDER, + Leaf: cert, + CertPEM: encodeCertToPEM(cert), + } + + if len(keyData) > 0 { + key, err := parsePEMPrivateKey(keyData) + if err != nil { + // Try DER key + key, err = x509.ParsePKCS8PrivateKey(keyData) + if err != nil { + key2, err2 := x509.ParseECPrivateKey(keyData) + if err2 != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + key = key2 + } + } + result.PrivateKey = key + keyPEM, err := encodeKeyToPEM(key) + if err != nil { + return nil, fmt.Errorf("failed to encode private key to PEM: %w", err) + } + result.KeyPEM = keyPEM + } + + return result, nil +} + +// ValidateKeyMatch checks that the private key matches the certificate public key. +func ValidateKeyMatch(cert *x509.Certificate, key crypto.PrivateKey) error { + if cert == nil { + return fmt.Errorf("certificate is nil") + } + if key == nil { + return fmt.Errorf("private key is nil") + } + + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + privKey, ok := key.(*rsa.PrivateKey) + if !ok { + return fmt.Errorf("key type mismatch: certificate has RSA public key but private key is not RSA") + } + if pub.N.Cmp(privKey.N) != 0 { + return fmt.Errorf("RSA key mismatch: certificate and private key modulus differ") + } + case *ecdsa.PublicKey: + privKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return fmt.Errorf("key type mismatch: certificate has ECDSA public key but private key is not ECDSA") + } + if pub.X.Cmp(privKey.X) != 0 || pub.Y.Cmp(privKey.Y) != 0 { + return fmt.Errorf("ECDSA key mismatch: certificate and private key points differ") + } + case ed25519.PublicKey: + privKey, ok := key.(ed25519.PrivateKey) + if !ok { + return fmt.Errorf("key type mismatch: certificate has Ed25519 public key but private key is not Ed25519") + } + pubFromPriv := privKey.Public().(ed25519.PublicKey) + if !pub.Equal(pubFromPriv) { + return fmt.Errorf("Ed25519 key mismatch: certificate and private key differ") + } + default: + return fmt.Errorf("unsupported public key type: %T", cert.PublicKey) + } + + return nil +} + +// ValidateChain verifies the certificate chain from leaf to root. +func ValidateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error { + if leaf == nil { + return fmt.Errorf("leaf certificate is nil") + } + + pool := x509.NewCertPool() + for _, ic := range intermediates { + pool.AddCert(ic) + } + + opts := x509.VerifyOptions{ + Intermediates: pool, + CurrentTime: time.Now(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + } + + if _, err := leaf.Verify(opts); err != nil { + return fmt.Errorf("chain verification failed: %w", err) + } + + return nil +} + +// ConvertDERToPEM converts DER-encoded certificate to PEM. +func ConvertDERToPEM(derData []byte) (string, error) { + cert, err := x509.ParseCertificate(derData) + if err != nil { + return "", fmt.Errorf("invalid DER data: %w", err) + } + return encodeCertToPEM(cert), nil +} + +// ConvertPFXToPEM extracts cert, key, and chain from PFX/PKCS12. +func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM string, chainPEM string, err error) { + privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password) + if err != nil { + return "", "", "", fmt.Errorf("failed to decode PFX: %w", err) + } + + certPEM = encodeCertToPEM(leaf) + + keyPEM, err = encodeKeyToPEM(privateKey) + if err != nil { + return "", "", "", fmt.Errorf("failed to encode key: %w", err) + } + + if len(caCerts) > 0 { + var builder strings.Builder + for _, ca := range caCerts { + builder.WriteString(encodeCertToPEM(ca)) + } + chainPEM = builder.String() + } + + return certPEM, keyPEM, chainPEM, nil +} + +// ConvertPEMToPFX bundles cert, key, chain into PFX. +func ConvertPEMToPFX(certPEM string, keyPEM string, chainPEM string, password string) ([]byte, error) { + certs, err := parsePEMCertificates([]byte(certPEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse cert PEM: %w", err) + } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found in cert PEM") + } + + key, err := parsePEMPrivateKey([]byte(keyPEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse key PEM: %w", err) + } + + var caCerts []*x509.Certificate + if chainPEM != "" { + caCerts, err = parsePEMCertificates([]byte(chainPEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse chain PEM: %w", err) + } + } + + pfxData, err := pkcs12.Modern.Encode(key, certs[0], caCerts, password) + if err != nil { + return nil, fmt.Errorf("failed to encode PFX: %w", err) + } + + return pfxData, nil +} + +// ConvertPEMToDER converts PEM certificate to DER. +func ConvertPEMToDER(certPEM string) ([]byte, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM") + } + // Verify it's a valid certificate + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + return nil, fmt.Errorf("invalid certificate PEM: %w", err) + } + return block.Bytes, nil +} + +// ExtractCertificateMetadata extracts fingerprint, serial, issuer, key type, etc. +func ExtractCertificateMetadata(cert *x509.Certificate) *CertificateMetadata { + if cert == nil { + return nil + } + + fingerprint := sha256.Sum256(cert.Raw) + fpHex := formatFingerprint(hex.EncodeToString(fingerprint[:])) + + serial := formatSerial(cert.SerialNumber) + + issuerOrg := "" + if len(cert.Issuer.Organization) > 0 { + issuerOrg = cert.Issuer.Organization[0] + } + + domains := make([]string, 0, len(cert.DNSNames)+1) + if cert.Subject.CommonName != "" { + domains = append(domains, cert.Subject.CommonName) + } + for _, san := range cert.DNSNames { + if san != cert.Subject.CommonName { + domains = append(domains, san) + } + } + + return &CertificateMetadata{ + CommonName: cert.Subject.CommonName, + Domains: domains, + Fingerprint: fpHex, + SerialNumber: serial, + IssuerOrg: issuerOrg, + KeyType: detectKeyType(cert), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + } +} + +// --- helpers --- + +func parsePEMCertificates(data []byte) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + rest := data + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + certs = append(certs, cert) + } + return certs, nil +} + +func parsePEMPrivateKey(data []byte) (crypto.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM data found") + } + + // Try PKCS8 first (handles RSA, ECDSA, Ed25519) + if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + return key, nil + } + + // Try PKCS1 RSA + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + + // Try EC + if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + return key, nil + } + + return nil, fmt.Errorf("unsupported private key format") +} + +func encodeCertToPEM(cert *x509.Certificate) string { + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})) +} + +func encodeKeyToPEM(key crypto.PrivateKey) (string, error) { + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", fmt.Errorf("failed to marshal private key: %w", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), nil +} + +func formatFingerprint(hex string) string { + var parts []string + for i := 0; i < len(hex); i += 2 { + end := i + 2 + if end > len(hex) { + end = len(hex) + } + parts = append(parts, strings.ToUpper(hex[i:end])) + } + return strings.Join(parts, ":") +} + +func formatSerial(n *big.Int) string { + if n == nil { + return "" + } + b := n.Bytes() + parts := make([]string, len(b)) + for i, v := range b { + parts[i] = fmt.Sprintf("%02X", v) + } + return strings.Join(parts, ":") +} + +func detectKeyType(cert *x509.Certificate) string { + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + bits := pub.N.BitLen() + return fmt.Sprintf("RSA-%d", bits) + case *ecdsa.PublicKey: + switch pub.Curve { + case elliptic.P256(): + return "ECDSA-P256" + case elliptic.P384(): + return "ECDSA-P384" + default: + return "ECDSA" + } + case ed25519.PublicKey: + return "Ed25519" + default: + return "Unknown" + } +} diff --git a/backend/internal/services/certificate_validator_coverage_test.go b/backend/internal/services/certificate_validator_coverage_test.go new file mode 100644 index 000000000..8aa927254 --- /dev/null +++ b/backend/internal/services/certificate_validator_coverage_test.go @@ -0,0 +1,324 @@ +package services + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "software.sslmate.com/src/go-pkcs12" +) + +// --- parsePFXInput --- + +func TestParsePFXInput(t *testing.T) { + cert, priv, _, _ := makeRSACertAndKey(t, "pfx.test", time.Now().Add(time.Hour)) + pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword) + require.NoError(t, err) + + t.Run("valid PFX", func(t *testing.T) { + parsed, err := parsePFXInput(pfxData, pkcs12.DefaultPassword) + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.NotNil(t, parsed.PrivateKey) + assert.Equal(t, FormatPFX, parsed.Format) + assert.Contains(t, parsed.CertPEM, "BEGIN CERTIFICATE") + assert.Contains(t, parsed.KeyPEM, "PRIVATE KEY") + }) + + t.Run("PFX with chain", func(t *testing.T) { + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(100), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + require.NoError(t, err) + caCert, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + + pfxWithChain, err := pkcs12.Modern.Encode(priv, cert, []*x509.Certificate{caCert}, pkcs12.DefaultPassword) + require.NoError(t, err) + + parsed, err := parsePFXInput(pfxWithChain, pkcs12.DefaultPassword) + require.NoError(t, err) + assert.NotEmpty(t, parsed.ChainPEM) + assert.Contains(t, parsed.ChainPEM, "BEGIN CERTIFICATE") + }) + + t.Run("invalid PFX data", func(t *testing.T) { + _, err := parsePFXInput([]byte("not-pfx"), "password") + assert.Error(t, err) + assert.Contains(t, err.Error(), "PFX") + }) + + t.Run("wrong password", func(t *testing.T) { + _, err := parsePFXInput(pfxData, "wrong-password") + assert.Error(t, err) + }) +} + +// --- parseDERInput --- + +func TestParseDERInput(t *testing.T) { + cert, priv, _, keyPEM := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour)) + + t.Run("DER cert only", func(t *testing.T) { + parsed, err := parseDERInput(cert.Raw, nil) + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.Equal(t, FormatDER, parsed.Format) + assert.Contains(t, parsed.CertPEM, "BEGIN CERTIFICATE") + assert.Nil(t, parsed.PrivateKey) + }) + + t.Run("DER cert with PEM key", func(t *testing.T) { + parsed, err := parseDERInput(cert.Raw, keyPEM) + require.NoError(t, err) + assert.NotNil(t, parsed.PrivateKey) + assert.Contains(t, parsed.KeyPEM, "PRIVATE KEY") + }) + + t.Run("DER cert with DER PKCS8 key", func(t *testing.T) { + derKey, err := x509.MarshalPKCS8PrivateKey(priv) + require.NoError(t, err) + parsed, err := parseDERInput(cert.Raw, derKey) + require.NoError(t, err) + assert.NotNil(t, parsed.PrivateKey) + }) + + t.Run("DER cert with DER EC key", func(t *testing.T) { + ecCert, ecPriv, _, _ := makeECDSACertAndKey(t, "ec-der.test") + ecDERKey, err := x509.MarshalECPrivateKey(ecPriv) + require.NoError(t, err) + parsed, err := parseDERInput(ecCert.Raw, ecDERKey) + require.NoError(t, err) + assert.NotNil(t, parsed.PrivateKey) + }) + + t.Run("DER cert with invalid key", func(t *testing.T) { + _, err := parseDERInput(cert.Raw, []byte("bad-key-data")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "private key") + }) + + t.Run("invalid DER cert data", func(t *testing.T) { + _, err := parseDERInput([]byte("not-der"), nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "DER certificate") + }) +} + +// --- parsePEMInput chain building --- + +func TestParsePEMInput_ChainBuilding(t *testing.T) { + t.Run("cert with intermediates in cert data", func(t *testing.T) { + _, _, certPEM1, _ := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour)) + _, _, certPEM2, _ := makeRSACertAndKey(t, "intermediate.test", time.Now().Add(time.Hour)) + combined := append(certPEM1, certPEM2...) + + parsed, err := parsePEMInput(combined, nil, nil) + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.Len(t, parsed.Intermediates, 1) + assert.NotEmpty(t, parsed.ChainPEM) + assert.Contains(t, parsed.ChainPEM, "BEGIN CERTIFICATE") + }) + + t.Run("cert with chain file", func(t *testing.T) { + _, _, certPEM, keyPEM := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour)) + _, _, chainPEM, _ := makeRSACertAndKey(t, "chain.test", time.Now().Add(time.Hour)) + + parsed, err := parsePEMInput(certPEM, keyPEM, chainPEM) + require.NoError(t, err) + assert.NotNil(t, parsed.PrivateKey) + assert.Len(t, parsed.Intermediates, 1) + assert.Equal(t, string(chainPEM), parsed.ChainPEM) + }) + + t.Run("invalid chain data ignored", func(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour)) + parsed, err := parsePEMInput(certPEM, nil, []byte("not-pem")) + require.NoError(t, err) + assert.Empty(t, parsed.Intermediates, "invalid PEM chain should be silently ignored") + }) + + t.Run("invalid cert data", func(t *testing.T) { + _, err := parsePEMInput([]byte("not-pem"), nil, nil) + assert.Error(t, err) + }) + + t.Run("empty PEM block", func(t *testing.T) { + emptyPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")}) + _, err := parsePEMInput(emptyPEM, nil, nil) + assert.Error(t, err) + }) +} + +// --- ConvertPFXToPEM --- + +func TestConvertPFXToPEM(t *testing.T) { + cert, priv, _, _ := makeRSACertAndKey(t, "pfx-convert.test", time.Now().Add(time.Hour)) + pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword) + require.NoError(t, err) + + t.Run("valid PFX", func(t *testing.T) { + certPEM, keyPEM, chainPEM, err := ConvertPFXToPEM(pfxData, pkcs12.DefaultPassword) + require.NoError(t, err) + assert.Contains(t, certPEM, "BEGIN CERTIFICATE") + assert.Contains(t, keyPEM, "PRIVATE KEY") + assert.Empty(t, chainPEM) + }) + + t.Run("PFX with chain", func(t *testing.T) { + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(200), + Subject: pkix.Name{CommonName: "PFX Test CA"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + require.NoError(t, err) + caCert, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + + pfxWithChain, err := pkcs12.Modern.Encode(priv, cert, []*x509.Certificate{caCert}, pkcs12.DefaultPassword) + require.NoError(t, err) + + certPEM, keyPEM, chainPEM, err := ConvertPFXToPEM(pfxWithChain, pkcs12.DefaultPassword) + require.NoError(t, err) + assert.Contains(t, certPEM, "BEGIN CERTIFICATE") + assert.Contains(t, keyPEM, "PRIVATE KEY") + assert.Contains(t, chainPEM, "BEGIN CERTIFICATE") + }) + + t.Run("invalid PFX", func(t *testing.T) { + _, _, _, err := ConvertPFXToPEM([]byte("bad"), "password") + assert.Error(t, err) + assert.Contains(t, err.Error(), "PFX") + }) +} + +// --- encodeKeyToPEM --- + +func TestEncodeKeyToPEM(t *testing.T) { + t.Run("RSA key", func(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pemStr, err := encodeKeyToPEM(priv) + require.NoError(t, err) + assert.Contains(t, pemStr, "PRIVATE KEY") + }) + + t.Run("ECDSA key", func(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + pemStr, err := encodeKeyToPEM(priv) + require.NoError(t, err) + assert.Contains(t, pemStr, "PRIVATE KEY") + }) +} + +// --- ParseCertificateInput for PFX --- + +func TestParseCertificateInput_PFX(t *testing.T) { + cert, priv, _, _ := makeRSACertAndKey(t, "pfx-parse.test", time.Now().Add(time.Hour)) + pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword) + require.NoError(t, err) + + t.Run("PFX format detected and parsed", func(t *testing.T) { + parsed, err := ParseCertificateInput(pfxData, nil, nil, pkcs12.DefaultPassword) + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.NotNil(t, parsed.PrivateKey) + assert.Equal(t, FormatPFX, parsed.Format) + }) +} + +// --- detectKeyType additional branches --- + +func TestDetectKeyType_P384(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(99), + Subject: pkix.Name{CommonName: "p384.test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + assert.Equal(t, "ECDSA-P384", detectKeyType(cert)) +} + +// --- parsePEMPrivateKey additional formats --- + +func TestParsePEMPrivateKey_PKCS1(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + + key, err := parsePEMPrivateKey(keyPEM) + require.NoError(t, err) + assert.NotNil(t, key) +} + +func TestParsePEMPrivateKey_EC(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + ecDER, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: ecDER}) + + key, err := parsePEMPrivateKey(keyPEM) + require.NoError(t, err) + assert.NotNil(t, key) +} + +func TestParsePEMPrivateKey_Invalid(t *testing.T) { + t.Run("no PEM data", func(t *testing.T) { + _, err := parsePEMPrivateKey([]byte("not pem")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no PEM data") + }) + + t.Run("unsupported key format", func(t *testing.T) { + badPEM := pem.EncodeToMemory(&pem.Block{Type: "UNKNOWN KEY", Bytes: []byte("junk")}) + _, err := parsePEMPrivateKey(badPEM) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") + }) +} + +// --- DetectFormat for PFX --- + +func TestDetectFormat_PFX(t *testing.T) { + cert, priv, _, _ := makeRSACertAndKey(t, "detect-pfx.test", time.Now().Add(time.Hour)) + pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword) + require.NoError(t, err) + + format := DetectFormat(pfxData) + assert.Equal(t, FormatPFX, format, "PFX data should be detected as FormatPFX") +} diff --git a/backend/internal/services/certificate_validator_extra_coverage_test.go b/backend/internal/services/certificate_validator_extra_coverage_test.go new file mode 100644 index 000000000..1bf462d9d --- /dev/null +++ b/backend/internal/services/certificate_validator_extra_coverage_test.go @@ -0,0 +1,256 @@ +package services + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- ValidateKeyMatch ECDSA --- + +func TestValidateKeyMatch_ECDSA_Success(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ecdsa-match.test") + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Use the actual key that signed the cert + ecCert, ecKey, _, _ := makeECDSACertAndKey(t, "ecdsa-ok.test") + err = ValidateKeyMatch(ecCert, ecKey) + assert.NoError(t, err) + + // Mismatch: different ECDSA key + err = ValidateKeyMatch(cert, priv) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ECDSA key mismatch") +} + +func TestValidateKeyMatch_ECDSA_WrongKeyType(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ecdsa-wrong.test") + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + err = ValidateKeyMatch(cert, rsaKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "key type mismatch") +} + +// --- ValidateKeyMatch Ed25519 --- + +func TestValidateKeyMatch_Ed25519_Success(t *testing.T) { + cert, priv, _, _ := makeEd25519CertAndKey(t, "ed25519-ok.test") + err := ValidateKeyMatch(cert, priv) + assert.NoError(t, err) +} + +func TestValidateKeyMatch_Ed25519_Mismatch(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-mismatch.test") + _, otherPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + err = ValidateKeyMatch(cert, otherPriv) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Ed25519 key mismatch") +} + +func TestValidateKeyMatch_Ed25519_WrongKeyType(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-wrong.test") + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + err = ValidateKeyMatch(cert, rsaKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "key type mismatch") +} + +func TestValidateKeyMatch_UnsupportedKeyType(t *testing.T) { + // Create a cert with a nil public key type to trigger the default branch + cert := &x509.Certificate{PublicKey: "not-a-real-key"} + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + err = ValidateKeyMatch(cert, key) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported public key type") +} + +// --- ConvertDERToPEM --- + +func TestConvertDERToPEM_Valid(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "der-to-pem.test", time.Now().Add(time.Hour)) + pemStr, err := ConvertDERToPEM(cert.Raw) + require.NoError(t, err) + assert.Contains(t, pemStr, "BEGIN CERTIFICATE") +} + +func TestConvertDERToPEM_Invalid(t *testing.T) { + _, err := ConvertDERToPEM([]byte("not-der-data")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid DER") +} + +// --- ConvertPEMToDER --- + +func TestConvertPEMToDER_Valid(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "pem-to-der.test", time.Now().Add(time.Hour)) + derData, err := ConvertPEMToDER(string(certPEM)) + require.NoError(t, err) + assert.NotEmpty(t, derData) + + // Verify it's valid DER + parsed, err := x509.ParseCertificate(derData) + require.NoError(t, err) + assert.Equal(t, "pem-to-der.test", parsed.Subject.CommonName) +} + +func TestConvertPEMToDER_NoPEMBlock(t *testing.T) { + _, err := ConvertPEMToDER("not-pem-data") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode PEM") +} + +func TestConvertPEMToDER_InvalidCert(t *testing.T) { + fakePEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")})) + _, err := ConvertPEMToDER(fakePEM) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid certificate PEM") +} + +// --- ConvertPEMToPFX --- + +func TestConvertPEMToPFX_Valid(t *testing.T) { + _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pem-to-pfx.test", time.Now().Add(time.Hour)) + pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), "", "test-password") + require.NoError(t, err) + assert.NotEmpty(t, pfxData) +} + +func TestConvertPEMToPFX_WithChain(t *testing.T) { + _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pfx-chain.test", time.Now().Add(time.Hour)) + _, _, chainPEM, _ := makeRSACertAndKey(t, "pfx-ca.test", time.Now().Add(time.Hour)) + pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), string(chainPEM), "pass") + require.NoError(t, err) + assert.NotEmpty(t, pfxData) +} + +func TestConvertPEMToPFX_BadCert(t *testing.T) { + _, err := ConvertPEMToPFX("not-pem", "not-pem", "", "pass") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cert PEM") +} + +func TestConvertPEMToPFX_BadKey(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "pfx-badkey.test", time.Now().Add(time.Hour)) + _, err := ConvertPEMToPFX(string(certPEM), "not-pem", "", "pass") + assert.Error(t, err) + assert.Contains(t, err.Error(), "key PEM") +} + +// --- ExtractCertificateMetadata --- + +func TestExtractCertificateMetadata_Nil(t *testing.T) { + result := ExtractCertificateMetadata(nil) + assert.Nil(t, result) +} + +func TestExtractCertificateMetadata_Valid(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "metadata.test", time.Now().Add(24*time.Hour)) + meta := ExtractCertificateMetadata(cert) + require.NotNil(t, meta) + assert.NotEmpty(t, meta.Fingerprint) + assert.NotEmpty(t, meta.SerialNumber) + assert.Contains(t, meta.KeyType, "RSA") + assert.Contains(t, meta.Domains, "metadata.test") +} + +func TestExtractCertificateMetadata_WithSANs(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(42), + Subject: pkix.Name{CommonName: "san.test", Organization: []string{"Test Org"}}, + Issuer: pkix.Name{Organization: []string{"Test Issuer"}}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"san.test", "alt.test", "other.test"}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + meta := ExtractCertificateMetadata(cert) + require.NotNil(t, meta) + assert.Contains(t, meta.Domains, "san.test") + assert.Contains(t, meta.Domains, "alt.test") + assert.Contains(t, meta.Domains, "other.test") + assert.Equal(t, "Test Org", meta.IssuerOrg) +} + +// --- detectKeyType --- + +func TestDetectKeyType_Ed25519(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-type.test") + assert.Equal(t, "Ed25519", detectKeyType(cert)) +} + +func TestDetectKeyType_RSA(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa-type.test", time.Now().Add(time.Hour)) + kt := detectKeyType(cert) + assert.Contains(t, kt, "RSA-") +} + +func TestDetectKeyType_ECDSA_P256(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "p256-type.test") + assert.Equal(t, "ECDSA-P256", detectKeyType(cert)) +} + +// --- formatSerial --- + +func TestFormatSerial_Nil(t *testing.T) { + assert.Equal(t, "", formatSerial(nil)) +} + +func TestFormatSerial_Value(t *testing.T) { + result := formatSerial(big.NewInt(256)) + assert.NotEmpty(t, result) + assert.Contains(t, result, ":") +} + +// --- formatFingerprint --- + +func TestFormatFingerprint_Normal(t *testing.T) { + result := formatFingerprint("aabbccdd") + assert.Equal(t, "AA:BB:CC:DD", result) +} + +func TestFormatFingerprint_OddLength(t *testing.T) { + result := formatFingerprint("aabbc") + assert.Contains(t, result, "AA:BB") +} + +// --- DetectFormat DER --- + +func TestDetectFormat_DER(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "detect-der.test", time.Now().Add(time.Hour)) + format := DetectFormat(cert.Raw) + assert.Equal(t, FormatDER, format) +} + +func TestDetectFormat_PEM(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "detect-pem.test", time.Now().Add(time.Hour)) + format := DetectFormat(certPEM) + assert.Equal(t, FormatPEM, format) +} + + diff --git a/backend/internal/services/certificate_validator_patch_coverage_test.go b/backend/internal/services/certificate_validator_patch_coverage_test.go new file mode 100644 index 000000000..137bab104 --- /dev/null +++ b/backend/internal/services/certificate_validator_patch_coverage_test.go @@ -0,0 +1,189 @@ +package services + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "software.sslmate.com/src/go-pkcs12" +) + +func TestDetectFormat_PasswordProtectedPFX(t *testing.T) { + cert, key, _, _ := makeRSACertAndKey(t, "pfx-pw.example.com", time.Now().Add(24*time.Hour)) + + pfxData, err := pkcs12.Modern.Encode(key, cert, nil, "custompw") + require.NoError(t, err) + + format := DetectFormat(pfxData) + assert.Equal(t, FormatPFX, format) +} + +func TestParsePEMPrivateKey_PKCS1RSA(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + keyDER := x509.MarshalPKCS1PrivateKey(key) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}) + + parsed, err := parsePEMPrivateKey(keyPEM) + require.NoError(t, err) + assert.NotNil(t, parsed) +} + +func TestParsePEMPrivateKey_ECPrivKey(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + parsed, err := parsePEMPrivateKey(keyPEM) + require.NoError(t, err) + assert.NotNil(t, parsed) +} + +func TestDetectKeyType_ECDSAP384(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "p384.example.com"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(certDER) + require.NoError(t, err) + + assert.Equal(t, "ECDSA-P384", detectKeyType(cert)) +} + +func TestDetectKeyType_ECDSAUnknownCurve(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "p224.example.com"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(certDER) + require.NoError(t, err) + + assert.Equal(t, "ECDSA", detectKeyType(cert)) +} + +func TestConvertPEMToPFX_EmptyChain(t *testing.T) { + _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pfx-chain.example.com", time.Now().Add(24*time.Hour)) + + pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), "", "testpass") + require.NoError(t, err) + assert.NotEmpty(t, pfxData) +} + +func TestConvertPEMToDER_NonCertBlock(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + _, err = ConvertPEMToDER(string(keyPEM)) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid certificate PEM") +} + +func TestFormatSerial_NilInput(t *testing.T) { + assert.Equal(t, "", formatSerial(nil)) +} + +func TestDetectFormat_EmptyPasswordPFX(t *testing.T) { + cert, key, _, _ := makeRSACertAndKey(t, "empty-pw.example.com", time.Now().Add(24*time.Hour)) + + pfxData, err := pkcs12.Modern.Encode(key, cert, nil, "") + require.NoError(t, err) + + format := DetectFormat(pfxData) + assert.Equal(t, FormatPFX, format) +} + +func TestParseCertificateInput_BadChainPEM(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "bad-chain-test.example.com", time.Now().Add(24*time.Hour)) + + badChain := []byte("-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----\n") + + _, err := ParseCertificateInput(certPEM, nil, badChain, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse chain PEM") +} + +func TestValidateChain_WithIntermediates(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "chain-inter.example.com", time.Now().Add(24*time.Hour)) + + _ = ValidateChain(cert, []*x509.Certificate{cert}) +} + +func TestConvertPEMToPFX_BadCertPEM(t *testing.T) { + badCertPEM := "-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----\n" + + _, err := ConvertPEMToPFX(badCertPEM, "somekey", "", "pass") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse cert PEM") +} + +func TestConvertPEMToPFX_BadChainPEM(t *testing.T) { + _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pfx-bad-chain.example.com", time.Now().Add(24*time.Hour)) + + badChain := "-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----\n" + + _, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), badChain, "pass") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse chain PEM") +} + +func TestParsePEMPrivateKey_PKCS8(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + der, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + + parsed, err := parsePEMPrivateKey(keyPEM) + require.NoError(t, err) + assert.NotNil(t, parsed) +} + +func TestEncodeKeyToPEM_UnsupportedKeyType(t *testing.T) { + type badKey struct{} + + _, err := encodeKeyToPEM(badKey{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to marshal private key") +} + +func TestDetectKeyType_Unknown(t *testing.T) { + cert := &x509.Certificate{ + PublicKey: "not-a-real-key", + } + assert.Equal(t, "Unknown", detectKeyType(cert)) +} diff --git a/backend/internal/services/certificate_validator_test.go b/backend/internal/services/certificate_validator_test.go new file mode 100644 index 000000000..16de35b36 --- /dev/null +++ b/backend/internal/services/certificate_validator_test.go @@ -0,0 +1,388 @@ +package services + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- helpers --- + +func makeRSACertAndKey(t *testing.T, cn string, expiry time.Time) (*x509.Certificate, *rsa.PrivateKey, []byte, []byte) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: expiry, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return cert, priv, certPEM, keyPEM +} + +func makeECDSACertAndKey(t *testing.T, cn string) (*x509.Certificate, *ecdsa.PrivateKey, []byte, []byte) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return cert, priv, certPEM, keyPEM +} + +func makeEd25519CertAndKey(t *testing.T, cn string) (*x509.Certificate, ed25519.PrivateKey, []byte, []byte) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalPKCS8PrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + return cert, priv, certPEM, keyPEM +} + +// --- DetectFormat --- + +func TestDetectFormat(t *testing.T) { + cert, _, certPEM, _ := makeRSACertAndKey(t, "test.com", time.Now().Add(time.Hour)) + + t.Run("PEM format", func(t *testing.T) { + assert.Equal(t, FormatPEM, DetectFormat(certPEM)) + }) + + t.Run("DER format", func(t *testing.T) { + assert.Equal(t, FormatDER, DetectFormat(cert.Raw)) + }) + + t.Run("unknown format", func(t *testing.T) { + assert.Equal(t, FormatUnknown, DetectFormat([]byte("not a cert"))) + }) + + t.Run("empty data", func(t *testing.T) { + assert.Equal(t, FormatUnknown, DetectFormat([]byte{})) + }) +} + +// --- ParseCertificateInput --- + +func TestParseCertificateInput(t *testing.T) { + t.Run("PEM cert only", func(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "pem.test", time.Now().Add(time.Hour)) + parsed, err := ParseCertificateInput(certPEM, nil, nil, "") + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.Equal(t, FormatPEM, parsed.Format) + assert.Nil(t, parsed.PrivateKey) + }) + + t.Run("PEM cert with key", func(t *testing.T) { + _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pem-key.test", time.Now().Add(time.Hour)) + parsed, err := ParseCertificateInput(certPEM, keyPEM, nil, "") + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.NotNil(t, parsed.PrivateKey) + assert.Equal(t, FormatPEM, parsed.Format) + }) + + t.Run("DER cert", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour)) + parsed, err := ParseCertificateInput(cert.Raw, nil, nil, "") + require.NoError(t, err) + assert.NotNil(t, parsed.Leaf) + assert.Equal(t, FormatDER, parsed.Format) + }) + + t.Run("empty data returns error", func(t *testing.T) { + _, err := ParseCertificateInput(nil, nil, nil, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty") + }) + + t.Run("unrecognized format returns error", func(t *testing.T) { + _, err := ParseCertificateInput([]byte("garbage"), nil, nil, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized") + }) + + t.Run("invalid key PEM returns error", func(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "badkey.test", time.Now().Add(time.Hour)) + _, err := ParseCertificateInput(certPEM, []byte("not-key"), nil, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "private key") + }) +} + +// --- ValidateKeyMatch --- + +func TestValidateKeyMatch(t *testing.T) { + t.Run("RSA matching", func(t *testing.T) { + cert, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + assert.NoError(t, ValidateKeyMatch(cert, priv)) + }) + + t.Run("RSA mismatched", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa1.test", time.Now().Add(time.Hour)) + _, otherPriv, _, _ := makeRSACertAndKey(t, "rsa2.test", time.Now().Add(time.Hour)) + err := ValidateKeyMatch(cert, otherPriv) + assert.Error(t, err) + assert.Contains(t, err.Error(), "mismatch") + }) + + t.Run("ECDSA matching", func(t *testing.T) { + cert, priv, _, _ := makeECDSACertAndKey(t, "ecdsa.test") + assert.NoError(t, ValidateKeyMatch(cert, priv)) + }) + + t.Run("ECDSA mismatched", func(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ec1.test") + _, other, _, _ := makeECDSACertAndKey(t, "ec2.test") + assert.Error(t, ValidateKeyMatch(cert, other)) + }) + + t.Run("Ed25519 matching", func(t *testing.T) { + cert, priv, _, _ := makeEd25519CertAndKey(t, "ed.test") + assert.NoError(t, ValidateKeyMatch(cert, priv)) + }) + + t.Run("Ed25519 mismatched", func(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed1.test") + _, other, _, _ := makeEd25519CertAndKey(t, "ed2.test") + assert.Error(t, ValidateKeyMatch(cert, other)) + }) + + t.Run("type mismatch RSA cert with ECDSA key", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + _, ecKey, _, _ := makeECDSACertAndKey(t, "ec.test") + err := ValidateKeyMatch(cert, ecKey) + assert.Error(t, err) + assert.Contains(t, err.Error(), "type mismatch") + }) + + t.Run("nil certificate", func(t *testing.T) { + _, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + assert.Error(t, ValidateKeyMatch(nil, priv)) + }) + + t.Run("nil key", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + assert.Error(t, ValidateKeyMatch(cert, nil)) + }) +} + +// --- ValidateChain --- + +func TestValidateChain(t *testing.T) { + t.Run("nil leaf returns error", func(t *testing.T) { + err := ValidateChain(nil, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") + }) + + t.Run("self-signed cert validates", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "self.test", time.Now().Add(time.Hour)) + // Self-signed won't pass chain validation without being a CA + err := ValidateChain(cert, nil) + assert.Error(t, err) + }) +} + +// --- ConvertDERToPEM --- + +func TestConvertDERToPEM(t *testing.T) { + t.Run("valid DER", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour)) + pemStr, err := ConvertDERToPEM(cert.Raw) + require.NoError(t, err) + assert.Contains(t, pemStr, "BEGIN CERTIFICATE") + }) + + t.Run("invalid DER", func(t *testing.T) { + _, err := ConvertDERToPEM([]byte("not-der")) + assert.Error(t, err) + }) +} + +// --- ConvertPEMToDER --- + +func TestConvertPEMToDER(t *testing.T) { + t.Run("valid PEM", func(t *testing.T) { + _, _, certPEM, _ := makeRSACertAndKey(t, "p2d.test", time.Now().Add(time.Hour)) + der, err := ConvertPEMToDER(string(certPEM)) + require.NoError(t, err) + assert.NotEmpty(t, der) + // Round-trip + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + assert.Equal(t, "p2d.test", cert.Subject.CommonName) + }) + + t.Run("invalid PEM", func(t *testing.T) { + _, err := ConvertPEMToDER("not-pem") + assert.Error(t, err) + }) +} + +// --- ExtractCertificateMetadata --- + +func TestExtractCertificateMetadata(t *testing.T) { + t.Run("nil cert returns nil", func(t *testing.T) { + assert.Nil(t, ExtractCertificateMetadata(nil)) + }) + + t.Run("RSA cert metadata", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "meta.test", time.Now().Add(time.Hour)) + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Equal(t, "meta.test", m.CommonName) + assert.Contains(t, m.KeyType, "RSA") + assert.NotEmpty(t, m.Fingerprint) + assert.NotEmpty(t, m.SerialNumber) + assert.Contains(t, m.Domains, "meta.test") + }) + + t.Run("ECDSA cert metadata", func(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ec-meta.test") + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Contains(t, m.KeyType, "ECDSA") + }) + + t.Run("Ed25519 cert metadata", func(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed-meta.test") + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Equal(t, "Ed25519", m.KeyType) + }) + + t.Run("cert with SANs", func(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(10), + Subject: pkix.Name{CommonName: "main.test"}, + DNSNames: []string{"main.test", "alt1.test", "alt2.test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, _ := x509.ParseCertificate(der) + + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + assert.Contains(t, m.Domains, "main.test") + assert.Contains(t, m.Domains, "alt1.test") + assert.Contains(t, m.Domains, "alt2.test") + // CN should not be duplicated when it matches a SAN + count := 0 + for _, d := range m.Domains { + if d == "main.test" { + count++ + } + } + assert.Equal(t, 1, count, "CN should not be duplicated in domains list") + }) + + t.Run("cert with issuer org", func(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(11), + Subject: pkix.Name{CommonName: "org.test"}, + Issuer: pkix.Name{Organization: []string{"Test Org Inc"}}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, _ := x509.ParseCertificate(der) + + m := ExtractCertificateMetadata(cert) + require.NotNil(t, m) + // Self-signed cert's issuer org may differ from template + assert.NotEmpty(t, m.Fingerprint) + }) +} + +// --- Helpers --- + +func TestFormatFingerprint(t *testing.T) { + assert.Equal(t, "AB:CD:EF", formatFingerprint("abcdef")) + assert.Equal(t, "01:23", formatFingerprint("0123")) + assert.Equal(t, "", formatFingerprint("")) +} + +func TestFormatSerial(t *testing.T) { + assert.Equal(t, "01", formatSerial(big.NewInt(1))) + assert.Equal(t, "FF", formatSerial(big.NewInt(255))) + assert.Equal(t, "", formatSerial(nil)) +} + +func TestDetectKeyType(t *testing.T) { + t.Run("RSA key type", func(t *testing.T) { + cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour)) + kt := detectKeyType(cert) + assert.Contains(t, kt, "RSA-2048") + }) + + t.Run("ECDSA-P256 key type", func(t *testing.T) { + cert, _, _, _ := makeECDSACertAndKey(t, "ec.test") + kt := detectKeyType(cert) + assert.Equal(t, "ECDSA-P256", kt) + }) + + t.Run("Ed25519 key type", func(t *testing.T) { + cert, _, _, _ := makeEd25519CertAndKey(t, "ed.test") + kt := detectKeyType(cert) + assert.Equal(t, "Ed25519", kt) + }) +} diff --git a/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go index 2c42b5986..7b44bf73f 100644 --- a/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go +++ b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go @@ -12,6 +12,7 @@ func TestNewRFC2136Provider(t *testing.T) { if provider == nil { t.Fatal("NewRFC2136Provider() returned nil") + return } if provider.propagationTimeout != RFC2136DefaultPropagationTimeout { diff --git a/docs/development/go_version_upgrades.md b/docs/development/go_version_upgrades.md index 58a1da52b..09cc45972 100644 --- a/docs/development/go_version_upgrades.md +++ b/docs/development/go_version_upgrades.md @@ -251,13 +251,13 @@ Go releases **two major versions per year**: - February (e.g., Go 1.26.0) - August (e.g., Go 1.27.0) -Plus occasional patch releases (e.g., Go 1.26.1) for security fixes. +Plus occasional patch releases (e.g., Go 1.26.2) for security fixes. **Bottom line:** Expect to run `./scripts/rebuild-go-tools.sh` 2-3 times per year. ### Do I need to rebuild tools for patch releases? -**Usually no**, but it doesn't hurt. Patch releases (like 1.26.0 → 1.26.1) rarely break tool compatibility. +**Usually no**, but it doesn't hurt. Patch releases (like 1.26.0 → 1.26.2) rarely break tool compatibility. **Rebuild if:** diff --git a/docs/plans/archive/custom-cert-upload-management-spec-2026-04-15.md b/docs/plans/archive/custom-cert-upload-management-spec-2026-04-15.md new file mode 100644 index 000000000..3efbc742d --- /dev/null +++ b/docs/plans/archive/custom-cert-upload-management-spec-2026-04-15.md @@ -0,0 +1,1808 @@ +# Custom Certificate Upload & Management + +**Issue**: #22 — Custom Certificate Upload & Management +**Date**: 2026-04-10 +**Status**: Draft — Awaiting Approval +**Priority**: High +**Milestone**: Beta +**Labels**: high, beta, ssl +**Archived**: Previous plan (Nightly Build Vulnerability Remediation) → `docs/plans/archive/nightly-vuln-remediation-spec.md` + +--- + +## 1. Executive Summary + +Charon currently supports automatic certificate provisioning via Let's Encrypt/ZeroSSL (ACME) and has a rudimentary custom certificate upload flow (basic PEM upload with name). This plan enhances the certificate management system to support: + +- **Full certificate validation pipeline** (format, chain, expiry, key matching) +- **Private key encryption at rest** using the existing `CHARON_ENCRYPTION_KEY` infrastructure +- **Multiple certificate formats** (PEM, PFX/PKCS12, DER) +- **Certificate chain/intermediate support** +- **Certificate assignment to proxy hosts** via the UI +- **Expiry warning notifications** using the existing notification infrastructure +- **Certificate export** with format conversion +- **Enhanced upload UI** with drag-and-drop, validation feedback, and chain preview + +### Why This Matters + +Users who bring their own certificates (enterprise CAs, internal PKI, wildcard certs from commercial providers) need a secure, validated workflow for importing, managing, and assigning certificates. Currently, private keys are stored in plaintext in the database, there is no format validation beyond basic PEM decoding, and the UI lacks chain support and export capabilities. + +--- + +## 2. Current State Analysis + +### 2.1 What Already Exists + +| Component | Status | Location | Notes | +|-----------|--------|----------|-------| +| **SSLCertificate model** | Exists | `backend/internal/models/ssl_certificate.go` | Has `Certificate`, `PrivateKey` (plaintext), `Domains`, `ExpiresAt`, `Provider` | +| **CertificateService** | Exists | `backend/internal/services/certificate_service.go` | `UploadCertificate()`, `ListCertificates()`, `DeleteCertificate()`, `IsCertificateInUse()`, disk sync for ACME | +| **CertificateHandler** | Exists | `backend/internal/api/handlers/certificate_handler.go` | `List`, `Upload`, `Delete` endpoints | +| **API routes** | Exists | `backend/internal/api/routes/routes.go:664-675` | `GET/POST/DELETE /api/v1/certificates` | +| **Frontend API client** | Exists | `frontend/src/api/certificates.ts` | `getCertificates()`, `uploadCertificate()`, `deleteCertificate()` | +| **Certificates page** | Exists | `frontend/src/pages/Certificates.tsx` | Upload dialog (name + 2 files), list view | +| **CertificateList** | Exists | `frontend/src/components/CertificateList.tsx` | Table with sort, bulk delete, status display | +| **useCertificates hook** | Exists | `frontend/src/hooks/useCertificates.ts` | React Query wrapper | +| **Caddy TLS loading** | Exists | `backend/internal/caddy/config.go:418-453` | Custom certs loaded via `LoadPEM` in TLS app | +| **Caddy types** | Exists | `backend/internal/caddy/types.go:239-266` | `TLSApp`, `CertificatesConfig`, `LoadPEMConfig` | +| **Encryption service** | Exists | `backend/internal/crypto/encryption.go` | AES-256-GCM encrypt/decrypt with `CHARON_ENCRYPTION_KEY` | +| **Key rotation** | Exists | `backend/internal/crypto/rotation_service.go` | Multi-version key rotation for DNS provider credentials | +| **Notification service** | Exists | `backend/internal/services/notification_service.go` | `SendExternal()` with event types, `Create()` for in-app | +| **ProxyHost.CertificateID** | Exists | `backend/internal/models/proxy_host.go` | FK to SSLCertificate, already used in update handler | +| **Delete E2E tests** | Exists | `tests/certificate-delete.spec.ts`, `tests/certificate-bulk-delete.spec.ts` | Delete flow E2E coverage | +| **Config tests** | Exists | `backend/internal/caddy/config_test.go:1480-1600` | Custom cert loading via Caddy tested | + +### 2.2 Gaps to Address + +| Gap | Severity | Description | +|-----|----------|-------------| +| **Private keys stored in plaintext** | CRITICAL | `PrivateKey` field in `SSLCertificate` is stored as raw PEM. Must encrypt at rest. | +| **🔴 Active private key disclosure** | CRITICAL | The Upload handler (`certificate_handler.go:137`) returns the full `*SSLCertificate` struct via `c.JSON(http.StatusCreated, cert)`. Because the model has `json:"private_key"`, the raw PEM private key is sent to the client in every upload response. **This is an active vulnerability in production.** Commit 1 fixes this by changing the tag to `json:"-"`. | +| **Unsafe file read pattern** | HIGH | `certificate_handler.go:109` uses `certSrc.Read(certBytes)` which may return partial reads. Must use `io.ReadAll(io.LimitReader(src, 1<<20))` for safe, bounded reads. Commit 2 (task 2.1) fixes this. | +| **No certificate chain validation** | HIGH | Upload accepts any PEM without verifying chain or key-cert match. | +| **No format conversion** | HIGH | Only PEM is accepted. PFX/DER users cannot upload. | +| **No expiry warnings** | HIGH | No scheduled check or notification for upcoming certificate expiry. | +| **No certificate export** | MEDIUM | Users cannot download certs they uploaded (for migration, backup). | +| **No chain/intermediate storage** | MEDIUM | Model has single `Certificate` field; no dedicated chain field. | +| **No certificate detail view** | MEDIUM | Frontend shows only list; no detail/expand view showing SANs, issuer chain, fingerprint. | +| **`CertificateInfo` leaks numeric ID** | HIGH | `CertificateInfo.ID uint json:"id,omitempty"` in service — violates GORM security rules. | +| **Delete uses numeric ID in URL** | HIGH | `DELETE /certificates/:id` uses numeric ID; should use UUID. | + +--- + +## 3. Requirements (EARS Notation) + +### 3.1 Certificate Upload + +| ID | Requirement | +|----|-------------| +| R-UP-01 | WHEN a user submits a certificate upload form with valid PEM, PFX, or DER files, THE SYSTEM SHALL parse and validate the certificate, encrypt the private key at rest, store the certificate in the database, and return the certificate metadata. | +| R-UP-02 | WHEN a user uploads a PFX/PKCS12 file with a password, THE SYSTEM SHALL decrypt the PFX, extract the certificate chain and private key, convert to PEM, and store them. | +| R-UP-03 | WHEN a user uploads a DER-encoded certificate, THE SYSTEM SHALL convert it to PEM format before storage. | +| R-UP-04 | WHEN a certificate upload contains intermediate certificates, THE SYSTEM SHALL store the full chain in order (leaf then intermediate then root). | +| R-UP-05 | IF a user uploads a certificate whose private key does not match the certificate's public key, THEN THE SYSTEM SHALL reject the upload with a descriptive error. | +| R-UP-06 | IF a user uploads an expired certificate, THEN THE SYSTEM SHALL warn but still allow storage (with status "expired"). | +| R-UP-07 | THE SYSTEM SHALL enforce a maximum upload size of 1MB per file to prevent abuse. | +| R-UP-08 | IF a user uploads a file that is not a valid certificate or key format, THEN THE SYSTEM SHALL reject the upload with a descriptive error. | + +### 3.2 Certificate Validation + +| ID | Requirement | +|----|-------------| +| R-VL-01 | WHEN a certificate is uploaded, THE SYSTEM SHALL verify the X.509 structure, extract the Common Name, SANs, issuer, serial number, and expiry date. | +| R-VL-02 | WHEN a certificate chain is provided, THE SYSTEM SHALL verify that each certificate in the chain is signed by the next certificate (leaf then intermediate then root). | +| R-VL-03 | WHEN a private key is uploaded, THE SYSTEM SHALL verify that the key matches the certificate's public key by comparing the public key modulus. | + +### 3.3 Private Key Security + +| ID | Requirement | +|----|-------------| +| R-PK-01 | THE SYSTEM SHALL encrypt all custom certificate private keys at rest using AES-256-GCM via the existing `CHARON_ENCRYPTION_KEY`. | +| R-PK-02 | THE SYSTEM SHALL decrypt private keys only when serving them to Caddy for TLS or when exporting. | +| R-PK-03 | THE SYSTEM SHALL never return private key content in API list/get responses. | +| R-PK-04 | WHEN `CHARON_ENCRYPTION_KEY` is rotated, THE SYSTEM SHALL re-encrypt all stored private keys during the rotation process. | + +### 3.4 Certificate Assignment + +| ID | Requirement | +|----|-------------| +| R-AS-01 | WHEN a user assigns a custom certificate to a proxy host, THE SYSTEM SHALL update the proxy host's `CertificateID` and reload Caddy configuration. | +| R-AS-02 | WHEN a custom certificate is assigned to a proxy host, THE SYSTEM SHALL use `LoadPEM` in Caddy's TLS app to serve the certificate for that domain. | +| R-AS-03 | THE SYSTEM SHALL prevent deletion of certificates that are assigned to one or more proxy hosts. | + +### 3.5 Expiry Warnings + +| ID | Requirement | +|----|-------------| +| R-EX-01 | THE SYSTEM SHALL check certificate expiry dates daily via a background scheduler. | +| R-EX-02 | WHEN a custom certificate will expire within 30 days, THE SYSTEM SHALL create an in-app warning notification. | +| R-EX-03 | WHEN a custom certificate will expire within 30 days AND external notification providers are configured, THE SYSTEM SHALL send an external notification (rate-limited to once per 24 hours per certificate). | +| R-EX-04 | WHEN a custom certificate has expired, THE SYSTEM SHALL update its status to "expired" and send a critical notification. | + +### 3.6 Certificate Export + +| ID | Requirement | +|----|-------------| +| R-EXP-01 | WHEN a user requests a certificate export, THE SYSTEM SHALL provide the certificate and chain in the requested format (PEM, PFX, DER). | +| R-EXP-02 | WHEN exporting in PFX format, THE SYSTEM SHALL prompt for a password and encrypt the PFX bundle. | +| R-EXP-03 | THE SYSTEM SHALL require authentication for all export operations. | +| R-EXP-04 | THE SYSTEM SHALL never include the private key in export unless explicitly requested with re-authentication. | + +### 3.7 UI/UX + +| ID | Requirement | +|----|-------------| +| R-UI-01 | THE SYSTEM SHALL support drag-and-drop file upload for certificate and key files. | +| R-UI-02 | WHEN a certificate is uploaded, THE SYSTEM SHALL display a preview showing: domains (CN + SANs), issuer, expiry date, chain depth, and key match status. | +| R-UI-03 | THE SYSTEM SHALL display an expiry warning badge on certificates expiring within 30 days. | +| R-UI-04 | THE SYSTEM SHALL provide a certificate detail view showing full metadata including fingerprint, serial number, issuer chain, and assigned hosts. | + +--- + +## 4. Technical Architecture + +### 4.1 Database Model Changes + +#### Modified: `SSLCertificate` (`backend/internal/models/ssl_certificate.go`) + +```go +type SSLCertificate struct { + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Name string `json:"name" gorm:"index"` + Provider string `json:"provider" gorm:"index"` + Domains string `json:"domains" gorm:"index"` + CommonName string `json:"common_name"` // NEW + Certificate string `json:"-" gorm:"type:text"` // CHANGED: hide from JSON + CertificateChain string `json:"-" gorm:"type:text"` // NEW + PrivateKeyEncrypted string `json:"-" gorm:"column:private_key_enc;type:text"` // NEW + PrivateKey string `json:"-" gorm:"-"` // CHANGED: json:"-" fixes active private key disclosure (was json:"private_key"), gorm:"-" excludes from queries (column kept but values cleared) + KeyVersion int `json:"-" gorm:"default:1"` // NEW + Fingerprint string `json:"fingerprint"` // NEW + SerialNumber string `json:"serial_number"` // NEW + IssuerOrg string `json:"issuer_org"` // NEW + KeyType string `json:"key_type"` // NEW — see KeyType values below + ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"` + NotBefore *time.Time `json:"not_before,omitempty"` // NEW + AutoRenew bool `json:"auto_renew" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**`KeyType` enum values**: `RSA-2048`, `RSA-4096`, `ECDSA-P256`, `ECDSA-P384`, `Ed25519`. Derived from the parsed private key at upload time. + +**Migration strategy**: Add new columns with defaults. Migrate existing plaintext `PrivateKey` data to `PrivateKeyEncrypted` via a dedicated migration step. After migration, clear `private_key` values (set to empty string) but **do NOT drop the column** — SQLite < 3.35.0 does not support `ALTER TABLE DROP COLUMN`, and GORM's `DropColumn` has varying support. Add `gorm:"-"` tag to the `PrivateKey` field so GORM ignores it in all queries. + +**Migration verification criteria**: No rows where `private_key != '' AND private_key_enc == ''`. + +**Follow-up task** (outside this feature's 4 PRs): Drop `private_key` column in a future release once all deployments are confirmed migrated and SQLite version requirements are established. + +#### Modified: `CertificateInfo` (`backend/internal/services/certificate_service.go`) + +```go +type CertificateInfo struct { + UUID string `json:"uuid"` + Name string `json:"name,omitempty"` + CommonName string `json:"common_name,omitempty"` + Domains string `json:"domains"` + Issuer string `json:"issuer"` + IssuerOrg string `json:"issuer_org,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + KeyType string `json:"key_type,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + NotBefore time.Time `json:"not_before,omitempty"` + Status string `json:"status"` + Provider string `json:"provider"` + ChainDepth int `json:"chain_depth,omitempty"` + HasKey bool `json:"has_key"` + InUse bool `json:"in_use"` +} +``` + +### 4.2 API Endpoints + +All endpoints under `/api/v1` require authentication (existing middleware). + +#### Existing (Modified) + +| Method | Path | Changes | +|--------|------|---------| +| `GET` | `/certificates` | Response uses `CertificateInfo` (UUID only, no numeric ID, new metadata fields) | +| `POST` | `/certificates` | Accept PEM, PFX, DER; encrypt private key; validate chain; return `CertificateInfo` | +| `DELETE` | `/certificates/:uuid` | CHANGED: Use UUID param instead of numeric ID | + +#### UUID-to-uint Resolution for Certificate Assignment + +The `ProxyHost.CertificateID` field is `*uint` — this **will not change** to UUID. It remains a numeric foreign key. When the certificate assignment endpoint receives a certificate UUID (from the UI/API), the handler **must resolve UUID → numeric ID** via a DB lookup (`SELECT id FROM ssl_certificates WHERE uuid = ?`) before setting `ProxyHost.CertificateID`. Implementers must NOT attempt to change the FK type to UUID. + +#### New Endpoints + +| Method | Path | Description | Request | Response | +|--------|------|-------------|---------|----------| +| `GET` | `/certificates/:uuid` | Get certificate detail | — | `CertificateDetail` (full metadata, chain info, assigned hosts) | +| `POST` | `/certificates/:uuid/export` | Export certificate | JSON body (format, include_key, pfx_password, password) | Binary file download | +| `PUT` | `/certificates/:uuid` | Update certificate metadata (name) | JSON body (name) | `CertificateInfo` | +| `POST` | `/certificates/validate` | Validate certificate without storing | Multipart (same as upload) | `ValidationResult` | + +#### Request/Response Schemas + +**Upload Request** (`POST /certificates`) — Multipart form: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Display name | +| `certificate_file` | file | Yes | Certificate file (.pem, .crt, .cer, .pfx, .p12, .der) | +| `key_file` | file | Conditional | Private key file (required for PEM/DER; not needed for PFX) | +| `chain_file` | file | No | Intermediate chain file (PEM) | +| `pfx_password` | string | Conditional | Password for PFX decryption | + +**Upload Response** (`201 Created`): + +```json +{ + "uuid": "a1b2c3d4-...", + "name": "My Wildcard Cert", + "common_name": "*.example.com", + "domains": "*.example.com,example.com", + "issuer": "custom", + "issuer_org": "DigiCert Inc", + "fingerprint": "AB:CD:EF:...", + "serial_number": "03:A1:...", + "key_type": "RSA-2048", + "expires_at": "2027-04-10T00:00:00Z", + "not_before": "2026-04-10T00:00:00Z", + "status": "valid", + "provider": "custom", + "chain_depth": 2, + "has_key": true, + "in_use": false +} +``` + +**Certificate Detail Response** (`GET /certificates/:uuid`): + +```json +{ + "uuid": "a1b2c3d4-...", + "name": "My Wildcard Cert", + "common_name": "*.example.com", + "domains": "*.example.com,example.com", + "issuer": "custom", + "issuer_org": "DigiCert Inc", + "fingerprint": "AB:CD:EF:...", + "serial_number": "03:A1:...", + "key_type": "RSA-2048", + "expires_at": "2027-04-10T00:00:00Z", + "not_before": "2026-04-10T00:00:00Z", + "status": "valid", + "provider": "custom", + "chain_depth": 2, + "has_key": true, + "in_use": true, + "assigned_hosts": [ + {"uuid": "host-uuid-1", "name": "My App", "domain_names": "app.example.com"} + ], + "chain": [ + {"subject": "*.example.com", "issuer": "DigiCert SHA2 Extended Validation Server CA", "expires_at": "2027-04-10T00:00:00Z"}, + {"subject": "DigiCert SHA2 Extended Validation Server CA", "issuer": "DigiCert Global Root CA", "expires_at": "2031-11-10T00:00:00Z"} + ], + "auto_renew": false, + "created_at": "2026-04-10T12:00:00Z", + "updated_at": "2026-04-10T12:00:00Z" +} +``` + +**Export Request** (`POST /certificates/:uuid/export`): + +```json +{ + "format": "pem", + "include_key": true, + "pfx_password": "optional-for-pfx", + "password": "current-user-password" +} +``` + +**R-EXP-04 Re-authentication Design**: When `include_key: true` is set, the request body **must** include the `password` field containing the current user's password. The export handler validates the password against the authenticated user's stored credentials before decrypting and returning the private key. If the password is missing or incorrect, the endpoint returns `403 Forbidden`. This prevents key exfiltration via stolen session tokens. + +```json +// Example: export without key (no password required) +{ + "format": "pem", + "include_key": false +} + +// Example: export with key (password confirmation required) +{ + "format": "pem", + "include_key": true, + "password": "MyCurrentPassword123" +} +``` + +**Validation Response** (`POST /certificates/validate`): + +```json +{ + "valid": true, + "common_name": "*.example.com", + "domains": ["*.example.com", "example.com"], + "issuer_org": "DigiCert Inc", + "expires_at": "2027-04-10T00:00:00Z", + "key_match": true, + "chain_valid": true, + "chain_depth": 2, + "warnings": ["Certificate expires in 365 days"], + "errors": [] +} +``` + +### 4.3 Service Layer Changes + +#### Modified: `CertificateService` (`backend/internal/services/certificate_service.go`) + +New/modified function signatures: + +```go +// NewCertificateService — MODIFIED: add encryption service dependency +func NewCertificateService(dataDir string, db *gorm.DB, encSvc *crypto.EncryptionService) *CertificateService + +// UploadCertificate — MODIFIED: accepts parsed content, validates, encrypts key +func (s *CertificateService) UploadCertificate(name string, certPEM string, keyPEM string, chainPEM string) (*CertificateInfo, error) + +// GetCertificate — NEW: get single certificate detail by UUID +func (s *CertificateService) GetCertificate(uuid string) (*CertificateDetail, error) + +// UpdateCertificate — NEW: update metadata (name) +func (s *CertificateService) UpdateCertificate(uuid string, name string) (*CertificateInfo, error) + +// DeleteCertificate — MODIFIED: accept UUID instead of numeric ID +func (s *CertificateService) DeleteCertificate(uuid string) error + +// IsCertificateInUse — MODIFIED: accept UUID instead of numeric ID +func (s *CertificateService) IsCertificateInUse(uuid string) (bool, error) + +// ExportCertificate — NEW: export cert in requested format +func (s *CertificateService) ExportCertificate(uuid string, format string, includeKey bool) ([]byte, string, error) + +// ValidateCertificate — NEW: validate without storing +func (s *CertificateService) ValidateCertificate(certPEM string, keyPEM string, chainPEM string) (*ValidationResult, error) + +// GetDecryptedPrivateKey — NEW: internal only, decrypt key for Caddy/export +func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error) + +// CheckExpiringCertificates — NEW: called by scheduler +func (s *CertificateService) CheckExpiringCertificates() ([]CertificateInfo, error) + +// MigratePrivateKeys — NEW: one-time migration from plaintext to encrypted +func (s *CertificateService) MigratePrivateKeys() error +``` + +#### New: `CertificateValidator` (`backend/internal/services/certificate_validator.go`) + +```go +// ParseCertificateInput handles PEM, PFX, and DER input parsing +func ParseCertificateInput(certData []byte, keyData []byte, chainData []byte, pfxPassword string) (*ParsedCertificate, error) + +// ValidateKeyMatch checks that the private key matches the certificate public key +func ValidateKeyMatch(cert *x509.Certificate, key crypto.PrivateKey) error + +// ValidateChain verifies the certificate chain from leaf to root. +// Uses x509.Certificate.Verify() with an intermediate cert pool to validate +// the chain against system roots (or provided root certificates). +func ValidateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error + +// DetectFormat determines the certificate format from file content +func DetectFormat(data []byte) (string, error) + +// ConvertDERToPEM converts DER-encoded certificate to PEM +func ConvertDERToPEM(derData []byte) (string, error) + +// ConvertPFXToPEM extracts cert, key, and chain from PFX/PKCS12 +func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM string, chainPEM string, err error) + +// ConvertPEMToPFX bundles cert, key, chain into PFX +func ConvertPEMToPFX(certPEM string, keyPEM string, chainPEM string, password string) ([]byte, error) + +// ConvertPEMToDER converts PEM certificate to DER +func ConvertPEMToDER(certPEM string) ([]byte, error) + +// ExtractCertificateMetadata extracts fingerprint, serial, issuer, key type, etc. +func ExtractCertificateMetadata(cert *x509.Certificate) *CertificateMetadata +``` + +### 4.4 Caddy Integration Changes + +#### Modified: Config Generation (`backend/internal/caddy/config.go`) + +The existing custom certificate loading logic (lines 418-453) needs modification to: + +1. **Decrypt private keys** before passing to Caddy's `LoadPEM` +2. **Include certificate chain** in the `Certificate` field (full PEM chain) +3. **Add TLS automation policy** with `skip` for custom cert domains (prevent ACME from trying to issue for those domains) + +Updated custom cert loading block: + +```go +for _, cert := range customCerts { + if cert.Certificate == "" || cert.PrivateKeyEncrypted == "" { + logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing data, skipping") + continue + } + + decryptedKey, err := encSvc.Decrypt(cert.PrivateKeyEncrypted) + if err != nil { + logger.Log().WithError(err).WithField("cert", cert.Name).Warn("Failed to decrypt custom cert key, skipping") + continue + } + + fullCert := cert.Certificate + if cert.CertificateChain != "" { + fullCert = cert.Certificate + "\n" + cert.CertificateChain + } + + loadPEM = append(loadPEM, LoadPEMConfig{ + Certificate: fullCert, + Key: string(decryptedKey), + Tags: []string{cert.UUID}, + }) +} +``` + +Additionally, add a TLS automation policy that skips ACME for custom cert domains: + +```go +if len(customCertDomains) > 0 { + tlsPolicies = append(tlsPolicies, &AutomationPolicy{ + Subjects: customCertDomains, + IssuersRaw: nil, + }) +} +``` + +### 4.5 Encryption Strategy + +**Private Key Encryption at Rest**: + +1. On upload: `encSvc.Encrypt([]byte(keyPEM))` stores in `PrivateKeyEncrypted` +2. On Caddy config generation: `encSvc.Decrypt(cert.PrivateKeyEncrypted)` passes decrypted PEM to Caddy +3. On export: `encSvc.Decrypt(cert.PrivateKeyEncrypted)` converts to requested format +4. `KeyVersion` tracks which encryption key version was used (for rotation via `RotationService`) + +**Migration**: Existing certificates with plaintext `PrivateKey` will be migrated to encrypted form during application startup if `CHARON_ENCRYPTION_KEY` is set. The migration: + +- Reads `private_key` column +- Encrypts with current key +- Writes to `private_key_enc` column +- Sets `key_version = 1` +- Clears `private_key` column +- Logs migration progress + +### 4.6 Certificate Format Handling + +| Input Format | Detection | Processing | +|-------------|-----------|------------| +| **PEM** | Trial parse: `pem.Decode` succeeds | Direct parse via `pem.Decode` + `x509.ParseCertificate` | +| **PFX/PKCS12** | Trial parse: if PEM fails, attempt `pkcs12.Decode` | `pkcs12.Decode(pfxData, password)` then extract cert, key, chain and store as PEM | +| **DER** | Trial parse: if PEM and PFX fail, attempt `x509.ParseCertificate(raw)` | `x509.ParseCertificate(derBytes)` then convert to PEM for storage | + +**Detection strategy**: Use trial-parse (not magic bytes). Try PEM decode first → if that fails, try PFX/PKCS12 decode → if that also fails, try raw DER parse via `x509.ParseCertificate`. This is more reliable than magic byte sniffing, especially for DER which shares ASN.1 structure with PFX. + +**Dependencies**: Use `software.sslmate.com/src/go-pkcs12` for PFX handling (widely used, maintained). + +### 4.7 Expiry Warning Scheduler + +Add a background goroutine in `CertificateService` that runs daily: + +```go +func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) { + // Startup delay: avoid notification bursts during frequent restarts + startupDelay := 5 * time.Minute + select { + case <-ctx.Done(): + return + case <-time.After(startupDelay): + } + + // Add random jitter (0-60 minutes) to stagger checks across instances/restarts + jitter := time.Duration(rand.Int63n(int64(60 * time.Minute))) + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + s.checkExpiry(notificationSvc, warningDays) + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.checkExpiry(notificationSvc, warningDays) + } + } +} +``` + +**Configuration**: `warningDays` is read from `CHARON_CERT_EXPIRY_WARNING_DAYS` environment variable at startup (default: `30`). The startup wiring reads the config value and passes it to `StartExpiryChecker`. + +The checker: + +1. Queries all custom certificates +2. For certs expiring within `warningDays` days: create warning notification + send external notification (rate-limited per cert per 24h) +3. For expired certs: update status to "expired" + send critical notification + +--- + +## 5. Frontend Design + +### 5.1 New/Modified Components + +| Component | Type | Path | Description | +|-----------|------|------|-------------| +| `CertificateUploadDialog` | Modified | `frontend/src/components/dialogs/CertificateUploadDialog.tsx` | Extract from `Certificates.tsx`; add drag-and-drop, format detection, chain file, PFX password, validation preview | +| `CertificateDetailDialog` | New | `frontend/src/components/dialogs/CertificateDetailDialog.tsx` | Full metadata view, chain visualization, assigned hosts list, export button | +| `CertificateExportDialog` | New | `frontend/src/components/dialogs/CertificateExportDialog.tsx` | Format selector (PEM/PFX/DER), include-key toggle, PFX password field | +| `CertificateValidationPreview` | New | `frontend/src/components/CertificateValidationPreview.tsx` | Shows parsed cert info before upload confirmation | +| `CertificateChainViewer` | New | `frontend/src/components/CertificateChainViewer.tsx` | Visual chain display (leaf then intermediate then root) | +| `FileDropZone` | New | `frontend/src/components/ui/FileDropZone.tsx` | Reusable drag-and-drop file upload component | +| `CertificateList` | Modified | `frontend/src/components/CertificateList.tsx` | Add detail view button, export button, expiry warning badges, use UUID for actions | + +### 5.2 API Client Updates (`frontend/src/api/certificates.ts`) + +```typescript +export interface Certificate { + uuid: string + name?: string + common_name?: string + domains: string + issuer: string + issuer_org?: string + fingerprint?: string + serial_number?: string + key_type?: string + expires_at: string + not_before?: string + status: 'valid' | 'expiring' | 'expired' | 'untrusted' + provider: string + chain_depth?: number + has_key: boolean + in_use: boolean +} + +export interface CertificateDetail extends Certificate { + assigned_hosts: { uuid: string; name: string; domain_names: string }[] + chain: { subject: string; issuer: string; expires_at: string }[] + auto_renew: boolean + created_at: string + updated_at: string +} + +export interface ValidationResult { + valid: boolean + common_name: string + domains: string[] + issuer_org: string + expires_at: string + key_match: boolean + chain_valid: boolean + chain_depth: number + warnings: string[] + errors: string[] +} + +export async function getCertificateDetail(uuid: string): Promise +export async function uploadCertificate( + name: string, certFile: File, keyFile?: File, chainFile?: File, pfxPassword?: string +): Promise +export async function updateCertificate(uuid: string, name: string): Promise +export async function deleteCertificate(uuid: string): Promise +export async function exportCertificate( + uuid: string, format: string, includeKey: boolean, pfxPassword?: string +): Promise +export async function validateCertificate( + certFile: File, keyFile?: File, chainFile?: File, pfxPassword?: string +): Promise +``` + +### 5.3 Hook Updates (`frontend/src/hooks/useCertificates.ts`) + +```typescript +export function useCertificates(options?: UseCertificatesOptions) +export function useCertificateDetail(uuid: string | null) +export function useUploadCertificate() +export function useUpdateCertificate() +export function useDeleteCertificate() +export function useExportCertificate() +export function useValidateCertificate() +``` + +### 5.4 Upload Flow UX + +1. User clicks "Add Certificate" +2. **Upload Dialog** opens with: + - Name input field + - File drop zones (certificate file, key file, optional chain file) + - Auto-format detection on file drop/select (show detected format badge: PEM/PFX/DER) + - If PFX detected: show password field, hide key file input + - "Validate" button calls `/certificates/validate` and shows `CertificateValidationPreview` +3. Validation preview shows: CN, SANs, issuer, expiry, chain depth, key-match status, warnings +4. User confirms and submits to `POST /certificates` +5. On success: toast + refresh list + close dialog + +### 5.5 Expiry Warning Display + +- Certificates expiring in 30 days or less: yellow warning badge + tooltip with days remaining +- Expired certificates: red expired badge +- The existing `status` field already provides `"expiring"` and `"expired"` values — the UI enhancement adds visual prominence + +--- + +## 6. Security Considerations + +### 6.1 Private Key Encryption + +- **🔴 ACTIVE VULNERABILITY FIX**: The current Upload handler (`certificate_handler.go:137`) returns `c.JSON(http.StatusCreated, cert)` where `cert` is the full `*SSLCertificate` struct. Because `PrivateKey` currently has `json:"private_key"`, the raw PEM private key is disclosed to the client in every upload response. **Commit 1 fixes this** by changing the tag to `json:"-"`, immediately closing this private key disclosure vulnerability. +- All private keys encrypted at rest using AES-256-GCM +- Encryption uses the same `CHARON_ENCRYPTION_KEY` and rotation infrastructure as DNS provider credentials +- Keys are decrypted only in-memory when needed (Caddy reload, export) +- The `PrivateKey` field is hidden from JSON serialization (`json:"-"`) and excluded from GORM queries (`gorm:"-"`) +- The `PrivateKeyEncrypted` field is also hidden from JSON (`json:"-"`) + +### 6.2 File Upload Security + +- Maximum file size: 1MB per file (enforced in handler) +- File content validated (must parse as valid certificate/key/PFX) +- No path traversal risk: files are read into memory, never written to arbitrary paths +- Content-Type and extension validation (`.pem`, `.crt`, `.cer`, `.key`, `.pfx`, `.p12`, `.der`) +- PFX password is not stored; used only during parsing + +### 6.3 GORM Model Security + +- `SSLCertificate.ID` uses `json:"-"` (numeric ID hidden) +- `SSLCertificate.Certificate` uses `json:"-"` (PEM content hidden from list) +- `SSLCertificate.PrivateKey` uses `json:"-"` (transient, not persisted) +- `SSLCertificate.PrivateKeyEncrypted` uses `json:"-"` (encrypted, hidden) +- All API endpoints use UUID for identification +- `CertificateInfo` no longer exposes numeric `ID` + +### 6.4 Export Security + +- Export endpoint requires authentication (existing middleware) +- `include_key: true` requires **password re-confirmation** — the user must supply their current password in the request body; the handler validates it before decrypting the key (implements R-EXP-04) +- PFX export requires a password (enforced) +- Audit log entry for key exports (via notification service) + +--- + +## 7. Implementation Phases (Tasks) + +### Phase 1: Backend Foundation — Model, Encryption, Validation (Commit 1) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 1.1 | Add new fields to SSLCertificate model | `backend/internal/models/ssl_certificate.go` | S | Add `CommonName`, `CertificateChain`, `PrivateKeyEncrypted`, `KeyVersion`, `Fingerprint`, `SerialNumber`, `IssuerOrg`, `KeyType`, `NotBefore`. Hide sensitive fields from JSON. | +| 1.2 | Update AutoMigrate | `backend/internal/api/routes/routes.go` | S | Already migrates `SSLCertificate`; GORM auto-adds new columns. | +| 1.3 | Create certificate validator | `backend/internal/services/certificate_validator.go` | L | `ParseCertificateInput()`, `ValidateKeyMatch()`, `ValidateChain()`, `DetectFormat()`, format conversion functions. | +| 1.4 | Add `go-pkcs12` dependency | `backend/go.mod` | S | `go get software.sslmate.com/src/go-pkcs12` | +| 1.5 | Write private key migration function | `backend/internal/services/certificate_service.go` | M | `MigratePrivateKeys()` — encrypts existing plaintext keys. | +| 1.6 | Modify `UploadCertificate()` | `backend/internal/services/certificate_service.go` | L | Full validation pipeline, encrypt key, store chain, extract metadata. | +| 1.7 | Add `GetCertificate()` | `backend/internal/services/certificate_service.go` | M | Get single cert by UUID with full detail (assigned hosts, chain). | +| 1.8 | Add `ValidateCertificate()` | `backend/internal/services/certificate_service.go` | M | Validate without storing. | +| 1.9 | Modify `DeleteCertificate()` | `backend/internal/services/certificate_service.go` | S | Accept UUID instead of numeric ID. | +| 1.10 | Add `ExportCertificate()` | `backend/internal/services/certificate_service.go` | M | Decrypt key, convert to requested format. | +| 1.11 | Add `GetDecryptedPrivateKey()` | `backend/internal/services/certificate_service.go` | S | Internal decrypt helper. | +| 1.12 | Update `CertificateInfo` | `backend/internal/services/certificate_service.go` | S | Remove numeric ID, add new metadata fields. | +| 1.13 | Update `refreshCacheFromDB()` | `backend/internal/services/certificate_service.go` | M | Populate new fields (fingerprint, chain depth, has_key, in_use). | +| 1.14 | Add constructor changes | `backend/internal/services/certificate_service.go` | S | Accept `*crypto.EncryptionService` in `NewCertificateService`. | +| 1.15 | Unit tests for validator | `backend/internal/services/certificate_validator_test.go` | L | PEM/DER/PFX parsing, key match, chain validation, format detection. | +| 1.16 | Unit tests for upload | `backend/internal/services/certificate_service_test.go` | L | Upload with encryption, migration, export. | +| 1.17 | GORM security scan | — | S | Run `./scripts/scan-gorm-security.sh --check` on new model fields. | + +### Phase 2: Backend API — Handlers, Routes, Caddy (Commit 2) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 2.1 | Update Upload handler | `backend/internal/api/handlers/certificate_handler.go` | L | Accept chain file, PFX password, detect format, call enhanced service. **Fix unsafe read**: replace `certSrc.Read(certBytes)` with `io.ReadAll(io.LimitReader(src, 1<<20))` for safe bounded reads (see Section 2.2 gaps). | +| 2.2 | Add Get handler | `backend/internal/api/handlers/certificate_handler.go` | M | `GET /certificates/:uuid` calls `GetCertificate()`. | +| 2.3 | Add Export handler | `backend/internal/api/handlers/certificate_handler.go` | M | `POST /certificates/:uuid/export` streams file download. | +| 2.4 | Add Update handler | `backend/internal/api/handlers/certificate_handler.go` | S | `PUT /certificates/:uuid` updates name. | +| 2.5 | Add Validate handler | `backend/internal/api/handlers/certificate_handler.go` | M | `POST /certificates/validate` validation-only endpoint. | +| 2.6 | Modify Delete handler | `backend/internal/api/handlers/certificate_handler.go` | S | Use UUID param instead of numeric ID. | +| 2.7 | Register new routes | `backend/internal/api/routes/routes.go` | S | Add new routes, pass encryption service. | +| 2.8 | Update Caddy config generation | `backend/internal/caddy/config.go` | M | Decrypt keys, include chains, skip ACME for custom cert domains. | +| 2.9 | Call migration on startup | `backend/internal/api/routes/routes.go` | S | Call `MigratePrivateKeys()` after service init. | +| 2.10 | Handler unit tests | `backend/internal/api/handlers/certificate_handler_test.go` | L | Test all new endpoints. | +| 2.11 | Caddy config tests | `backend/internal/caddy/config_test.go` | M | Update existing tests, add encrypted key test. | + +### Phase 3: Expiry Warnings & Notifications (within Commit 2) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 3.1 | Add `CheckExpiringCertificates()` | `backend/internal/services/certificate_service.go` | M | Query custom certs expiring in 30 days or less. | +| 3.2 | Add `StartExpiryChecker()` | `backend/internal/services/certificate_service.go` | M | Background goroutine, daily tick, rate-limited notifications. | +| 3.3 | Wire scheduler on startup | `backend/internal/api/routes/routes.go` | S | Start goroutine with context from server. | +| 3.4 | Unit tests for expiry checker | `backend/internal/services/certificate_service_test.go` | M | Mock time, verify notification calls. | + +### Phase 4: Frontend — Enhanced Upload, Detail, Export (Commit 3) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 4.1 | Create `FileDropZone` component | `frontend/src/components/ui/FileDropZone.tsx` | M | Reusable drag-and-drop with format badge. | +| 4.2 | Create `CertificateUploadDialog` | `frontend/src/components/dialogs/CertificateUploadDialog.tsx` | L | Full upload dialog with validation preview, chain, PFX. | +| 4.3 | Create `CertificateValidationPreview` | `frontend/src/components/CertificateValidationPreview.tsx` | M | Parsed cert preview before upload. | +| 4.4 | Create `CertificateDetailDialog` | `frontend/src/components/dialogs/CertificateDetailDialog.tsx` | L | Full metadata, chain, assigned hosts, export action. | +| 4.5 | Create `CertificateChainViewer` | `frontend/src/components/CertificateChainViewer.tsx` | M | Visual chain display. | +| 4.6 | Create `CertificateExportDialog` | `frontend/src/components/dialogs/CertificateExportDialog.tsx` | M | Format + key options. | +| 4.7 | Update `CertificateList` | `frontend/src/components/CertificateList.tsx` | M | Add detail/export buttons, use UUID, expiry badges. | +| 4.8 | Refactor `Certificates` page | `frontend/src/pages/Certificates.tsx` | M | Use new dialog components. | +| 4.9 | Update API client | `frontend/src/api/certificates.ts` | M | New functions, updated types. | +| 4.10 | Update hooks | `frontend/src/hooks/useCertificates.ts` | M | New hooks for detail, export, validate, update. | +| 4.11 | Add translations | `frontend/src/locales/en/translation.json` (+ other locales) | S | New keys for chain, export, validation messages. | +| 4.12 | Frontend unit tests | `frontend/src/components/__tests__/` | L | Tests for new components. | +| 4.13 | Vitest coverage | — | M | Ensure 85% coverage on new code. | + +### Phase 5: E2E Tests & Hardening (Commit 4) + +| # | Task | File(s) | Size | Description | +|---|------|---------|------|-------------| +| 5.1 | E2E: Certificate upload flow | `tests/certificate-upload.spec.ts` | L | Upload PEM cert + key, validate preview, verify list. | +| 5.2 | E2E: Certificate detail view | `tests/certificate-detail.spec.ts` | M | Open detail dialog, verify metadata, chain view. | +| 5.3 | E2E: Certificate export | `tests/certificate-export.spec.ts` | M | Export PEM, verify download blob. | +| 5.4 | E2E: Certificate assignment | `tests/certificate-assignment.spec.ts` | M | Assign cert to proxy host, verify Caddy reload. | +| 5.5 | Update existing delete tests | `tests/certificate-delete.spec.ts` | S | Use UUID instead of numeric ID. | +| 5.6 | CodeQL scans | — | S | Run Go + JS security scans. | +| 5.7 | GORM security scan | — | S | Final scan on all model changes. | +| 5.8 | Update documentation | `docs/features.md`, `CHANGELOG.md` | S | Document new capabilities. | + +--- + +## 8. Commit Slicing Strategy + +### Decision: 1 PR with 5 logical commits + +**Rationale**: Single feature = single PR. Charon is a self-hosted tool where users track merged PRs to know when features are available. Merging partial PRs (e.g., backend-only) creates false confidence that a feature is complete, leading to user-filed issues and discussions asking why the feature is missing or broken. A single PR ensures the feature ships atomically — users see one merge and get the full capability. + +Each commit maps to an implementation phase; this keeps the diff reviewable by walking through commits sequentially while guaranteeing the feature is never partially deployed. + +### Commit Structure + +| Commit | Phase | Scope | Key Files | +|--------|-------|-------|-----------| +| **Commit 1** | Backend Foundation | Tasks 1.1–1.17 | `backend/internal/models/ssl_certificate.go`, `backend/internal/services/certificate_validator.go`, `backend/internal/services/certificate_service.go`, `backend/go.mod`, `backend/go.sum`, test files | +| **Commit 2** | Backend API + Caddy + Expiry | Tasks 2.1–2.11, 3.1–3.4 | `backend/internal/api/handlers/certificate_handler.go`, `backend/internal/api/routes/routes.go`, `backend/internal/caddy/config.go`, test files | +| **Commit 3** | Frontend | Tasks 4.1–4.13 | `frontend/src/` components, pages, API client, hooks, locales | +| **Commit 4** | E2E Tests & Hardening | Tasks 5.1–5.7 | `tests/` E2E specs, CodeQL/GORM scans | +| **Commit 5** | Documentation | Task 5.8 | `docs/features.md`, `CHANGELOG.md` | + +### Commit Descriptions + +#### Commit 1: Backend Foundation (Model + Validator + Encryption) +- SSLCertificate model with all new fields and correct JSON tags +- Certificate validator: PEM, DER, PFX parsing, key-cert match, chain validation +- Private key encryption/decryption via `CHARON_ENCRYPTION_KEY` +- Migration function for existing plaintext keys +- Unit tests with 85% coverage on new code +- GORM security scan clean + +#### Commit 2: Backend API (Handlers + Routes + Caddy + Expiry Checker) +- Upload endpoint accepts PEM/PFX/DER with safe bounded reads +- Get/Export/Validate/Update endpoints (UUID-based) +- Delete uses UUID instead of numeric ID +- Caddy loads encrypted custom certs with chain support +- Expiry checker: background goroutine, daily tick, notifications for certs expiring within 30 days +- Handler and Caddy config unit tests + +#### Commit 3: Frontend (Upload + Detail + Export + UI Enhancements) +- Enhanced upload dialog with drag-and-drop, format detection, chain file, PFX password +- Validation preview before upload +- Certificate detail dialog with chain viewer +- Export dialog with format selection and key password confirmation +- List uses UUID for all operations, expiry warning badges +- Vitest coverage at 85% on new components + +#### Commit 4: E2E Tests & Hardening +- E2E tests covering upload, detail, export, assignment flows +- Existing delete tests updated for UUID +- CodeQL Go + JS scans clean (no HIGH/CRITICAL) +- GORM security scan clean + +#### Commit 5: Documentation +- `docs/features.md` updated with certificate management capabilities +- `CHANGELOG.md` updated + +### PR-Level Validation Gates + +The PR is merged only when **all** of the following pass: + +- [ ] All backend unit tests pass with 85% coverage on new code +- [ ] All frontend Vitest tests pass with 85% coverage on new code +- [ ] All E2E tests pass (Firefox, Chromium, WebKit) +- [ ] GORM security scan clean (`./scripts/scan-gorm-security.sh --check`) +- [ ] CodeQL Go + JS scans: no HIGH/CRITICAL findings +- [ ] staticcheck pass +- [ ] TypeScript check pass +- [ ] Local patch coverage report generated and reviewed +- [ ] Documentation updated + +### Rollback + +Revert the single PR. All changes are additive (new columns, new endpoints, new components). Reverting removes the feature atomically with no partial state left in production. + +--- + +## 9. Testing Strategy + +### 9.1 Backend Unit Tests + +| Test File | Coverage | +|-----------|----------| +| `backend/internal/services/certificate_validator_test.go` | PEM/DER/PFX parsing, key match (RSA + ECDSA), chain validation (valid/invalid/self-signed), format detection, error cases | +| `backend/internal/services/certificate_service_test.go` | Upload (all formats), encryption/decryption, migration, list (with new fields), get detail, export (all formats), delete by UUID, expiry checker, cache invalidation | +| `backend/internal/api/handlers/certificate_handler_test.go` | All endpoints: upload (multipart), get, export (file download), validate, update, delete; error cases (invalid format, missing key, expired cert) | +| `backend/internal/caddy/config_test.go` | Custom cert with encrypted key, chain inclusion, ACME skip for custom cert domains | + +### 9.2 Frontend Unit Tests + +| Test File | Coverage | +|-----------|----------| +| `frontend/src/components/__tests__/FileDropZone.test.tsx` | Drag-and-drop, file selection, format detection badge | +| `frontend/src/components/__tests__/CertificateUploadDialog.test.tsx` | Full upload flow, PFX mode toggle, validation preview | +| `frontend/src/components/__tests__/CertificateDetailDialog.test.tsx` | Metadata display, chain viewer, export action | +| `frontend/src/components/__tests__/CertificateExportDialog.test.tsx` | Format selection, key toggle, PFX password | +| `frontend/src/components/__tests__/CertificateList.test.tsx` | Updated: UUID-based actions, expiry badges, detail button | +| `frontend/src/hooks/__tests__/useCertificates.test.ts` | New hooks: detail, export, validate | + +### 9.3 E2E Playwright Tests + +| Spec File | Scenarios | +|-----------|-----------| +| `tests/certificate-upload.spec.ts` | Upload PEM cert + key, validate preview, verify list | +| `tests/certificate-detail.spec.ts` | Open detail dialog, verify metadata, chain view | +| `tests/certificate-export.spec.ts` | Export PEM, verify download blob | +| `tests/certificate-assignment.spec.ts` | Assign cert to proxy host, verify Caddy reload | +| `tests/certificate-delete.spec.ts` | Updated: UUID-based deletion | +| `tests/certificate-bulk-delete.spec.ts` | Updated: UUID-based bulk deletion | + +#### Negative / Error Scenarios (Commit 4) + +| Spec File | Scenarios | +|-----------|-----------| +| `tests/certificate-upload-errors.spec.ts` | Mismatched key/cert upload (expect error), invalid file upload (non-cert file), expired cert upload (expect warning + accept), oversized file upload (expect 413) | +| `tests/certificate-export-auth.spec.ts` | Export with `include_key: true` flow — verify password confirmation required, verify incorrect password rejected, verify export without key does not require password | + +### 9.4 Security Scans + +- GORM security scan (`./scripts/scan-gorm-security.sh --check`) — after Phase 1 +- CodeQL Go scan — after Phase 2 +- CodeQL JS scan — after Phase 3 +- Trivy container scan — after final build + +--- + +## 10. Config/Infrastructure Changes + +### 10.1 No Changes Required + +| File | Reason | +|------|--------| +| `.gitignore` | Uploaded certificates stored in database, not on disk. Existing `/data/` ignore covers Caddy runtime data. | +| `codecov.yml` | Existing configuration covers `backend/` and `frontend/src/`. | +| `.dockerignore` | No new file types to ignore. | +| `Dockerfile` | `go-pkcs12` dependency is a Go module pulled during build automatically. | + +### 10.2 Environment Variables + +| Variable | Status | Description | +|----------|--------|-------------| +| `CHARON_ENCRYPTION_KEY` | Existing | Required for private key encryption. Already used for DNS provider credentials. | +| `CHARON_ENCRYPTION_KEY_NEXT` | Existing | Used during key rotation. Rotation service already handles re-encryption. | +| `CHARON_CERT_EXPIRY_WARNING_DAYS` | New (optional) | Override default 30-day warning threshold. Default: `30`. Wired into `StartExpiryChecker()` at startup — see Section 4.7. | + +### 10.3 Database Migration + +GORM `AutoMigrate` handles additive column changes automatically. The private key migration from plaintext to encrypted is a one-time startup operation handled in code (see section 4.5). + +**Migration sequence**: + +1. GORM adds new columns (`common_name`, `certificate_chain`, `private_key_enc`, `key_version`, `fingerprint`, `serial_number`, `issuer_org`, `key_type`, `not_before`) +2. `MigratePrivateKeys()` runs once: reads `private_key`, encrypts to `private_key_enc`, clears `private_key` +3. Subsequent starts skip migration (checks if any rows have `private_key` non-empty and `private_key_enc` empty) + +--- + +## 11. Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| **Private key migration fails mid-way** | Low | High | Migration is transactional per-row. Idempotent — can be re-run. Original `private_key` column kept until migration verified complete. | +| **`CHARON_ENCRYPTION_KEY` not set** | Medium | High | Graceful degradation: upload/export of custom certs disabled when key not set. Clear error message in UI. ACME certs unaffected. | +| **PFX parsing edge cases** | Medium | Medium | Use well-maintained `go-pkcs12` library. Comprehensive test suite with real-world PFX files. Fall back to descriptive error messages. | +| **Caddy reload failure with bad cert** | Low | High | Caddy config generation validates cert/key pairing before including in config. Caddy itself validates on load and reports errors. Rollback logic already exists in Caddy manager. | +| **Breaking API change (numeric ID to UUID)** | Medium | Medium | Frontend and backend changes in separate PRs but deployed together. No external API consumers currently (self-hosted tool). Existing E2E tests catch regressions. | +| **Performance impact of encryption/decryption** | Low | Low | AES-256-GCM is hardware-accelerated on modern CPUs. Only custom certs are encrypted (typically fewer than 10 per instance). Caddy config generation is not a hot path. | +| **Large file upload DoS** | Low | Medium | 1MB file size limit enforced in handler. Gin's `MaxMultipartMemory` also provides protection. | + +--- + +## 12. Acceptance Criteria (Definition of Done) + +- [ ] Can upload custom certificates in PEM, PFX, and DER formats +- [ ] Certificate and key are validated before acceptance (format, key match, chain) +- [ ] Private keys are encrypted at rest using `CHARON_ENCRYPTION_KEY` +- [ ] Certificate detail view shows full metadata (CN, SANs, issuer, chain, fingerprint) +- [ ] Certificates can be assigned to proxy hosts +- [ ] Caddy serves custom certificates for assigned domains +- [ ] Expiry warnings fire as in-app and external notifications at 30 days +- [ ] Certificates can be exported in PEM, PFX, and DER formats +- [ ] All API endpoints use UUID (no numeric ID exposure) +- [ ] 85% test coverage on all new backend and frontend code +- [ ] E2E tests pass for upload, detail, export, assignment flows +- [ ] GORM security scan reports zero CRITICAL/HIGH findings +- [ ] CodeQL scans report zero HIGH/CRITICAL findings +- [ ] No plaintext private keys in database after migration + +--- + +## Root Cause Analysis: E2E Certificate Test Failures (PR #928) + +**Date**: 2026-06-24 +**Scope**: 4 failing tests in `tests/core/certificates.spec.ts` +**Branch**: `feature/beta-release` + +### Failing Tests + +| # | Test Name | Line | Test Describe Block | +|---|-----------|------|---------------------| +| 1 | should validate required name field | L349 | Upload Dialog | +| 2 | should require certificate file | L375 | Upload Dialog | +| 3 | should require private key file | L400 | Upload Dialog | +| 4 | should reject empty friendly name | L776 | Form Validation | + +--- + +### Root Cause Summary + +There are **two layers** of failure. Layer 1 is the primary blocker in CI. Layer 2 contains test-logic defects that would surface even after Layer 1 is resolved. + +#### Layer 1: Infrastructure — Disabled Submit Button Blocks All Validation Tests + +**Classification**: Test Issue +**Severity**: CRITICAL — blocks all 4 tests + +**Mechanism**: + +The `CertificateUploadDialog` submit button is governed by: + +```tsx +// frontend/src/components/dialogs/CertificateUploadDialog.tsx +const canSubmit = !!certFile && !!name.trim() + + - - - {t('certificates.deleteInUse')} - - - - ) - } +
+ + + {(() => { + if (inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) { + return ( + + + + + + + {t('certificates.deleteInUse')} + + + + ) + } - if (deletable) { - return ( - - ) - } + if (deletable) { + return ( + + ) + } - return null - })()} + return null + })()} +
) @@ -347,20 +358,44 @@ export default function CertificateList() { certificate={certToDelete} open={certToDelete !== null} onConfirm={() => { - if (certToDelete?.id) { - deleteMutation.mutate(certToDelete.id) + if (certToDelete?.uuid) { + handleDelete(certToDelete) } }} onCancel={() => setCertToDelete(null)} isDeleting={deleteMutation.isPending} /> c.id && selectedIds.has(c.id))} + certificates={sortedCertificates.filter(c => selectedIds.has(c.uuid))} open={showBulkDeleteDialog} - onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))} + onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds), { + onSuccess: ({ succeeded, failed }) => { + setSelectedIds(new Set()) + setShowBulkDeleteDialog(false) + if (failed > 0) { + toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed })) + } else { + toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded })) + } + }, + onError: () => { + toast.error(t('certificates.bulkDeleteFailed')) + setShowBulkDeleteDialog(false) + }, + })} onCancel={() => setShowBulkDeleteDialog(false)} isDeleting={bulkDeleteMutation.isPending} /> + { if (!open) setCertToView(null) }} + /> + { if (!open) setCertToExport(null) }} + /> ) } diff --git a/frontend/src/components/CertificateStatusCard.tsx b/frontend/src/components/CertificateStatusCard.tsx index 65f987294..ac4cc00fe 100644 --- a/frontend/src/components/CertificateStatusCard.tsx +++ b/frontend/src/components/CertificateStatusCard.tsx @@ -25,9 +25,9 @@ export default function CertificateStatusCard({ certificates, hosts, isLoading } const domains = new Set() for (const cert of certificates) { // Handle missing or undefined domain field - if (!cert.domain) continue - // Certificate domain field can be comma-separated - for (const d of cert.domain.split(',')) { + if (!cert.domains) continue + // Certificate domains field can be comma-separated + for (const d of cert.domains.split(',')) { const trimmed = d.trim().toLowerCase() if (trimmed) domains.add(trimmed) } diff --git a/frontend/src/components/CertificateValidationPreview.tsx b/frontend/src/components/CertificateValidationPreview.tsx new file mode 100644 index 000000000..9e5055d55 --- /dev/null +++ b/frontend/src/components/CertificateValidationPreview.tsx @@ -0,0 +1,107 @@ +import { AlertTriangle, CheckCircle, XCircle } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import type { ValidationResult } from '../api/certificates' + +interface CertificateValidationPreviewProps { + result: ValidationResult +} + +export default function CertificateValidationPreview({ + result, +}: CertificateValidationPreviewProps) { + const { t } = useTranslation() + + return ( +
+
+ {result.valid ? ( +
+ +
+
{t('certificates.commonName')}
+
{result.common_name || '-'}
+ +
{t('certificates.domains')}
+
+ {result.domains?.length ? result.domains.join(', ') : '-'} +
+ +
{t('certificates.issuerOrg')}
+
{result.issuer_org || '-'}
+ +
{t('certificates.expiresAt')}
+
+ {result.expires_at ? new Date(result.expires_at).toLocaleDateString() : '-'} +
+ +
{t('certificates.keyMatch')}
+
+ {result.key_match ? ( + Yes + ) : ( + No key provided + )} +
+ +
{t('certificates.chainValid')}
+
+ {result.chain_valid ? ( + Yes + ) : ( + Not verified + )} +
+ + {result.chain_depth > 0 && ( + <> +
{t('certificates.chainDepth')}
+
{result.chain_depth}
+ + )} +
+ + {result.warnings.length > 0 && ( +
+
+ )} + + {result.errors.length > 0 && ( +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 0a77144f9..549ed8260 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -123,7 +123,7 @@ function buildInitialFormData(host?: ProxyHost): Partial & { application: (host?.application || 'none') as ApplicationPreset, advanced_config: host?.advanced_config || '', enabled: host?.enabled ?? true, - certificate_id: host?.certificate_id, + certificate_id: host?.certificate?.uuid ?? host?.certificate_id, access_list_id: host?.access_list?.uuid ?? host?.access_list_id, security_header_profile_id: host?.security_header_profile?.uuid ?? host?.security_header_profile_id, dns_provider_id: host?.dns_provider_id || null, @@ -249,9 +249,10 @@ function getEntityToken(entity: { id?: number; uuid?: string }): string | null { } export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) { - type ProxyHostFormState = Omit, 'access_list_id' | 'security_header_profile_id'> & { + type ProxyHostFormState = Omit, 'access_list_id' | 'security_header_profile_id' | 'certificate_id'> & { access_list_id?: number | string | null security_header_profile_id?: number | string | null + certificate_id?: number | string | null addUptime?: boolean uptimeInterval?: number uptimeMaxRetries?: number @@ -562,6 +563,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor ...payloadWithoutUptime, access_list_id: normalizeAccessListReference(payloadWithoutUptime.access_list_id), security_header_profile_id: normalizeSecurityHeaderReference(payloadWithoutUptime.security_header_profile_id), + certificate_id: normalizeAccessListReference(payloadWithoutUptime.certificate_id), } const res = await onSubmit(submitPayload) @@ -910,18 +912,25 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor - setFormData(prev => ({ ...prev, certificate_id: resolveTokenToFormValue(token) }))} + > - Auto-manage with Let's Encrypt (recommended) - {certificates.map(cert => ( - - {(cert.name || cert.domain)} - {cert.provider ? ` (${cert.provider})` : ''} - - ))} + Auto-manage with Let's Encrypt (recommended) + {certificates.map(cert => { + const token = getEntityToken(cert) + if (!token) return null + return ( + + {cert.name || cert.domains} + {cert.provider ? ` (${cert.provider})` : ''} + + ) + })}

diff --git a/frontend/src/components/__tests__/CertificateChainViewer.test.tsx b/frontend/src/components/__tests__/CertificateChainViewer.test.tsx new file mode 100644 index 000000000..cb88c7fd2 --- /dev/null +++ b/frontend/src/components/__tests__/CertificateChainViewer.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +import type { ChainEntry } from '../../api/certificates' +import CertificateChainViewer from '../CertificateChainViewer' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +function makeChain(count: number): ChainEntry[] { + return Array.from({ length: count }, (_, i) => ({ + subject: `Subject ${i}`, + issuer: `Issuer ${i}`, + expires_at: '2026-06-01T00:00:00Z', + })) +} + +describe('CertificateChainViewer', () => { + it('renders empty state when chain is empty', () => { + render() + expect(screen.getByText('certificates.noChainData')).toBeTruthy() + }) + + it('renders single entry as leaf', () => { + render() + expect(screen.getByText('certificates.chainLeaf')).toBeTruthy() + expect(screen.getByText('Subject 0')).toBeTruthy() + }) + + it('renders two entries as leaf + root', () => { + render() + expect(screen.getByText('certificates.chainLeaf')).toBeTruthy() + expect(screen.getByText('certificates.chainRoot')).toBeTruthy() + }) + + it('renders three entries as leaf + intermediate + root', () => { + render() + expect(screen.getByText('certificates.chainLeaf')).toBeTruthy() + expect(screen.getByText('certificates.chainIntermediate')).toBeTruthy() + expect(screen.getByText('certificates.chainRoot')).toBeTruthy() + }) + + it('displays issuer for each entry', () => { + render() + expect(screen.getByText(/Issuer 0/)).toBeTruthy() + expect(screen.getByText(/Issuer 1/)).toBeTruthy() + }) + + it('displays formatted expiration dates', () => { + render() + const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString() + expect(screen.getByText(new RegExp(dateStr))).toBeTruthy() + }) + + it('uses list role with list items', () => { + render() + expect(screen.getByRole('list')).toBeTruthy() + expect(screen.getAllByRole('listitem')).toHaveLength(2) + }) + + it('has aria-label on list', () => { + render() + expect(screen.getByRole('list').getAttribute('aria-label')).toBe( + 'certificates.certificateChain', + ) + }) +}) diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx index ea63b910b..7e2d45288 100644 --- a/frontend/src/components/__tests__/CertificateList.test.tsx +++ b/frontend/src/components/__tests__/CertificateList.test.tsx @@ -3,16 +3,18 @@ import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { useCertificates } from '../../hooks/useCertificates' -import { useProxyHosts } from '../../hooks/useProxyHosts' +import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../../hooks/useCertificates' import { createTestQueryClient } from '../../test/createTestQueryClient' import CertificateList, { isDeletable, isInUse } from '../CertificateList' import type { Certificate } from '../../api/certificates' -import type { ProxyHost } from '../../api/proxyHosts' vi.mock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(), + useCertificateDetail: vi.fn(() => ({ detail: null, isLoading: false })), + useDeleteCertificate: vi.fn(), + useBulkDeleteCertificates: vi.fn(), + useExportCertificate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), })) vi.mock('../../api/certificates', () => ({ @@ -30,10 +32,6 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('../../hooks/useProxyHosts', () => ({ - useProxyHosts: vi.fn(), -})) - vi.mock('../../utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, })) @@ -43,14 +41,26 @@ function renderWithClient(ui: React.ReactNode) { return render({ui}) } +const makeCert = (overrides: Partial = {}): Certificate => ({ + uuid: 'cert-1', + domains: 'example.com', + issuer: 'Custom CA', + expires_at: '2026-03-01T00:00:00Z', + status: 'valid', + provider: 'custom', + has_key: true, + in_use: false, + ...overrides, +}) + const createCertificatesValue = (overrides: Partial> = {}) => { const certificates: Certificate[] = [ - { id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'expired', provider: 'custom' }, - { id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: '2026-04-01T00:00:00Z', status: 'untrusted', provider: 'letsencrypt-staging' }, - { id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: '2026-02-01T00:00:00Z', status: 'valid', provider: 'custom' }, - { id: 4, name: 'UnusedValidCert', domain: 'unused.example.com', issuer: 'Custom CA', expires_at: '2026-05-01T00:00:00Z', status: 'valid', provider: 'custom' }, - { id: 5, name: 'ExpiredLE', domain: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt' }, - { id: 6, name: 'ValidLE', domain: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt' }, + makeCert({ uuid: 'cert-1', name: 'CustomCert', domains: 'example.com', status: 'expired', in_use: false }), + makeCert({ uuid: 'cert-2', name: 'LE Staging', domains: 'staging.example.com', issuer: "Let's Encrypt Staging", status: 'untrusted', provider: 'letsencrypt-staging', in_use: false }), + makeCert({ uuid: 'cert-3', name: 'ActiveCert', domains: 'active.example.com', status: 'valid', in_use: true }), + makeCert({ uuid: 'cert-4', name: 'UnusedValidCert', domains: 'unused.example.com', status: 'valid', in_use: false }), + makeCert({ uuid: 'cert-5', name: 'ExpiredLE', domains: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt', in_use: false }), + makeCert({ uuid: 'cert-6', name: 'ValidLE', domains: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt', in_use: false }), ] return { @@ -62,126 +72,68 @@ const createCertificatesValue = (overrides: Partial = {}): ProxyHost => ({ - uuid: 'h1', - name: 'Host1', - domain_names: 'host1.example.com', - forward_scheme: 'http', - forward_host: '127.0.0.1', - forward_port: 80, - ssl_forced: false, - http2_support: true, - hsts_enabled: false, - hsts_subdomains: false, - block_exploits: false, - websocket_support: false, - application: 'none', - locations: [], - enabled: true, - created_at: '2026-02-01T00:00:00Z', - updated_at: '2026-02-01T00:00:00Z', - certificate_id: 3, - ...overrides, -}) - -const createProxyHostsValue = (overrides: Partial> = {}): ReturnType => ({ - hosts: [ - createProxyHost(), - ], - loading: false, - isFetching: false, - error: null, - createHost: vi.fn(), - updateHost: vi.fn(), - deleteHost: vi.fn(), - bulkUpdateACL: vi.fn(), - bulkUpdateSecurityHeaders: vi.fn(), - isCreating: false, - isUpdating: false, - isDeleting: false, - isBulkUpdating: false, - ...overrides, -}) - const getRowNames = () => screen .getAllByRole('row') .slice(1) .map(row => row.querySelectorAll('td')[1]?.textContent?.trim() ?? '') +let deleteMutateFn: ReturnType +let bulkDeleteMutateFn: ReturnType + beforeEach(() => { vi.clearAllMocks() + deleteMutateFn = vi.fn() + bulkDeleteMutateFn = vi.fn() vi.mocked(useCertificates).mockReturnValue(createCertificatesValue()) - vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsValue()) + vi.mocked(useDeleteCertificate).mockReturnValue({ + mutate: deleteMutateFn, + isPending: false, + } as unknown as ReturnType) + vi.mocked(useBulkDeleteCertificates).mockReturnValue({ + mutate: bulkDeleteMutateFn, + isPending: false, + } as unknown as ReturnType) }) describe('CertificateList', () => { describe('isDeletable', () => { - const noHosts: ProxyHost[] = [] - const withHost = (certId: number): ProxyHost[] => [createProxyHost({ certificate_id: certId })] - it('returns true for custom cert not in use', () => { - const cert: Certificate = { id: 1, name: 'C', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } - expect(isDeletable(cert, noHosts)).toBe(true) + expect(isDeletable(makeCert({ provider: 'custom', in_use: false }))).toBe(true) }) it('returns true for staging cert not in use', () => { - const cert: Certificate = { id: 2, name: 'S', domain: 'd', issuer: 'X', expires_at: '', status: 'untrusted', provider: 'letsencrypt-staging' } - expect(isDeletable(cert, noHosts)).toBe(true) + expect(isDeletable(makeCert({ provider: 'letsencrypt-staging', in_use: false }))).toBe(true) }) it('returns true for expired LE cert not in use', () => { - const cert: Certificate = { id: 3, name: 'E', domain: 'd', issuer: 'LE', expires_at: '', status: 'expired', provider: 'letsencrypt' } - expect(isDeletable(cert, noHosts)).toBe(true) + expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expired', in_use: false }))).toBe(true) }) it('returns false for valid LE cert not in use', () => { - const cert: Certificate = { id: 4, name: 'V', domain: 'd', issuer: 'LE', expires_at: '', status: 'valid', provider: 'letsencrypt' } - expect(isDeletable(cert, noHosts)).toBe(false) + expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'valid', in_use: false }))).toBe(false) }) it('returns false for cert in use', () => { - const cert: Certificate = { id: 5, name: 'U', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } - expect(isDeletable(cert, withHost(5))).toBe(false) - }) - - it('returns false for cert without id', () => { - const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } - expect(isDeletable(cert, noHosts)).toBe(false) + expect(isDeletable(makeCert({ provider: 'custom', in_use: true }))).toBe(false) }) it('returns true for expiring LE cert not in use', () => { - const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' } - expect(isDeletable(cert, noHosts)).toBe(true) + expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: false }))).toBe(true) }) it('returns false for expiring LE cert that is in use', () => { - const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' } - expect(isDeletable(cert, withHost(7))).toBe(false) + expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: true }))).toBe(false) }) }) describe('isInUse', () => { - it('returns true when host references cert by certificate_id', () => { - const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } - expect(isInUse(cert, [createProxyHost({ certificate_id: 10 })])).toBe(true) + it('returns true when cert.in_use is true', () => { + expect(isInUse(makeCert({ in_use: true }))).toBe(true) }) - it('returns true when host references cert via certificate.id', () => { - const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } - const host = createProxyHost({ certificate_id: undefined, certificate: { id: 10, uuid: 'u', name: 'c', provider: 'custom', domains: 'd', expires_at: '' } }) - expect(isInUse(cert, [host])).toBe(true) - }) - - it('returns false when no host references cert', () => { - const cert: Certificate = { id: 99, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } - expect(isInUse(cert, [createProxyHost({ certificate_id: 3 })])).toBe(false) - }) - - it('returns false when cert.id is undefined even if a host has certificate_id undefined', () => { - const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' } - const host = createProxyHost({ certificate_id: undefined }) - expect(isInUse(cert, [host])).toBe(false) + it('returns false when cert.in_use is false', () => { + expect(isInUse(makeCert({ in_use: false }))).toBe(false) }) }) @@ -215,7 +167,6 @@ describe('CertificateList', () => { }) it('opens dialog and deletes cert on confirm', async () => { - const { deleteCertificate } = await import('../../api/certificates') const user = userEvent.setup() renderWithClient() @@ -228,7 +179,7 @@ describe('CertificateList', () => { expect(within(dialog).getByText('certificates.deleteTitle')).toBeInTheDocument() await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' })) - await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1)) + await waitFor(() => expect(deleteMutateFn).toHaveBeenCalledWith('cert-1', expect.any(Object))) }) it('does not call createBackup on delete (server handles it)', async () => { @@ -257,23 +208,6 @@ describe('CertificateList', () => { expect(await screen.findByText('Failed to load certificates')).toBeInTheDocument() }) - it('shows error toast when delete mutation fails', async () => { - const { deleteCertificate } = await import('../../api/certificates') - const { toast } = await import('../../utils/toast') - vi.mocked(deleteCertificate).mockRejectedValueOnce(new Error('Network error')) - const user = userEvent.setup() - - renderWithClient() - const rows = await screen.findAllByRole('row') - const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! - await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' })) - - const dialog = await screen.findByRole('dialog') - await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' })) - - await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network error')) - }) - it('clicking disabled delete button for in-use cert does not open dialog', async () => { const user = userEvent.setup() renderWithClient() @@ -299,7 +233,7 @@ describe('CertificateList', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) - it('renders enabled checkboxes for deletable not-in-use certs (ids 1, 2, 4, 5)', async () => { + it('renders enabled checkboxes for deletable not-in-use certs', async () => { renderWithClient() const rows = await screen.findAllByRole('row') for (const name of ['CustomCert', 'LE Staging', 'UnusedValidCert', 'ExpiredLE']) { @@ -310,7 +244,7 @@ describe('CertificateList', () => { } }) - it('renders disabled checkbox for in-use cert (id 3)', async () => { + it('renders disabled checkbox for in-use cert', async () => { renderWithClient() const rows = await screen.findAllByRole('row') const activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))! @@ -320,7 +254,7 @@ describe('CertificateList', () => { expect(rowCheckbox).toHaveAttribute('aria-disabled', 'true') }) - it('renders no checkbox in valid production LE cert row (id 6)', async () => { + it('renders no checkbox in valid production LE cert row', async () => { renderWithClient() const rows = await screen.findAllByRole('row') const validLeRow = rows.find(r => r.textContent?.includes('ValidLE'))! @@ -360,8 +294,7 @@ describe('CertificateList', () => { expect(await screen.findByRole('dialog')).toBeInTheDocument() }) - it('confirming in the bulk dialog calls deleteCertificate for each selected ID', async () => { - const { deleteCertificate } = await import('../../api/certificates') + it('confirming in the bulk dialog calls bulk delete for selected UUIDs', async () => { const user = userEvent.setup() renderWithClient() const rows = await screen.findAllByRole('row') @@ -373,16 +306,17 @@ describe('CertificateList', () => { const dialog = await screen.findByRole('dialog') await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i })) await waitFor(() => { - expect(deleteCertificate).toHaveBeenCalledWith(1) - expect(deleteCertificate).toHaveBeenCalledWith(2) + expect(bulkDeleteMutateFn).toHaveBeenCalledWith( + expect.arrayContaining(['cert-1', 'cert-2']), + expect.any(Object), + ) }) }) it('shows partial failure toast when some bulk deletes fail', async () => { - const { deleteCertificate } = await import('../../api/certificates') const { toast } = await import('../../utils/toast') - vi.mocked(deleteCertificate).mockImplementation(async (id: number) => { - if (id === 2) throw new Error('network error') + bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onSuccess }: { onSuccess: (data: { succeeded: number; failed: number }) => void }) => { + onSuccess({ succeeded: 1, failed: 1 }) }) const user = userEvent.setup() renderWithClient() @@ -410,8 +344,8 @@ describe('CertificateList', () => { it('sorts certificates by name and expiry when headers are clicked', async () => { const certificates: Certificate[] = [ - { id: 10, name: 'Zulu', domain: 'z.example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'valid', provider: 'custom' }, - { id: 11, name: 'Alpha', domain: 'a.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom' }, + makeCert({ uuid: 'cert-z', name: 'Zulu', domains: 'z.example.com', expires_at: '2026-03-01T00:00:00Z' }), + makeCert({ uuid: 'cert-a', name: 'Alpha', domains: 'a.example.com', expires_at: '2026-01-01T00:00:00Z' }), ] const user = userEvent.setup() @@ -427,4 +361,119 @@ describe('CertificateList', () => { await user.click(screen.getByText('Expires')) expect(getRowNames()).toEqual(['Zulu', 'Alpha']) }) + + it('shows success toast when single delete succeeds', async () => { + const { toast } = await import('../../utils/toast') + deleteMutateFn.mockImplementation((_uuid: string, { onSuccess }: { onSuccess: () => void }) => { + onSuccess() + }) + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' })) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' })) + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('certificates.deleteSuccess')) + }) + + it('shows error toast when single delete fails', async () => { + const { toast } = await import('../../utils/toast') + deleteMutateFn.mockImplementation((_uuid: string, { onError }: { onError: (e: Error) => void }) => { + onError(new Error('Network failure')) + }) + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' })) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network failure')) + }) + + it('shows success toast when all bulk deletes succeed', async () => { + const { toast } = await import('../../utils/toast') + bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onSuccess }: { onSuccess: (data: { succeeded: number; failed: number }) => void }) => { + onSuccess({ succeeded: 2, failed: 0 }) + }) + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + await user.click(within(rows.find(r => r.textContent?.includes('CustomCert'))!).getByRole('checkbox')) + await user.click(within(rows.find(r => r.textContent?.includes('LE Staging'))!).getByRole('checkbox')) + await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i })) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i })) + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('certificates.bulkDeleteSuccess')) + }) + + it('shows error toast when bulk delete fails entirely', async () => { + const { toast } = await import('../../utils/toast') + bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onError }: { onError: () => void }) => { + onError() + }) + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + await user.click(within(rows.find(r => r.textContent?.includes('CustomCert'))!).getByRole('checkbox')) + await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i })) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.bulkDeleteFailed')) + }) + + it('opens detail dialog when view button is clicked', async () => { + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByTestId('view-cert-cert-1')) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + }) + + it('opens export dialog when export button is clicked', async () => { + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByTestId('export-cert-cert-1')) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + }) + + it('deselects a row checkbox by clicking it a second time', async () => { + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! + const checkbox = within(customRow).getByRole('checkbox') + await user.click(checkbox) + expect(screen.getByRole('status')).toBeInTheDocument() + await user.click(checkbox) + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()) + }) + + it('closes detail dialog via the dialog close button', async () => { + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByTestId('view-cert-cert-1')) + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: 'Close' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + it('closes export dialog via the cancel button', async () => { + const user = userEvent.setup() + renderWithClient() + const rows = await screen.findAllByRole('row') + const customRow = rows.find(r => r.textContent?.includes('CustomCert'))! + await user.click(within(customRow).getByTestId('export-cert-cert-1')) + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: 'common.cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) }) diff --git a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx index 6055672a7..fb02d346f 100644 --- a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx +++ b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx @@ -8,13 +8,15 @@ import type { Certificate } from '../../api/certificates' import type { ProxyHost } from '../../api/proxyHosts' const mockCert: Certificate = { - id: 1, + uuid: 'cert-1', name: 'Test Cert', - domain: 'example.com', + domains: 'example.com', issuer: "Let's Encrypt", expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), status: 'valid', provider: 'letsencrypt', + has_key: true, + in_use: false, } const mockHost: ProxyHost = { @@ -42,13 +44,15 @@ const mockHost: ProxyHost = { // Helper to create a certificate with a specific domain function mockCertWithDomain(domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate { return { - id: Math.floor(Math.random() * 10000), + uuid: `cert-${Math.random().toString(36).slice(2, 8)}`, name: domain, - domain: domain, + domains: domain, issuer: "Let's Encrypt", expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), status, provider: 'letsencrypt', + has_key: true, + in_use: false, } } @@ -58,7 +62,7 @@ function renderWithRouter(ui: React.ReactNode) { describe('CertificateStatusCard', () => { it('shows total certificate count', () => { - const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }] + const certs: Certificate[] = [mockCert, { ...mockCert, uuid: 'cert-2' }, { ...mockCert, uuid: 'cert-3' }] renderWithRouter() expect(screen.getByText('3')).toBeInTheDocument() @@ -68,8 +72,8 @@ describe('CertificateStatusCard', () => { it('shows valid certificate count', () => { const certs: Certificate[] = [ { ...mockCert, status: 'valid' }, - { ...mockCert, id: 2, status: 'valid' }, - { ...mockCert, id: 3, status: 'expired' }, + { ...mockCert, uuid: 'cert-2', status: 'valid' }, + { ...mockCert, uuid: 'cert-3', status: 'expired' }, ] renderWithRouter() @@ -79,7 +83,7 @@ describe('CertificateStatusCard', () => { it('shows expiring count when certificates are expiring', () => { const certs: Certificate[] = [ { ...mockCert, status: 'expiring' }, - { ...mockCert, id: 2, status: 'valid' }, + { ...mockCert, uuid: 'cert-2', status: 'valid' }, ] renderWithRouter() @@ -96,7 +100,7 @@ describe('CertificateStatusCard', () => { it('shows staging count for untrusted certificates', () => { const certs: Certificate[] = [ { ...mockCert, status: 'untrusted' }, - { ...mockCert, id: 2, status: 'untrusted' }, + { ...mockCert, uuid: 'cert-2', status: 'untrusted' }, ] renderWithRouter() @@ -206,7 +210,7 @@ describe('CertificateStatusCard - Domain Matching', () => { it('handles comma-separated certificate domains', () => { const certs: Certificate[] = [{ ...mockCertWithDomain('example.com'), - domain: 'example.com, www.example.com' + domains: 'example.com, www.example.com' }] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true } @@ -295,7 +299,7 @@ describe('CertificateStatusCard - Domain Matching', () => { it('handles whitespace in certificate domains', () => { const certs: Certificate[] = [{ ...mockCertWithDomain('example.com'), - domain: ' example.com ' + domains: ' example.com ' }] const hosts: ProxyHost[] = [ { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } diff --git a/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx b/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx new file mode 100644 index 000000000..5f97a2886 --- /dev/null +++ b/frontend/src/components/__tests__/CertificateValidationPreview.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +import type { ValidationResult } from '../../api/certificates' +import CertificateValidationPreview from '../CertificateValidationPreview' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +function makeResult(overrides: Partial = {}): ValidationResult { + return { + valid: true, + common_name: 'example.com', + domains: ['example.com', 'www.example.com'], + issuer_org: 'Test CA', + expires_at: '2026-06-01T00:00:00Z', + key_match: true, + chain_valid: true, + chain_depth: 2, + warnings: [], + errors: [], + ...overrides, + } +} + +describe('CertificateValidationPreview', () => { + it('renders valid certificate state', () => { + render() + expect(screen.getByText('certificates.validCertificate')).toBeTruthy() + expect(screen.getByTestId('certificate-validation-preview')).toBeTruthy() + }) + + it('renders invalid certificate state', () => { + render( + , + ) + expect(screen.getByText('certificates.invalidCertificate')).toBeTruthy() + }) + + it('displays common name', () => { + render() + expect(screen.getByText('example.com')).toBeTruthy() + }) + + it('displays domains joined by comma', () => { + render() + expect(screen.getByText('example.com, www.example.com')).toBeTruthy() + }) + + it('displays dash when no domains provided', () => { + render( + , + ) + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThan(0) + }) + + it('displays issuer organization', () => { + render() + expect(screen.getByText('Test CA')).toBeTruthy() + }) + + it('displays formatted expiration date', () => { + render() + const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString() + expect(screen.getByText(dateStr)).toBeTruthy() + }) + + it('shows Yes for key match', () => { + render() + expect(screen.getByText('Yes')).toBeTruthy() + }) + + it('shows No key provided when no key match', () => { + render( + , + ) + expect(screen.getByText('No key provided')).toBeTruthy() + }) + + it('shows chain depth when > 0', () => { + render( + , + ) + expect(screen.getByText('3')).toBeTruthy() + }) + + it('does not show chain depth when 0', () => { + render( + , + ) + expect(screen.queryByText('certificates.chainDepth')).toBeFalsy() + }) + + it('renders warnings when present', () => { + render( + , + ) + expect(screen.getByText('certificates.warnings')).toBeTruthy() + expect(screen.getByText('Expiring soon')).toBeTruthy() + expect(screen.getByText('Weak key')).toBeTruthy() + }) + + it('does not render warnings section when empty', () => { + render() + expect(screen.queryByText('certificates.warnings')).toBeFalsy() + }) + + it('renders errors when present', () => { + render( + , + ) + expect(screen.getByText('certificates.errors')).toBeTruthy() + expect(screen.getByText('Certificate revoked')).toBeTruthy() + }) + + it('does not render errors section when empty', () => { + render() + expect(screen.queryByText('certificates.errors')).toBeFalsy() + }) + + it('has correct region role and aria-label', () => { + render() + const region = screen.getByRole('region') + expect(region.getAttribute('aria-label')).toBe('certificates.validationPreview') + }) +}) diff --git a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx index 17f867ac5..458cb782e 100644 --- a/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx +++ b/frontend/src/components/dialogs/BulkDeleteCertificateDialog.tsx @@ -64,10 +64,10 @@ export default function BulkDeleteCertificateDialog({ > {certificates.map((cert) => (

  • - {cert.name || cert.domain} + {cert.name || cert.domains} {providerLabel(cert, t)}
  • ))} diff --git a/frontend/src/components/dialogs/CertificateCleanupDialog.tsx b/frontend/src/components/dialogs/CertificateCleanupDialog.tsx index 210849b16..594ed9d38 100644 --- a/frontend/src/components/dialogs/CertificateCleanupDialog.tsx +++ b/frontend/src/components/dialogs/CertificateCleanupDialog.tsx @@ -3,7 +3,7 @@ import { AlertTriangle } from 'lucide-react' interface CertificateCleanupDialogProps { onConfirm: (deleteCerts: boolean) => void onCancel: () => void - certificates: Array<{ id: number; name: string; domain: string }> + certificates: Array<{ uuid: string; name: string; domain: string }> hostNames: string[] isBulk?: boolean } @@ -82,7 +82,7 @@ export default function CertificateCleanupDialog({

      {certificates.map((cert) => ( -
    • +
    • {cert.name || cert.domain} ({cert.domain}) diff --git a/frontend/src/components/dialogs/CertificateDetailDialog.tsx b/frontend/src/components/dialogs/CertificateDetailDialog.tsx new file mode 100644 index 000000000..a8b38385f --- /dev/null +++ b/frontend/src/components/dialogs/CertificateDetailDialog.tsx @@ -0,0 +1,143 @@ +import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import type { Certificate } from '../../api/certificates' +import { useCertificateDetail } from '../../hooks/useCertificates' +import CertificateChainViewer from '../CertificateChainViewer' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '../ui' + +interface CertificateDetailDialogProps { + certificate: Certificate | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export default function CertificateDetailDialog({ + certificate, + open, + onOpenChange, +}: CertificateDetailDialogProps) { + const { t } = useTranslation() + + const { detail, isLoading } = useCertificateDetail( + open && certificate ? certificate.uuid : null, + ) + + return ( + + + + {t('certificates.detailTitle')} + + + {isLoading && ( +
      +
      + )} + + {detail && ( +
      +
      +
      +
      {t('certificates.friendlyName')}
      +
      {detail.name || '-'}
      + +
      {t('certificates.commonName')}
      +
      {detail.common_name || '-'}
      + +
      {t('certificates.domains')}
      +
      {detail.domains || '-'}
      + +
      {t('certificates.issuerOrg')}
      +
      {detail.issuer_org || detail.issuer || '-'}
      + +
      {t('certificates.fingerprint')}
      +
      + {detail.fingerprint || '-'} +
      + +
      {t('certificates.serialNumber')}
      +
      + {detail.serial_number || '-'} +
      + +
      {t('certificates.keyType')}
      +
      {detail.key_type || '-'}
      + +
      {t('certificates.status')}
      +
      {detail.status}
      + +
      {t('certificates.provider')}
      +
      {detail.provider}
      + +
      {t('certificates.notBefore')}
      +
      + {detail.not_before ? new Date(detail.not_before).toLocaleDateString() : '-'} +
      + +
      {t('certificates.expiresAt')}
      +
      + {detail.expires_at ? new Date(detail.expires_at).toLocaleDateString() : '-'} +
      + +
      {t('certificates.autoRenew')}
      +
      + {detail.auto_renew ? t('common.yes') : t('common.no')} +
      + +
      {t('certificates.createdAt')}
      +
      + {detail.created_at ? new Date(detail.created_at).toLocaleDateString() : '-'} +
      + +
      {t('certificates.updatedAt')}
      +
      + {detail.updated_at ? new Date(detail.updated_at).toLocaleDateString() : '-'} +
      +
      +
      + +
      +

      + {t('certificates.assignedHosts')} +

      + {detail.assigned_hosts?.length > 0 ? ( +
        + {detail.assigned_hosts.map((host) => ( +
      • + {host.name} + {host.domain_names} +
      • + ))} +
      + ) : ( +

      + {t('certificates.noAssignedHosts')} +

      + )} +
      + +
      +

      + {t('certificates.certificateChain')} +

      + +
      +
      + )} +
      +
      + ) +} diff --git a/frontend/src/components/dialogs/CertificateExportDialog.tsx b/frontend/src/components/dialogs/CertificateExportDialog.tsx new file mode 100644 index 000000000..fbe078238 --- /dev/null +++ b/frontend/src/components/dialogs/CertificateExportDialog.tsx @@ -0,0 +1,187 @@ +import { Download } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { Certificate } from '../../api/certificates' +import { useExportCertificate } from '../../hooks/useCertificates' +import { toast } from '../../utils/toast' +import { + Button, + Input, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + Label, +} from '../ui' + +interface CertificateExportDialogProps { + certificate: Certificate | null + open: boolean + onOpenChange: (open: boolean) => void +} + +const FORMAT_OPTIONS = [ + { value: 'pem', label: 'exportFormatPem' }, + { value: 'pfx', label: 'exportFormatPfx' }, + { value: 'der', label: 'exportFormatDer' }, +] as const + +export default function CertificateExportDialog({ + certificate, + open, + onOpenChange, +}: CertificateExportDialogProps) { + const { t } = useTranslation() + + const [format, setFormat] = useState('pem') + const [includeKey, setIncludeKey] = useState(false) + const [password, setPassword] = useState('') + const [pfxPassword, setPfxPassword] = useState('') + + const exportMutation = useExportCertificate() + + function resetForm() { + setFormat('pem') + setIncludeKey(false) + setPassword('') + setPfxPassword('') + } + + function handleClose(nextOpen: boolean) { + if (!nextOpen) resetForm() + onOpenChange(nextOpen) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!certificate) return + + exportMutation.mutate( + { + uuid: certificate.uuid, + format, + includeKey, + password: includeKey ? password : undefined, + pfxPassword: format === 'pfx' ? pfxPassword : undefined, + }, + { + onSuccess: (blob) => { + const ext = format === 'pfx' ? 'pfx' : format === 'der' ? 'der' : 'pem' + const filename = `${certificate.name || 'certificate'}.${ext}` + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + URL.revokeObjectURL(url) + a.remove() + toast.success(t('certificates.exportSuccess')) + handleClose(false) + }, + onError: (error: Error) => { + toast.error(`${t('certificates.exportFailed')}: ${error.message}`) + }, + }, + ) + } + + return ( + + + + + + + +
      +
      + +
      + {FORMAT_OPTIONS.map((opt) => ( + + ))} +
      +
      + + {certificate?.has_key && ( +
      + setIncludeKey(e.target.checked)} + className="mt-1 h-4 w-4 rounded border-gray-700 bg-surface-muted text-brand-500 focus:ring-brand-500" + /> +
      + + {includeKey && ( +

      + {t('certificates.includePrivateKeyWarning')} +

      + )} +
      +
      + )} + + {includeKey && ( + setPassword(e.target.value)} + required + aria-required="true" + autoComplete="current-password" + /> + )} + + {format === 'pfx' && ( + setPfxPassword(e.target.value)} + autoComplete="off" + /> + )} + + + + + +
      +
      +
      + ) +} diff --git a/frontend/src/components/dialogs/CertificateUploadDialog.tsx b/frontend/src/components/dialogs/CertificateUploadDialog.tsx new file mode 100644 index 000000000..0d538a5af --- /dev/null +++ b/frontend/src/components/dialogs/CertificateUploadDialog.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ValidationResult } from '../../api/certificates' +import CertificateValidationPreview from '../CertificateValidationPreview' +import { + Button, + Input, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '../ui' +import { FileDropZone } from '../ui/FileDropZone' + +import { useUploadCertificate, useValidateCertificate } from '../../hooks/useCertificates' +import { toast } from '../../utils/toast' + +interface CertificateUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +function detectFormat(file: File | null): string | null { + if (!file) return null + const ext = file.name.toLowerCase().split('.').pop() + if (ext === 'pfx' || ext === 'p12') return 'PFX/PKCS#12' + if (ext === 'pem' || ext === 'crt' || ext === 'cer') return 'PEM' + if (ext === 'der') return 'DER' + if (ext === 'key') return 'KEY' + return null +} + +export default function CertificateUploadDialog({ + open, + onOpenChange, +}: CertificateUploadDialogProps) { + const { t } = useTranslation() + + const [name, setName] = useState('') + const [certFile, setCertFile] = useState(null) + const [keyFile, setKeyFile] = useState(null) + const [chainFile, setChainFile] = useState(null) + const [validationResult, setValidationResult] = useState(null) + + const uploadMutation = useUploadCertificate() + const validateMutation = useValidateCertificate() + + const certFormat = detectFormat(certFile) + const isPfx = certFormat === 'PFX/PKCS#12' + + function resetForm() { + setName('') + setCertFile(null) + setKeyFile(null) + setChainFile(null) + setValidationResult(null) + } + + function handleClose(nextOpen: boolean) { + if (!nextOpen) resetForm() + onOpenChange(nextOpen) + } + + function handleValidate() { + if (!certFile) return + validateMutation.mutate( + { certFile, keyFile: keyFile ?? undefined, chainFile: chainFile ?? undefined }, + { + onSuccess: (result) => { + setValidationResult(result) + }, + onError: (error: Error) => { + toast.error(error.message) + }, + }, + ) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!certFile) return + + uploadMutation.mutate( + { + name, + certFile, + keyFile: keyFile ?? undefined, + chainFile: chainFile ?? undefined, + }, + { + onSuccess: () => { + toast.success(t('certificates.uploadSuccess')) + handleClose(false) + }, + onError: (error: Error) => { + toast.error(`${t('certificates.uploadFailed')}: ${error.message}`) + }, + }, + ) + } + + const canValidate = !!certFile && !validateMutation.isPending + const needsKeyFile = !!certFile && !isPfx && !keyFile + const canSubmit = !!certFile && !!name.trim() && !needsKeyFile + + return ( + + + + {t('certificates.uploadCertificate')} + +
      + setName(e.target.value)} + placeholder="e.g. My Custom Cert" + required + aria-required="true" + /> + + { + setCertFile(f) + setValidationResult(null) + }} + required + formatBadge={certFormat} + /> + + {isPfx && ( +

      + {t('certificates.pfxDetected')} +

      + )} + + {!isPfx && ( + <> + { + setKeyFile(f) + setValidationResult(null) + }} + /> + + { + setChainFile(f) + setValidationResult(null) + }} + /> + + )} + + {needsKeyFile && ( +

      + {t('certificates.keyFileRequired')} +

      + )} + + {certFile && !validationResult && ( + + )} + + {validationResult && ( + + )} + + + + + + +
      +
      + ) +} diff --git a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx index 68491eb6d..14e4bf89c 100644 --- a/frontend/src/components/dialogs/DeleteCertificateDialog.tsx +++ b/frontend/src/components/dialogs/DeleteCertificateDialog.tsx @@ -45,7 +45,7 @@ export default function DeleteCertificateDialog({ {t('certificates.deleteTitle')} - {certificate.name || certificate.domain} + {certificate.name || certificate.domains} @@ -59,7 +59,7 @@ export default function DeleteCertificateDialog({
      {t('certificates.domain')}
      -
      {certificate.domain}
      +
      {certificate.domains}
      {t('certificates.status')}
      {certificate.status}
      {t('certificates.provider')}
      diff --git a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx index 535074f8d..20c7e56a0 100644 --- a/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx +++ b/frontend/src/components/dialogs/__tests__/BulkDeleteCertificateDialog.test.tsx @@ -7,20 +7,22 @@ import BulkDeleteCertificateDialog from '../../dialogs/BulkDeleteCertificateDial import type { Certificate } from '../../../api/certificates' const makeCert = (overrides: Partial): Certificate => ({ - id: 1, + uuid: 'cert-1', name: 'Test Cert', - domain: 'test.example.com', + domains: 'test.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom', + has_key: true, + in_use: false, ...overrides, }) const certs: Certificate[] = [ - makeCert({ id: 1, name: 'Cert One', domain: 'one.example.com' }), - makeCert({ id: 2, name: 'Cert Two', domain: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }), - makeCert({ id: 3, name: 'Cert Three', domain: 'three.example.com', provider: 'letsencrypt', status: 'expired' }), + makeCert({ uuid: 'cert-1', name: 'Cert One', domains: 'one.example.com' }), + makeCert({ uuid: 'cert-2', name: 'Cert Two', domains: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }), + makeCert({ uuid: 'cert-3', name: 'Cert Three', domains: 'three.example.com', provider: 'letsencrypt', status: 'expired' }), ] describe('BulkDeleteCertificateDialog', () => { @@ -121,7 +123,7 @@ describe('BulkDeleteCertificateDialog', () => { }) it('renders "Expiring LE" label for a letsencrypt cert with status expiring', () => { - const expiringCert = makeCert({ id: 4, name: 'Expiring Cert', domain: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' }) + const expiringCert = makeCert({ uuid: 'cert-4', name: 'Expiring Cert', domains: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' }) render( ({ + useCertificateDetail: vi.fn((uuid: string | null) => { + if (!uuid) return { detail: undefined, isLoading: false } + return { detail: mockDetail, isLoading: false } + }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +const baseCert: Certificate = { + uuid: 'cert-1', + name: 'My Cert', + domains: 'example.com', + issuer: 'Test CA', + expires_at: '2026-06-01T00:00:00Z', + status: 'valid', + provider: 'custom', + has_key: true, + in_use: true, +} + +function renderDialog( + certificate: Certificate | null = baseCert, + open = true, + onOpenChange = vi.fn(), +) { + const qc = createTestQueryClient() + return { + onOpenChange, + ...render( + + + , + ), + } +} + +describe('CertificateDetailDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dialog with title when open', () => { + renderDialog() + expect(screen.getByTestId('certificate-detail-dialog')).toBeTruthy() + expect(screen.getByText('certificates.detailTitle')).toBeTruthy() + }) + + it('does not render when closed', () => { + renderDialog(baseCert, false) + expect(screen.queryByTestId('certificate-detail-dialog')).toBeFalsy() + }) + + it('displays certificate name', () => { + renderDialog() + expect(screen.getByText('My Cert')).toBeTruthy() + }) + + it('displays common name', () => { + renderDialog() + const matches = screen.getAllByText(/^app\.example\.com$/) + expect(matches.length).toBeGreaterThanOrEqual(1) + }) + + it('displays fingerprint', () => { + renderDialog() + expect(screen.getByText('AA:BB:CC:DD')).toBeTruthy() + }) + + it('displays serial number', () => { + renderDialog() + expect(screen.getByText('1234567890')).toBeTruthy() + }) + + it('displays key type', () => { + renderDialog() + expect(screen.getByText('RSA 2048')).toBeTruthy() + }) + + it('displays status', () => { + renderDialog() + expect(screen.getByText('valid')).toBeTruthy() + }) + + it('displays provider', () => { + renderDialog() + expect(screen.getByText('custom')).toBeTruthy() + }) + + it('displays assigned hosts section', () => { + renderDialog() + expect(screen.getByText('certificates.assignedHosts')).toBeTruthy() + expect(screen.getByText('Web Server')).toBeTruthy() + }) + + it('displays certificate chain section', () => { + renderDialog() + expect(screen.getByText('certificates.certificateChain')).toBeTruthy() + }) + + it('shows auto renew status', () => { + renderDialog() + expect(screen.getByText('common.no')).toBeTruthy() + }) + + it('shows formatted dates', () => { + renderDialog() + const notBeforeDate = new Date('2024-03-15T00:00:00Z').toLocaleDateString() + const updatedDate = new Date('2024-08-20T00:00:00Z').toLocaleDateString() + expect(screen.getByText(notBeforeDate)).toBeTruthy() + expect(screen.getByText(updatedDate)).toBeTruthy() + }) + + it('shows loading state', () => { + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: undefined as unknown as CertificateDetail, + isLoading: true, + error: null, + }) + renderDialog() + expect(screen.getByTestId('certificate-detail-dialog')).toBeTruthy() + // Detail content should not be rendered while loading + expect(screen.queryByText('My Cert')).toBeFalsy() + }) + + it('shows dash for missing optional fields', () => { + const sparseDetail: CertificateDetail = { + ...mockDetail, + name: '', + common_name: '', + domains: '', + issuer_org: '', + issuer: '', + fingerprint: '', + serial_number: '', + key_type: '', + not_before: '', + expires_at: '', + created_at: '', + updated_at: '', + chain: [], + assigned_hosts: [], + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: sparseDetail, + isLoading: false, + error: null, + }) + renderDialog() + const dashes = screen.getAllByText('-') + // Many fields should fall back to '-' when empty + expect(dashes.length).toBeGreaterThanOrEqual(8) + }) + + it('shows no assigned hosts message when empty', () => { + const noHostDetail: CertificateDetail = { + ...mockDetail, + assigned_hosts: [], + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: noHostDetail, + isLoading: false, + error: null, + }) + renderDialog() + expect(screen.getByText('certificates.noAssignedHosts')).toBeTruthy() + }) + + it('shows auto renew yes when enabled', () => { + const autoRenewDetail: CertificateDetail = { + ...mockDetail, + auto_renew: true, + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: autoRenewDetail, + isLoading: false, + error: null, + }) + renderDialog() + expect(screen.getByText('common.yes')).toBeTruthy() + }) + + it('falls back to issuer when issuer_org is missing', () => { + const noOrgDetail: CertificateDetail = { + ...mockDetail, + issuer_org: '', + issuer: 'Fallback Issuer', + } + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: noOrgDetail, + isLoading: false, + error: null, + }) + renderDialog() + expect(screen.getByText('Fallback Issuer')).toBeTruthy() + }) + + it('renders nothing when certificate is null', () => { + vi.mocked(useCertificateDetail).mockReturnValue({ + detail: undefined as unknown as CertificateDetail, + isLoading: false, + error: null, + }) + renderDialog(null) + expect(screen.queryByText('My Cert')).toBeFalsy() + }) +}) diff --git a/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx new file mode 100644 index 000000000..76a0107fb --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/CertificateExportDialog.test.tsx @@ -0,0 +1,275 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import type { Certificate } from '../../../api/certificates' +import { createTestQueryClient } from '../../../test/createTestQueryClient' +import CertificateExportDialog from '../CertificateExportDialog' + +const exportMutateFn = vi.fn() + +vi.mock('../../../hooks/useCertificates', () => ({ + useExportCertificate: vi.fn(() => ({ + mutate: exportMutateFn, + isPending: false, + })), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +vi.mock('../../../utils/toast', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const baseCert: Certificate = { + uuid: 'cert-1', + name: 'Test Cert', + domains: 'example.com', + issuer: 'Test CA', + expires_at: '2026-06-01T00:00:00Z', + status: 'valid', + provider: 'custom', + has_key: true, + in_use: false, +} + +function renderDialog( + certificate: Certificate | null = baseCert, + open = true, + onOpenChange = vi.fn(), +) { + const qc = createTestQueryClient() + return { + onOpenChange, + ...render( + + + , + ), + } +} + +describe('CertificateExportDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dialog when open', () => { + renderDialog() + expect(screen.getByTestId('certificate-export-dialog')).toBeTruthy() + expect(screen.getByText('certificates.exportTitle')).toBeTruthy() + }) + + it('does not render when closed', () => { + renderDialog(baseCert, false) + expect(screen.queryByTestId('certificate-export-dialog')).toBeFalsy() + }) + + it('shows format radio options', () => { + renderDialog() + expect(screen.getByText('certificates.exportFormatPem')).toBeTruthy() + expect(screen.getByText('certificates.exportFormatPfx')).toBeTruthy() + expect(screen.getByText('certificates.exportFormatDer')).toBeTruthy() + }) + + it('shows include private key checkbox', () => { + renderDialog() + expect(screen.getByText('certificates.includePrivateKey')).toBeTruthy() + }) + + it('shows export button', () => { + renderDialog() + expect(screen.getByTestId('export-certificate-submit')).toBeTruthy() + }) + + it('shows cancel button', () => { + renderDialog() + expect(screen.getByText('common.cancel')).toBeTruthy() + }) + + it('calls onOpenChange(false) on cancel', async () => { + const onOpenChange = vi.fn() + renderDialog(baseCert, true, onOpenChange) + await userEvent.click(screen.getByText('common.cancel')) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('selects PEM format by default', () => { + renderDialog() + const pemRadio = screen.getByRole('radio', { name: 'certificates.exportFormatPem' }) + expect(pemRadio).toHaveAttribute('aria-checked', 'true') + }) + + it('can select PFX format', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatPfx')) + const pfxRadio = screen.getByRole('radio', { name: 'certificates.exportFormatPfx' }) + expect(pfxRadio).toHaveAttribute('aria-checked', 'true') + }) + + it('shows PFX password when PFX format selected', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatPfx')) + expect(screen.getByText('certificates.exportPfxPassword')).toBeTruthy() + }) + + it('shows private key warning when include key is checked', async () => { + renderDialog() + const checkbox = screen.getByRole('checkbox') + await userEvent.click(checkbox) + expect(screen.getByText('certificates.includePrivateKeyWarning')).toBeTruthy() + }) + + it('shows password field when include key is checked', async () => { + renderDialog() + const checkbox = screen.getByRole('checkbox') + await userEvent.click(checkbox) + expect(screen.getByText('certificates.exportPassword')).toBeTruthy() + }) + + it('calls export mutation on submit', async () => { + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn).toHaveBeenCalledTimes(1) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + uuid: 'cert-1', + format: 'pem', + includeKey: false, + }) + }) + + it('sends include key and password when checked', async () => { + renderDialog() + const checkbox = screen.getByRole('checkbox') + await userEvent.click(checkbox) + + const pwInput = document.getElementById('export-password') as HTMLInputElement + await userEvent.type(pwInput, 'secret123') + + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + uuid: 'cert-1', + format: 'pem', + includeKey: true, + password: 'secret123', + }) + }) + + it('hides include key checkbox when cert has no key', () => { + const certNoKey = { ...baseCert, has_key: false } + renderDialog(certNoKey) + expect(screen.queryByRole('checkbox')).toBeFalsy() + }) + + it('triggers blob download on export success', async () => { + const fakeBlob = new Blob(['cert-data'], { type: 'application/x-pem-file' }) + const revokeURL = vi.fn() + const createURL = vi.fn(() => 'blob:http://localhost/fake') + globalThis.URL.createObjectURL = createURL + globalThis.URL.revokeObjectURL = revokeURL + + const appendSpy = vi.spyOn(document.body, 'appendChild') + const removeSpy = vi.fn() + + exportMutateFn.mockImplementation( + (_params: unknown, opts: { onSuccess: (b: Blob) => void }) => { + const origCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => { + const el = origCreate(tag) as HTMLAnchorElement + el.remove = removeSpy + return el + }) + opts.onSuccess(fakeBlob) + }, + ) + + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + + expect(createURL).toHaveBeenCalledWith(fakeBlob) + expect(appendSpy).toHaveBeenCalled() + expect(revokeURL).toHaveBeenCalledWith('blob:http://localhost/fake') + expect(removeSpy).toHaveBeenCalled() + appendSpy.mockRestore() + }) + + it('shows toast error on export failure', async () => { + const { toast: mockToast } = await import('../../../utils/toast') + exportMutateFn.mockImplementation( + (_params: unknown, opts: { onError: (e: Error) => void }) => { + opts.onError(new Error('Export failed')) + }, + ) + + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(mockToast.error).toHaveBeenCalled() + }) + + it('selects DER format and submits', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatDer')) + const derRadio = screen.getByRole('radio', { name: 'certificates.exportFormatDer' }) + expect(derRadio).toHaveAttribute('aria-checked', 'true') + + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + format: 'der', + }) + }) + + it('sends pfxPassword when PFX format selected', async () => { + renderDialog() + await userEvent.click(screen.getByText('certificates.exportFormatPfx')) + + const pfxInput = document.getElementById('pfx-password') as HTMLInputElement + await userEvent.type(pfxInput, 'pfx-secret') + + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(exportMutateFn.mock.calls[0][0]).toMatchObject({ + format: 'pfx', + pfxPassword: 'pfx-secret', + }) + }) + + it('returns early from submit when certificate is null', async () => { + renderDialog(null) + // Dialog doesn't render without open+cert, so no submit button to click + // Just verify no calls + expect(exportMutateFn).not.toHaveBeenCalled() + }) + + it('uses certificate name in download filename on success', async () => { + const fakeBlob = new Blob(['data']) + globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake') + globalThis.URL.revokeObjectURL = vi.fn() + + let capturedAnchor: HTMLAnchorElement | null = null + exportMutateFn.mockImplementation( + (_params: unknown, opts: { onSuccess: (b: Blob) => void }) => { + const origCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementationOnce((tag: string) => { + const el = origCreate(tag) as HTMLAnchorElement + el.remove = vi.fn() + capturedAnchor = el + return el + }) + opts.onSuccess(fakeBlob) + }, + ) + + renderDialog() + await userEvent.click(screen.getByTestId('export-certificate-submit')) + expect(capturedAnchor!.download).toBe('Test Cert.pem') + }) +}) diff --git a/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx new file mode 100644 index 000000000..e77c33388 --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx @@ -0,0 +1,409 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { createTestQueryClient } from '../../../test/createTestQueryClient' +import CertificateUploadDialog from '../CertificateUploadDialog' +import { toast } from '../../../utils/toast' + +const uploadMutateFn = vi.fn() +const validateMutateFn = vi.fn() + +vi.mock('../../../hooks/useCertificates', () => ({ + useUploadCertificate: vi.fn(() => ({ + mutate: uploadMutateFn, + isPending: false, + })), + useValidateCertificate: vi.fn(() => ({ + mutate: validateMutateFn, + isPending: false, + })), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +vi.mock('../../../utils/toast', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +function renderDialog(open = true, onOpenChange = vi.fn()) { + const qc = createTestQueryClient() + return { + onOpenChange, + ...render( + + + , + ), + } +} + +function createFile(name = 'test.pem'): File { + return new File(['cert-content'], name, { type: 'application/x-pem-file' }) +} + +describe('CertificateUploadDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dialog when open', () => { + renderDialog() + expect(screen.getByTestId('certificate-upload-dialog')).toBeTruthy() + expect(screen.getByText('certificates.uploadCertificate')).toBeTruthy() + }) + + it('does not render when closed', () => { + renderDialog(false) + expect(screen.queryByTestId('certificate-upload-dialog')).toBeFalsy() + }) + + it('shows certificate file drop zone', () => { + renderDialog() + expect(screen.getByText('certificates.certificateFile')).toBeTruthy() + }) + + it('shows private key and chain file zones for non-PFX', () => { + renderDialog() + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + expect(screen.getByText('certificates.chainFile')).toBeTruthy() + }) + + it('shows name input', () => { + renderDialog() + expect(screen.getByText('certificates.friendlyName')).toBeTruthy() + }) + + it('has cancel and submit buttons', () => { + renderDialog() + expect(screen.getByText('common.cancel')).toBeTruthy() + expect(screen.getByText('certificates.uploadAndSave')).toBeTruthy() + }) + + it('shows validate button after cert file is selected', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + await userEvent.upload(certInput, file) + expect(await screen.findByTestId('validate-certificate-btn')).toBeTruthy() + }) + + it('calls validate mutation on validate click', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + await userEvent.upload(certInput, file) + + const validateBtn = await screen.findByTestId('validate-certificate-btn') + await userEvent.click(validateBtn) + + expect(validateMutateFn).toHaveBeenCalledTimes(1) + expect(validateMutateFn.mock.calls[0][0]).toMatchObject({ + certFile: file, + }) + }) + + it('calls upload mutation on form submit with name and cert', async () => { + renderDialog() + + const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert') + await userEvent.type(nameInput, 'My Cert') + + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + await userEvent.upload(certInput, file) + // jsdom constraint validation doesn't recognise programmatic file uploads + certInput.required = false + + const keyInput = document.getElementById('key-file') as HTMLInputElement + await userEvent.upload(keyInput, new File(['key'], 'key.pem', { type: 'application/x-pem-file' })) + keyInput.required = false + + const submitBtn = screen.getByTestId('upload-certificate-submit') + await userEvent.click(submitBtn) + + expect(uploadMutateFn).toHaveBeenCalledTimes(1) + expect(uploadMutateFn.mock.calls[0][0]).toMatchObject({ + name: 'My Cert', + certFile: file, + }) + }) + + it('calls onOpenChange(false) on cancel click', async () => { + const onOpenChange = vi.fn() + renderDialog(true, onOpenChange) + const cancelBtn = screen.getByText('common.cancel') + await userEvent.click(cancelBtn) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('shows PFX message when PFX file is selected', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['pfx-content'], 'cert.pfx', { type: 'application/x-pkcs12' }) + await userEvent.upload(certInput, file) + expect(await screen.findByText('certificates.pfxDetected')).toBeTruthy() + }) + + it('hides key and chain drop zones for PFX files', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['pfx-content'], 'cert.pfx', { type: 'application/x-pkcs12' }) + await userEvent.upload(certInput, file) + + await waitFor(() => { + expect(screen.queryByText('certificates.privateKeyFile')).toBeFalsy() + expect(screen.queryByText('certificates.chainFile')).toBeFalsy() + }) + }) + + it('shows toast on upload success', async () => { + uploadMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess() + }) + renderDialog() + + const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert') + await userEvent.type(nameInput, 'Cert') + + const certInput = document.getElementById('cert-file') as HTMLInputElement + await userEvent.upload(certInput, createFile()) + certInput.required = false + + const keyInput = document.getElementById('key-file') as HTMLInputElement + await userEvent.upload(keyInput, new File(['key'], 'key.pem', { type: 'application/x-pem-file' })) + keyInput.required = false + + await userEvent.click(screen.getByTestId('upload-certificate-submit')) + expect(toast.success).toHaveBeenCalledWith('certificates.uploadSuccess') + }) + + it('shows toast on upload error', async () => { + uploadMutateFn.mockImplementation((_params: unknown, opts: { onError: (e: Error) => void }) => { + opts.onError(new Error('Upload failed')) + }) + renderDialog() + + const nameInput = screen.getByPlaceholderText('e.g. My Custom Cert') + await userEvent.type(nameInput, 'Cert') + + const certInput = document.getElementById('cert-file') as HTMLInputElement + await userEvent.upload(certInput, createFile()) + certInput.required = false + + const keyInput = document.getElementById('key-file') as HTMLInputElement + await userEvent.upload(keyInput, new File(['key'], 'key.pem', { type: 'application/x-pem-file' })) + keyInput.required = false + + await userEvent.click(screen.getByTestId('upload-certificate-submit')) + expect(toast.error).toHaveBeenCalled() + }) + + it('shows validation preview after successful validation', async () => { + const mockResult = { + valid: true, + common_name: 'test.com', + domains: ['test.com'], + issuer_org: 'CA', + expires_at: '2026-01-01', + key_match: false, + chain_valid: false, + chain_depth: 0, + warnings: [], + errors: [], + } + validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => { + opts.onSuccess(mockResult) + }) + + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + await userEvent.upload(certInput, createFile()) + + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy() + }) + + it('shows toast on validate error', async () => { + validateMutateFn.mockImplementation((_params: unknown, opts: { onError: (e: Error) => void }) => { + opts.onError(new Error('Validation failed')) + }) + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + await userEvent.upload(certInput, createFile()) + + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(toast.error).toHaveBeenCalledWith('Validation failed') + }) + + it('detects .p12 as PFX format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['pkcs12'], 'bundle.p12', { type: 'application/x-pkcs12' }) + await userEvent.upload(certInput, file) + expect(await screen.findByText('certificates.pfxDetected')).toBeTruthy() + }) + + it('detects .crt as PEM format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['cert'], 'my.crt', { type: 'application/x-x509' }) + await userEvent.upload(certInput, file) + // PEM does not hide key/chain zones + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('detects .cer as PEM format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['cert'], 'my.cer', { type: 'application/x-x509' }) + await userEvent.upload(certInput, file) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('detects .der format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['der'], 'cert.der', { type: 'application/x-x509' }) + await userEvent.upload(certInput, file) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('detects .key format', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['key'], 'private.key', { type: 'application/x-pem-file' }) + await userEvent.upload(certInput, file) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('handles unknown file extension gracefully', async () => { + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['data'], 'cert.xyz', { type: 'application/octet-stream' }) + await userEvent.upload(certInput, file) + // Should still show key/chain zones (not PFX) + expect(screen.getByText('certificates.privateKeyFile')).toBeTruthy() + }) + + it('resets validation when cert file changes', async () => { + const mockResult = { + valid: true, + common_name: 'test.com', + domains: ['test.com'], + issuer_org: 'CA', + expires_at: '2026-01-01', + key_match: false, + chain_valid: false, + chain_depth: 0, + warnings: [], + errors: [], + } + validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => { + opts.onSuccess(mockResult) + }) + + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + await userEvent.upload(certInput, createFile()) + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy() + + // Change cert file — validation result should disappear + const newFile = new File(['new-cert'], 'new.pem', { type: 'application/x-pem-file' }) + await userEvent.upload(certInput, newFile) + await waitFor(() => { + expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy() + }) + }) + + it('resets validation when key file changes', async () => { + const mockResult = { + valid: true, + common_name: 'test.com', + domains: ['test.com'], + issuer_org: 'CA', + expires_at: '2026-01-01', + key_match: false, + chain_valid: false, + chain_depth: 0, + warnings: [], + errors: [], + } + validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => { + opts.onSuccess(mockResult) + }) + + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + await userEvent.upload(certInput, createFile()) + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy() + + const keyInput = document.getElementById('key-file') as HTMLInputElement + const keyFile = new File(['key-data'], 'private.key', { type: 'application/x-pem-file' }) + await userEvent.upload(keyInput, keyFile) + await waitFor(() => { + expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy() + }) + }) + + it('resets validation when chain file changes', async () => { + const mockResult = { + valid: true, + common_name: 'test.com', + domains: ['test.com'], + issuer_org: 'CA', + expires_at: '2026-01-01', + key_match: false, + chain_valid: false, + chain_depth: 0, + warnings: [], + errors: [], + } + validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => { + opts.onSuccess(mockResult) + }) + + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + await userEvent.upload(certInput, createFile()) + await userEvent.click(await screen.findByTestId('validate-certificate-btn')) + expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy() + + const chainInput = document.getElementById('chain-file') as HTMLInputElement + const chainFile = new File(['chain-data'], 'chain.pem', { type: 'application/x-pem-file' }) + await userEvent.upload(chainInput, chainFile) + await waitFor(() => { + expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy() + }) + }) + + it('shows KEY format badge when .key file is uploaded', async () => { + const user = userEvent.setup({ applyAccept: false }) + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['key-data'], 'server.key', { type: 'application/x-pem-file' }) + await user.upload(certInput, file) + expect(await screen.findByText('KEY')).toBeTruthy() + }) + + it('shows no format badge for unknown file extension', async () => { + const user = userEvent.setup({ applyAccept: false }) + renderDialog() + const certInput = document.getElementById('cert-file') as HTMLInputElement + const file = new File(['data'], 'cert.bin', { type: 'application/octet-stream' }) + await user.upload(certInput, file) + await screen.findByText('cert.bin') + expect(screen.queryByText('KEY')).toBeNull() + expect(screen.queryByText('DER')).toBeNull() + expect(screen.queryByText('PFX/PKCS#12')).toBeNull() + expect(screen.queryByText('PEM')).toBeNull() + }) +}) diff --git a/frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx b/frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx index c5998599f..950124f24 100644 --- a/frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx +++ b/frontend/src/components/dialogs/__tests__/DeleteCertificateDialog.test.tsx @@ -14,13 +14,15 @@ vi.mock('react-i18next', () => ({ })) const baseCert: Certificate = { - id: 1, + uuid: 'cert-1', name: 'Test Cert', - domain: 'test.example.com', + domains: 'test.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom', + has_key: true, + in_use: false, } describe('DeleteCertificateDialog', () => { diff --git a/frontend/src/components/ui/FileDropZone.tsx b/frontend/src/components/ui/FileDropZone.tsx new file mode 100644 index 000000000..6573e937f --- /dev/null +++ b/frontend/src/components/ui/FileDropZone.tsx @@ -0,0 +1,136 @@ +import { Upload } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { cn } from '../../utils/cn' + +interface FileDropZoneProps { + id: string + label: string + accept?: string + file: File | null + onFileChange: (file: File | null) => void + disabled?: boolean + required?: boolean + formatBadge?: string | null +} + +export function FileDropZone({ + id, + label, + accept, + file, + onFileChange, + disabled = false, + required = false, + formatBadge, +}: FileDropZoneProps) { + const { t } = useTranslation() + const inputRef = useRef(null) + const [isDragOver, setIsDragOver] = useState(false) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + if (disabled) return + const droppedFile = e.dataTransfer.files[0] + if (droppedFile) onFileChange(droppedFile) + }, + [disabled, onFileChange], + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + if (!disabled) setIsDragOver(true) + }, + [disabled], + ) + + const handleDragLeave = useCallback(() => { + setIsDragOver(false) + }, []) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const selected = e.target.files?.[0] || null + onFileChange(selected) + }, + [onFileChange], + ) + + const handleClick = useCallback(() => { + if (!disabled) inputRef.current?.click() + }, [disabled]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.key === 'Enter' || e.key === ' ') && !disabled) { + e.preventDefault() + inputRef.current?.click() + } + }, + [disabled], + ) + + return ( +
      + +
      + + + {file ? ( +
      +
      + ) : ( +
      +
      + )} +
      +
      + ) +} diff --git a/frontend/src/components/ui/__tests__/FileDropZone.test.tsx b/frontend/src/components/ui/__tests__/FileDropZone.test.tsx new file mode 100644 index 000000000..bbe99ae03 --- /dev/null +++ b/frontend/src/components/ui/__tests__/FileDropZone.test.tsx @@ -0,0 +1,157 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { FileDropZone } from '../FileDropZone' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +const defaultProps = { + id: 'cert-file', + label: 'Certificate File', + file: null as File | null, + onFileChange: vi.fn(), +} + +function createFile(name = 'test.pem', type = 'application/x-pem-file'): File { + return new File(['cert-content'], name, { type }) +} + +describe('FileDropZone', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders label and empty drop zone', () => { + render() + expect(screen.getByText('Certificate File')).toBeTruthy() + expect(screen.getByText('certificates.dropFileHere')).toBeTruthy() + }) + + it('shows required asterisk when required', () => { + render() + expect(screen.getByText('*')).toBeTruthy() + }) + + it('displays file name when a file is provided', () => { + const file = createFile('my-cert.pem') + render() + expect(screen.getByText('my-cert.pem')).toBeTruthy() + }) + + it('displays format badge when file is provided', () => { + const file = createFile('my-cert.pem') + render() + expect(screen.getByText('PEM')).toBeTruthy() + }) + + it('triggers file input on click', async () => { + render() + const dropZone = screen.getByRole('button') + await userEvent.click(dropZone) + // The hidden file input should exist + const input = document.getElementById('cert-file') as HTMLInputElement + expect(input).toBeTruthy() + expect(input.type).toBe('file') + }) + + it('calls onFileChange when a file is selected via input', () => { + render() + const input = document.getElementById('cert-file') as HTMLInputElement + const file = createFile() + fireEvent.change(input, { target: { files: [file] } }) + expect(defaultProps.onFileChange).toHaveBeenCalledWith(file) + }) + + it('calls onFileChange on drop', () => { + render() + const dropZone = screen.getByRole('button') + const file = createFile() + + fireEvent.dragOver(dropZone, { dataTransfer: { files: [file] } }) + fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }) + + expect(defaultProps.onFileChange).toHaveBeenCalledWith(file) + }) + + it('does not call onFileChange on drop when disabled', () => { + render() + const dropZone = screen.getByRole('button') + const file = createFile() + + fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }) + + expect(defaultProps.onFileChange).not.toHaveBeenCalled() + }) + + it('activates via keyboard Enter', async () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.keyDown(dropZone, { key: 'Enter' }) + // Should not throw; input ref click would be called + }) + + it('activates via keyboard Space', async () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.keyDown(dropZone, { key: ' ' }) + }) + + it('does not activate via keyboard when disabled', () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.keyDown(dropZone, { key: 'Enter' }) + // No crash, no file change + expect(defaultProps.onFileChange).not.toHaveBeenCalled() + }) + + it('sets aria-disabled when disabled', () => { + render() + const dropZone = screen.getByRole('button') + expect(dropZone.getAttribute('aria-disabled')).toBe('true') + }) + + it('has tabIndex=-1 when disabled', () => { + render() + const dropZone = screen.getByRole('button') + expect(dropZone.tabIndex).toBe(-1) + }) + + it('has tabIndex=0 when not disabled', () => { + render() + const dropZone = screen.getByRole('button') + expect(dropZone.tabIndex).toBe(0) + }) + + it('has appropriate aria-label when file is selected', () => { + const file = createFile('cert.pem') + render() + const dropZone = screen.getByRole('button') + expect(dropZone.getAttribute('aria-label')).toBe('Certificate File: cert.pem') + }) + + it('handles dragLeave event', () => { + render() + const dropZone = screen.getByRole('button') + fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } }) + fireEvent.dragLeave(dropZone) + // No crash; drag state should reset + }) + + it('sets accept attribute on input', () => { + render() + const input = document.getElementById('cert-file') as HTMLInputElement + expect(input.getAttribute('accept')).toBe('.pem,.crt') + }) + + it('sets aria-required on input when required', () => { + render() + const input = document.getElementById('cert-file') as HTMLInputElement + expect(input.getAttribute('aria-required')).toBe('true') + }) +}) diff --git a/frontend/src/hooks/__tests__/useCertificates.test.tsx b/frontend/src/hooks/__tests__/useCertificates.test.tsx new file mode 100644 index 000000000..94bbe92d3 --- /dev/null +++ b/frontend/src/hooks/__tests__/useCertificates.test.tsx @@ -0,0 +1,238 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import * as api from '../../api/certificates'; +import type { Certificate, CertificateDetail } from '../../api/certificates'; +import { + useCertificates, + useCertificateDetail, + useUploadCertificate, + useUpdateCertificate, + useDeleteCertificate, + useExportCertificate, + useValidateCertificate, + useBulkDeleteCertificates, +} from '../useCertificates'; + +vi.mock('../../api/certificates'); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +const mockCert: Certificate = { + uuid: 'abc-123', + domains: 'example.com', + issuer: "Let's Encrypt", + expires_at: '2025-01-01', + status: 'valid', + provider: 'letsencrypt', + has_key: true, + in_use: false, +}; + +const mockDetail: CertificateDetail = { + ...mockCert, + assigned_hosts: [], + chain: [], + auto_renew: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', +}; + +describe('useCertificates hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('useCertificates', () => { + it('fetches certificate list', async () => { + vi.mocked(api.getCertificates).mockResolvedValue([mockCert]); + + const { result } = renderHook(() => useCertificates(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.certificates).toEqual([mockCert]); + }); + + it('returns empty array when no data', async () => { + vi.mocked(api.getCertificates).mockResolvedValue([]); + + const { result } = renderHook(() => useCertificates(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.certificates).toEqual([]); + }); + }); + + describe('useCertificateDetail', () => { + it('fetches certificate detail by uuid', async () => { + vi.mocked(api.getCertificateDetail).mockResolvedValue(mockDetail); + + const { result } = renderHook(() => useCertificateDetail('abc-123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.detail).toEqual(mockDetail); + }); + + it('does not fetch when uuid is null', () => { + const { result } = renderHook(() => useCertificateDetail(null), { + wrapper: createWrapper(), + }); + + expect(api.getCertificateDetail).not.toHaveBeenCalled(); + expect(result.current.detail).toBeUndefined(); + }); + }); + + describe('useUploadCertificate', () => { + it('uploads certificate and invalidates cache', async () => { + vi.mocked(api.uploadCertificate).mockResolvedValue(mockCert); + + const { result } = renderHook(() => useUploadCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ + name: 'My Cert', + certFile: new File(['cert'], 'cert.pem'), + keyFile: new File(['key'], 'key.pem'), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.uploadCertificate).toHaveBeenCalledWith( + 'My Cert', + expect.any(File), + expect.any(File), + undefined, + ); + }); + }); + + describe('useUpdateCertificate', () => { + it('updates certificate name', async () => { + vi.mocked(api.updateCertificate).mockResolvedValue(mockCert); + + const { result } = renderHook(() => useUpdateCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ uuid: 'abc-123', name: 'Updated' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.updateCertificate).toHaveBeenCalledWith('abc-123', 'Updated'); + }); + }); + + describe('useDeleteCertificate', () => { + it('deletes certificate and invalidates cache', async () => { + vi.mocked(api.deleteCertificate).mockResolvedValue(undefined); + + const { result } = renderHook(() => useDeleteCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate('abc-123'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.deleteCertificate).toHaveBeenCalledWith('abc-123'); + }); + }); + + describe('useExportCertificate', () => { + it('exports certificate as blob', async () => { + const blob = new Blob(['data']); + vi.mocked(api.exportCertificate).mockResolvedValue(blob); + + const { result } = renderHook(() => useExportCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ + uuid: 'abc-123', + format: 'pem', + includeKey: true, + password: 'pass', + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.exportCertificate).toHaveBeenCalledWith('abc-123', 'pem', true, 'pass', undefined); + }); + }); + + describe('useValidateCertificate', () => { + it('validates certificate files', async () => { + const validation = { + valid: true, + common_name: 'example.com', + domains: ['example.com'], + issuer_org: 'LE', + expires_at: '2025-01-01', + key_match: true, + chain_valid: true, + chain_depth: 1, + warnings: [], + errors: [], + }; + vi.mocked(api.validateCertificate).mockResolvedValue(validation); + + const { result } = renderHook(() => useValidateCertificate(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ + certFile: new File(['cert'], 'cert.pem'), + keyFile: new File(['key'], 'key.pem'), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.validateCertificate).toHaveBeenCalled(); + }); + }); + + describe('useBulkDeleteCertificates', () => { + it('deletes multiple certificates', async () => { + vi.mocked(api.deleteCertificate).mockResolvedValue(undefined); + + const { result } = renderHook(() => useBulkDeleteCertificates(), { + wrapper: createWrapper(), + }); + + result.current.mutate(['uuid-1', 'uuid-2']); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.deleteCertificate).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual({ succeeded: 2, failed: 0 }); + }); + + it('reports partial failures', async () => { + vi.mocked(api.deleteCertificate) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('fail')); + + const { result } = renderHook(() => useBulkDeleteCertificates(), { + wrapper: createWrapper(), + }); + + result.current.mutate(['uuid-1', 'uuid-2']); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ succeeded: 1, failed: 1 }); + }); + }); +}); diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts index 228c3c7d4..540280f06 100644 --- a/frontend/src/hooks/useCertificates.ts +++ b/frontend/src/hooks/useCertificates.ts @@ -1,6 +1,16 @@ -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { getCertificates } from '../api/certificates' +import { + getCertificates, + getCertificateDetail, + uploadCertificate, + updateCertificate, + deleteCertificate, + exportCertificate, + validateCertificate, +} from '../api/certificates' + +import type { CertificateDetail } from '../api/certificates' interface UseCertificatesOptions { refetchInterval?: number | false @@ -20,3 +30,103 @@ export function useCertificates(options?: UseCertificatesOptions) { refetch, } } + +export function useCertificateDetail(uuid: string | null) { + const { data, isLoading, error } = useQuery({ + queryKey: ['certificates', uuid], + queryFn: () => getCertificateDetail(uuid!), + enabled: !!uuid, + }) + + return { + detail: data as CertificateDetail | undefined, + isLoading, + error, + } +} + +export function useUploadCertificate() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: { + name: string + certFile: File + keyFile?: File + chainFile?: File + }) => uploadCertificate(params.name, params.certFile, params.keyFile, params.chainFile), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['certificates'] }) + }, + }) +} + +export function useUpdateCertificate() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: { uuid: string; name: string }) => + updateCertificate(params.uuid, params.name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['certificates'] }) + }, + }) +} + +export function useDeleteCertificate() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (uuid: string) => deleteCertificate(uuid), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['certificates'] }) + queryClient.invalidateQueries({ queryKey: ['proxyHosts'] }) + }, + }) +} + +export function useExportCertificate() { + return useMutation({ + mutationFn: (params: { + uuid: string + format: string + includeKey: boolean + password?: string + pfxPassword?: string + }) => + exportCertificate( + params.uuid, + params.format, + params.includeKey, + params.password, + params.pfxPassword, + ), + }) +} + +export function useValidateCertificate() { + return useMutation({ + mutationFn: (params: { + certFile: File + keyFile?: File + chainFile?: File + }) => validateCertificate(params.certFile, params.keyFile, params.chainFile), + }) +} + +export function useBulkDeleteCertificates() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (uuids: string[]) => { + const results = await Promise.allSettled(uuids.map(uuid => deleteCertificate(uuid))) + const failed = results.filter(r => r.status === 'rejected').length + const succeeded = results.filter(r => r.status === 'fulfilled').length + return { succeeded, failed } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['certificates'] }) + queryClient.invalidateQueries({ queryKey: ['proxyHosts'] }) + }, + }) +} diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 592a502ff..fe265c90d 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -207,7 +207,69 @@ "providerStaging": "Staging", "providerCustom": "Custom", "providerExpiredLE": "Expired LE", - "providerExpiringLE": "Expiring LE" + "providerExpiringLE": "Expiring LE", + + "certificateFile": "Certificate File", + "privateKeyFile": "Private Key File", + "chainFile": "Chain File (Optional)", + "dropFileHere": "Drag and drop a file here, or click to browse", + "formatDetected": "Detected: {{format}}", + "pfxDetected": "PFX/PKCS#12 detected — key is embedded, no separate key file needed.", + "keyFileRequired": "A private key file is required for PEM/DER certificates.", + "pfxPassword": "PFX Password (if protected)", + + "validate": "Validate", + "validating": "Validating...", + "validationPreview": "Validation Preview", + "commonName": "Common Name", + "domains": "Domains", + "issuerOrg": "Issuer", + "keyMatch": "Key Match", + "chainValid": "Chain Valid", + "chainDepth": "Chain Depth", + "warnings": "Warnings", + "errors": "Errors", + "validCertificate": "Valid certificate", + "invalidCertificate": "Certificate has errors", + "uploadAndSave": "Upload & Save", + + "detailTitle": "Certificate Details", + "fingerprint": "Fingerprint", + "serialNumber": "Serial Number", + "keyType": "Key Type", + "notBefore": "Valid From", + "autoRenew": "Auto Renew", + "createdAt": "Created", + "updatedAt": "Last Updated", + "assignedHosts": "Assigned Hosts", + "noAssignedHosts": "Not assigned to any proxy host", + "certificateChain": "Certificate Chain", + "noChainData": "No chain data available", + "chainLeaf": "Leaf", + "chainIntermediate": "Intermediate", + "chainRoot": "Root", + + "exportTitle": "Export Certificate", + "exportFormat": "Format", + "exportFormatPem": "PEM", + "exportFormatPfx": "PFX/PKCS#12", + "exportFormatDer": "DER", + "includePrivateKey": "Include Private Key", + "includePrivateKeyWarning": "Exporting the private key requires re-authentication.", + "exportPassword": "Account Password", + "exportPfxPassword": "PFX Password", + "exportButton": "Export", + "exportSuccess": "Certificate exported", + "exportFailed": "Failed to export certificate", + + "expiresInDays": "Expires in {{days}} days", + "expiredAgo": "Expired {{days}} days ago", + "viewDetails": "View details", + "export": "Export", + + "updateName": "Rename Certificate", + "updateSuccess": "Certificate renamed", + "updateFailed": "Failed to rename certificate" }, "auth": { "login": "Login", diff --git a/frontend/src/pages/Certificates.tsx b/frontend/src/pages/Certificates.tsx index 87f2bed7b..472c60392 100644 --- a/frontend/src/pages/Certificates.tsx +++ b/frontend/src/pages/Certificates.tsx @@ -1,59 +1,19 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, ShieldCheck } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { uploadCertificate } from '../api/certificates' import CertificateList from '../components/CertificateList' +import CertificateUploadDialog from '../components/dialogs/CertificateUploadDialog' import { PageShell } from '../components/layout/PageShell' -import { - Button, - Input, - Alert, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - Label, -} from '../components/ui' -import { toast } from '../utils/toast' +import { Button, Alert } from '../components/ui' export default function Certificates() { const { t } = useTranslation() - const [isModalOpen, setIsModalOpen] = useState(false) - const [name, setName] = useState('') - const [certFile, setCertFile] = useState(null) - const [keyFile, setKeyFile] = useState(null) - const queryClient = useQueryClient() + const [isUploadOpen, setIsUploadOpen] = useState(false) - const uploadMutation = useMutation({ - mutationFn: async () => { - if (!certFile || !keyFile) throw new Error('Files required') - await uploadCertificate(name, certFile, keyFile) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['certificates'] }) - setIsModalOpen(false) - setName('') - setCertFile(null) - setKeyFile(null) - toast.success(t('certificates.uploadSuccess')) - }, - onError: (error: Error) => { - toast.error(`${t('certificates.uploadFailed')}: ${error.message}`) - }, - }) - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - uploadMutation.mutate() - } - - // Header actions const headerActions = ( - ) @@ -70,56 +30,7 @@ export default function Certificates() { - {/* Upload Certificate Dialog */} - - - - {t('certificates.uploadCertificate')} - -
      - setName(e.target.value)} - placeholder="e.g. My Custom Cert" - required - /> -
      - - setCertFile(e.target.files?.[0] || null)} - className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer" - required - /> -
      -
      - - setKeyFile(e.target.files?.[0] || null)} - className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer" - required - /> -
      - - - - -
      -
      -
      + ) } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 878fee0c0..b5c2715e7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -44,8 +44,8 @@ export default function Dashboard() { const certifiedDomains = new Set() for (const cert of certificates) { // Handle missing or undefined domain field - if (!cert.domain) continue - for (const d of cert.domain.split(',')) { + if (!cert.domains) continue + for (const d of cert.domains.split(',')) { const trimmed = d.trim().toLowerCase() if (trimmed) certifiedDomains.add(trimmed) } diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx index 869f9957a..386455f44 100644 --- a/frontend/src/pages/ProxyHosts.tsx +++ b/frontend/src/pages/ProxyHosts.tsx @@ -57,7 +57,7 @@ export default function ProxyHosts() { const [certCleanupData, setCertCleanupData] = useState<{ hostUUIDs: string[] hostNames: string[] - certificates: Array<{ id: number; name: string; domain: string }> + certificates: Array<{ uuid: string; name: string; domain: string }> isBulk: boolean } | null>(null) const [selectedACLs, setSelectedACLs] = useState>(new Set()) @@ -103,7 +103,7 @@ export default function ProxyHosts() { const certStatusByDomain = useMemo(() => { const map: Record = {} for (const cert of certificates) { - const domains = cert.domain.split(',').map(d => d.trim().toLowerCase()) + const domains = (cert.domains || '').split(',').map(d => d.trim().toLowerCase()).filter(Boolean) for (const domain of domains) { if (!map[domain]) { map[domain] = { status: cert.status, provider: cert.provider } @@ -148,7 +148,7 @@ export default function ProxyHosts() { const host = hostToDelete // Check for orphaned certificates that would need cleanup - const orphanedCerts: Array<{ id: number; name: string; domain: string }> = [] + const orphanedCerts: Array<{ uuid: string; name: string; domain: string }> = [] if (host.certificate_id && host.certificate) { const cert = host.certificate @@ -160,7 +160,7 @@ export default function ProxyHosts() { const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging') if (isCustomOrStaging) { orphanedCerts.push({ - id: cert.id!, + uuid: cert.uuid, name: cert.name || '', domain: cert.domains }) @@ -237,7 +237,7 @@ export default function ProxyHosts() { for (const cert of certCleanupData.certificates) { try { - await deleteCertificate(cert.id) + await deleteCertificate(cert.uuid) certsDeleted++ } catch { certsFailed++ @@ -282,7 +282,7 @@ export default function ProxyHosts() { // Delete certificate if user confirmed if (deleteCerts && certCleanupData.certificates.length > 0) { try { - await deleteCertificate(certCleanupData.certificates[0].id) + await deleteCertificate(certCleanupData.certificates[0].uuid) toast.success('Proxy host and certificate deleted') } catch (err) { toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`) @@ -329,7 +329,7 @@ export default function ProxyHosts() { toast.success(`Backup created: ${backup.filename}`) // Collect certificates to potentially delete - const certsToConsider: Map = new Map() + const certsToConsider: Map = new Map() for (const uuid of hostUUIDs) { const host = hosts.find(h => h.uuid === uuid) @@ -343,9 +343,9 @@ export default function ProxyHosts() { h.certificate_id === host.certificate_id && !hostUUIDs.includes(h.uuid) ) - if (otherHosts.length === 0 && cert.id) { - certsToConsider.set(cert.id, { - id: cert.id, + if (otherHosts.length === 0 && cert.uuid) { + certsToConsider.set(cert.uuid, { + uuid: cert.uuid, name: cert.name || '', domain: cert.domains }) diff --git a/frontend/src/pages/__tests__/Certificates.test.tsx b/frontend/src/pages/__tests__/Certificates.test.tsx index c211726b5..d86bc817b 100644 --- a/frontend/src/pages/__tests__/Certificates.test.tsx +++ b/frontend/src/pages/__tests__/Certificates.test.tsx @@ -1,55 +1,27 @@ -import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { uploadCertificate, type Certificate } from '../../api/certificates' import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient' -import { toast } from '../../utils/toast' import Certificates from '../Certificates' - - -const translations: Record = { - 'certificates.addCertificate': 'Add Certificate', - 'certificates.uploadCertificate': 'Upload Certificate', - 'certificates.friendlyName': 'Friendly Name', - 'certificates.certificatePem': 'Certificate (PEM)', - 'certificates.privateKeyPem': 'Private Key (PEM)', - 'certificates.uploadSuccess': 'Certificate uploaded successfully', - 'certificates.uploadFailed': 'Failed to upload certificate', - 'common.upload': 'Upload', - 'common.cancel': 'Cancel', -} - -const t = (key: string, options?: Record) => { - const template = translations[key] ?? key - - if (!options) return template - - return Object.entries(options).reduce((acc, [optionKey, optionValue]) => { - return acc.replace(`{{${optionKey}}}`, String(optionValue)) - }, template) -} - vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t, + t: (key: string) => key, }), })) vi.mock('../../components/CertificateList', () => ({ - default: () =>
      CertificateList
      , -})) - -vi.mock('../../api/certificates', () => ({ - uploadCertificate: vi.fn(), + default: () =>
      CertificateList
      , })) -vi.mock('../../utils/toast', () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - }, +vi.mock('../../components/dialogs/CertificateUploadDialog', () => ({ + default: ({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) => + open ? ( +
      + +
      + ) : null, })) describe('Certificates', () => { @@ -57,93 +29,35 @@ describe('Certificates', () => { vi.clearAllMocks() }) - it('uploads certificate and closes dialog on success', async () => { - const certificate: Certificate = { - domain: 'example.com', - issuer: 'Test CA', - expires_at: '2026-03-01T00:00:00Z', - status: 'valid', - provider: 'custom', - } - vi.mocked(uploadCertificate).mockResolvedValue(certificate) + it('renders the page with certificate list and add button', () => { + renderWithQueryClient() + expect(screen.getByText('certificates.addCertificate')).toBeInTheDocument() + expect(screen.getByTestId('certificate-list')).toBeInTheDocument() + }) + it('opens upload dialog when add button is clicked', async () => { const user = userEvent.setup() - const { queryClient } = renderWithQueryClient() - const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') - - await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') })) - - const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') }) - - const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement - await user.type(nameInput, 'My Cert') - await waitFor(() => { - expect(nameInput.value).toBe('My Cert') - }) - - const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' }) - const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' }) - - const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement - const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement - - await user.upload(certInput, certFile) - await user.upload(keyInput, keyFile) - - await waitFor(() => { - expect(certInput.files?.[0]).toBe(certFile) - expect(keyInput.files?.[0]).toBe(keyFile) - }) - - const form = dialog.querySelector('form') as HTMLFormElement - fireEvent.submit(form) + renderWithQueryClient() - await waitFor(() => { - expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile) - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] }) - expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess')) - }) + expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument() - await waitFor(() => { - expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument() - }) + await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' })) + expect(screen.getByTestId('upload-dialog')).toBeInTheDocument() }) - it('surfaces upload errors', async () => { - vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed')) - + it('closes upload dialog via onOpenChange callback', async () => { const user = userEvent.setup() renderWithQueryClient() - await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') })) - - const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') }) + await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' })) + expect(screen.getByTestId('upload-dialog')).toBeInTheDocument() - const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement - await user.type(nameInput, 'My Cert') - await waitFor(() => { - expect(nameInput.value).toBe('My Cert') - }) - - const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' }) - const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' }) - - const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement - const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement - - await user.upload(certInput, certFile) - await user.upload(keyInput, keyFile) - - await waitFor(() => { - expect(certInput.files?.[0]).toBe(certFile) - expect(keyInput.files?.[0]).toBe(keyFile) - }) - - const form = dialog.querySelector('form') as HTMLFormElement - fireEvent.submit(form) + await user.click(screen.getByText('Close')) + expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument() + }) - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`) - }) + it('renders info alert with note text', () => { + renderWithQueryClient() + expect(screen.getByText('certificates.noteText')).toBeInTheDocument() }) }) diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx index 91445bf72..06883756c 100644 --- a/frontend/src/pages/__tests__/Dashboard.test.tsx +++ b/frontend/src/pages/__tests__/Dashboard.test.tsx @@ -73,4 +73,15 @@ describe('Dashboard page', () => { expect(await screen.findByText('Error')).toBeInTheDocument() }) + + it('handles certificates with missing domains field', async () => { + // The top-level mock returns certs with "domain" (singular) but Dashboard + // reads "domains" (plural), so the !cert.domains guard on line 48 is + // already exercised by every render. Re-render and verify it doesn't crash. + renderWithQueryClient() + + expect(await screen.findByText('Dashboard')).toBeInTheDocument() + // "1 valid" still renders even though cert.domains is undefined + expect(screen.getByText('1 valid')).toBeInTheDocument() + }) }) diff --git a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx index 6e869fc10..245d0fa4b 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx @@ -129,7 +129,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) }) @@ -303,7 +303,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) // Toast should show error about certificate but host was deleted @@ -366,7 +366,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2') }) diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx index d9c4641d1..bf160e6cc 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx @@ -72,7 +72,7 @@ describe('ProxyHosts page - coverage targets (isolated)', () => { vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [ - { id: 1, name: 'StagingCert', domain: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' } + { id: 1, name: 'StagingCert', domains: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' } ], isLoading: false, error: null, diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx index 93647bb08..b25ed93ec 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx @@ -485,8 +485,8 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostCustom, hostStaging, hostAuto, hostLets]) vi.mocked(certificatesApi.getCertificates).mockResolvedValue([ - { domain: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' }, - { domain: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' }, + { uuid: 'cert-staging', domains: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true }, + { uuid: 'cert-lets', domains: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true }, ]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) diff --git a/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx index 8337fbc27..386c89e2d 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-extra.test.tsx @@ -190,12 +190,15 @@ describe('ProxyHosts page extra tests', () => { certificates: [ { id: 1, + uuid: 'cert-le-1', name: 'LE', - domain: 'valid.example.com', + domains: 'valid.example.com', issuer: 'letsencrypt', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), status: 'valid', provider: 'letsencrypt', + has_key: false, + in_use: true, }, ], }), diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index f6a32fb97..5fa138c23 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -453,12 +453,12 @@ describe('UsersPage', () => { await waitFor(() => { expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' }) - }, { timeout: 1000 }) + }, { timeout: 2000 }) // Look for the preview URL content with ellipsis replacing the token await waitFor(() => { expect(screen.getByText('https://charon.example.com/accept-invite?token=...')).toBeInTheDocument() - }, { timeout: 1000 }) + }, { timeout: 2000 }) }) it('debounces URL preview for 500ms', async () => { @@ -521,12 +521,16 @@ describe('UsersPage', () => { const emailInput = screen.getByPlaceholderText('user@example.com') await user.type(emailInput, 'test@example.com') + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' }) + }, { timeout: 2000 }) + await waitFor(() => { const preview = screen.getByText('https://example.com/accept-invite?token=...') expect(preview.textContent).toContain('...') expect(preview.textContent).not.toContain('SAMPLE_TOKEN_PREVIEW') - }, { timeout: 1000 }) + }, { timeout: 2000 }) }) it('shows warning when not configured', async () => { @@ -550,11 +554,15 @@ describe('UsersPage', () => { const emailInput = screen.getByPlaceholderText('user@example.com') await user.type(emailInput, 'test@example.com') + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' }) + }, { timeout: 2000 }) + await waitFor(() => { // Look for link to system settings const link = screen.getByRole('link') expect(link.getAttribute('href')).toContain('/settings/system') - }, { timeout: 1000 }) + }, { timeout: 2000 }) }) it('does not show preview when email is invalid', async () => { @@ -590,14 +598,9 @@ describe('UsersPage', () => { const emailInput = screen.getByPlaceholderText('user@example.com') await user.type(emailInput, 'test@example.com') - // Wait for debounce - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 600)) - }) - await waitFor(() => { expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' }) - }, { timeout: 1000 }) + }, { timeout: 2000 }) // Verify preview is not displayed after error const previewQuery = screen.queryByText(/accept-invite/) diff --git a/go.work.sum b/go.work.sum index c0da62ff6..017df268a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -123,6 +123,7 @@ golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= diff --git a/package-lock.json b/package-lock.json index ecc37df53..1b6667fda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,14 @@ "@playwright/test": "^1.59.1", "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/node": "^25.6.0", - "dotenv": "^17.4.1", + "dotenv": "^17.4.2", "markdownlint-cli2": "^0.22.0", - "prettier": "^3.8.2", + "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "tar": "^7.5.13", "typescript": "^6.0.2", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.4" } }, "node_modules/@bcoe/v8-coverage": { @@ -323,9 +324,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -682,6 +683,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -693,6 +701,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -703,6 +722,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint-plugin-jsx-a11y": { "version": "6.10.1", "resolved": "https://registry.npmjs.org/@types/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.1.tgz", @@ -786,6 +812,119 @@ "simplify-trace-types": "bin/simplify-trace-types" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -861,6 +1000,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -869,9 +1018,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -902,6 +1051,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1155,9 +1314,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1186,6 +1345,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1352,6 +1518,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1370,6 +1546,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2245,6 +2431,16 @@ "dev": true, "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -3019,6 +3215,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3121,6 +3328,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3174,9 +3388,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -3212,9 +3426,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -3505,6 +3719,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -3550,6 +3771,20 @@ "readable-stream": "^3.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -3643,6 +3878,23 @@ "readable-stream": "3" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3691,6 +3943,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -3929,6 +4191,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3955,6 +4320,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 86758e437..ca48fa3fe 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,17 @@ "smol-toml": "^1.6.1" }, "devDependencies": { - "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@bgotink/playwright-coverage": "^0.3.2", "@playwright/test": "^1.59.1", + "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/node": "^25.6.0", - "dotenv": "^17.4.1", + "dotenv": "^17.4.2", "markdownlint-cli2": "^0.22.0", - "prettier": "^3.8.2", + "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "tar": "^7.5.13", "typescript": "^6.0.2", - "vite": "^8.0.8" + "vite": "^8.0.8", + "vitest": "^4.1.4" } } diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index 046abdaf7..e211f9f03 100755 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -20,7 +20,7 @@ echo "🔒 Running local security scan..." if ! command -v govulncheck &> /dev/null; then echo -e "${YELLOW}Installing govulncheck...${NC}" # renovate: datasource=go depName=golang.org/x/vuln - go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + go install golang.org/x/vuln/cmd/govulncheck@v1.2.0 fi # Run govulncheck on backend Go code diff --git a/tests/certificate-export.spec.ts b/tests/certificate-export.spec.ts new file mode 100644 index 000000000..f737465a7 --- /dev/null +++ b/tests/certificate-export.spec.ts @@ -0,0 +1,689 @@ +/** + * Certificate Export E2E Tests + * + * Tests the certificate export dialog UX: + * - Export button in certificate list opens export dialog + * - Dialog displays format radio group (PEM, PFX/PKCS#12, DER) + * - Format selection updates the active radio button + * - Include private key checkbox shown when cert has a key + * - Password field appears when include private key is checked + * - PFX password field appears when PFX format is selected + * - Cancel closes dialog without exporting + * - Escape key closes dialog + * - Successful PEM export triggers file download + * - Export with include key requires password re-authentication + * - Dialog accessibility: keyboard navigation, dialog role, labels + * + * @see /projects/Charon/docs/plans/current_spec.md + */ + +import { test, expect, loginUser } from './fixtures/auth-fixtures'; +import { request as playwrightRequest } from '@playwright/test'; +import { + waitForLoadingComplete, + waitForDialog, + waitForAPIResponse, +} from './utils/wait-helpers'; +import { generateUniqueId } from './fixtures/test-data'; +import { STORAGE_STATE } from './constants'; +import { getStorageStateAuthHeaders } from './utils/api-helpers'; + +const CERTIFICATES_API = /\/api\/v1\/certificates/; + +/** + * Real self-signed certificate and key for upload tests. + * Generated via: openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=test.local/O=TestOrg" + */ +const REAL_TEST_CERT = `-----BEGIN CERTIFICATE----- +MIIDLzCCAhegAwIBAgIUehGqwKI4zLvoZSNHlAuv7cJ0G5AwDQYJKoZIhvcNAQEL +BQAwJzETMBEGA1UEAwwKdGVzdC5sb2NhbDEQMA4GA1UECgwHVGVzdE9yZzAeFw0y +NjAzMjIwMzQyMDhaFw0yNzAzMjIwMzQyMDhaMCcxEzARBgNVBAMMCnRlc3QubG9j +YWwxEDAOBgNVBAoMB1Rlc3RPcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDdzdQfOkHzG/lZ242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdo +ylHhIqbwNq18Dni1znDYsOAlnfZR+gv84U4klRHGE7liNRixBA5ymZ6KI68sOwqx +bn6wpDZgNLnjD3POwSQoPEx2BAYwIyLPjXFjfnv5nce8Bt99j/zDVwhq24b9YdMR +BVV/sOBsAtNEuRngajA9+i2rmLVrXJSiSFhA/hR0wX6bICpFTtahYX7JqfzlMHFO +4lBka9sbC3xujwtFmLtkBovCzf69fA6p2qhJGVNJ9oHeFY3V2CdYq5Q8SZTsG1Yt +S0O/2A9ZkQmHezeG9DYeg68nLfJDAgMBAAGjUzBRMB0GA1UdDgQWBBRE+2+ss2yl +0vAmlccEC7MBWX6UmDAfBgNVHSMEGDAWgBRE+2+ss2yl0vAmlccEC7MBWX6UmDAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCvwsnSRYQ5PYtuhJ3v +YhKmjkg+NsojYItlo+UkJmq09LkIEwRqJwFLcDxhyHWqRL5Bpc1PA1VJAG6Pif8D +uwwNnXwZZf0P5e7exccSQZnI03OhS0c6/4kfvRSiFiT6BYTYSvQ+OWhpMIIcwhov +86muij2Y32E3F0aqOPjEB+cm/XauXzmFjXi7ig7cktphHcwT8zQn43yCG/BJfWe2 +bRLWqMy+jdr/x2Ij8eWPSlJD3zDxsQiLiO0hFzpQNHfz2Qe17K3dsuhNQ85h2s0w +zCLDm4WygKTw2foUXGNtbWG7z6Eq7PI+2fSlJDFgb+xmdIFQdyKDsZeYO5bmdYq5 +0tY8 +-----END CERTIFICATE-----`; + +const REAL_TEST_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdzdQfOkHzG/lZ +242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdoylHhIqbwNq18Dni1znDY +sOAlnfZR+gv84U4klRHGE7liNRixBA5ymZ6KI68sOwqxbn6wpDZgNLnjD3POwSQo +PEx2BAYwIyLPjXFjfnv5nce8Bt99j/zDVwhq24b9YdMRBVV/sOBsAtNEuRngajA9 ++i2rmLVrXJSiSFhA/hR0wX6bICpFTtahYX7JqfzlMHFO4lBka9sbC3xujwtFmLtk +BovCzf69fA6p2qhJGVNJ9oHeFY3V2CdYq5Q8SZTsG1YtS0O/2A9ZkQmHezeG9DYe +g68nLfJDAgMBAAECggEAA8uIcZsBkzNLVOpDcQvfZ+7ldkLt61x4xJUoKqRVt4/c +usTjSYTsNdps2lzRLH+h85eRPaonDpVLAP97FlRZk+rUrFhT30mzACdI6LvtLDox +imxudgFI91dwm2Xp7QPM77XMkxdUl+5eEVeBchN84kiiSS2BCdQZiEUsLF9sZi2P +A5+x6XHImE+Sqfm/xVOZzHjj7ObHxc3bUpDT+RvRDvEBGjtEUlCCWuKvLi3DWIBF +T9E38f0hqoxKwc7gsZCZs7phoVm9a3xjQ8Xh3ONLa30aBsJii33KHHxSASc7hMy1 +cM6GaGcg4xgqFw3B677KWUMc3Ur5YdLu71Bw7MFc4QKBgQD9FyRoWcTEktPdvH9y +o7yxRVWcSs5c47h5X9rhcKvUCyEzQ/89Gt1d8e/qMv9JxXmcg3AS8VYeFmzyyMta +iKTrHYnA8iRgM6CHvgSD4+vc7niW1de7qxW3T6MrGA4AEoQOPUvd6ZljBPIqxV8h +jw9BW5YREZV6fXqqVOVT4GMrbQKBgQDgWpvmu1FY65TjoDljOPBtO17krwaWzb/D +jlXQgZgRJVD7kaUPhm7Kb2d7P7t34LgzGH63hF82PlXqtwd5QhB3EZP9mhZTbXxK +vwLf+H44ANDlcZiyDG9OJBT6ND5/JP0jHEt/KsP9pcd9xbZWNEZZFzddbbcp1G/v +ue6p18XWbwKBgQCmdm8y10BNToldQVrOKxWzvve1CZq7i+fMpRhQyQurNvrKPkIF +jcLlxHhZINu6SNFY+TZgry1GMtfLw/fEfzWBkvcE2f7E64/9WCSeHu4GbS8Rfmsb +e0aYQCAA+xxSPdtvhi99MOT7NMiXCyQr7W1KPpPwfBFF9HwWxinjxiVT7QKBgFAb +Ch9QMrN1Kiw8QUFUS0Q1NqSgedHOlPHWGH3iR9GXaVrpne31KgnNzT0MfHtJGXvk ++xm7geN0TmkIAPsiw45AEH80TVRsezyVBwnBSA/m+q9x5/tqxTM5XuQXU1lCc7/d +kndNZb1jO9+EgJ42/AdDatlJG2UsHOuTj8vE5zaxAoGBAPthB+5YZfu3de+vnfpa +o0oFy++FeeHUTxor2605Lit9ZfEvDTe1/iPQw5TNOLjwx0CdsrCxWk5Tyz50aA30 +KfVperc+m+vEVXIPI1qluI0iTPcHd/lMQYCsu6tKWmFP/hAFTIy7rOHMHfPx3RzK +yRNV1UrzJGv5ZUVKq2kymBut +-----END PRIVATE KEY-----`; + +/** + * Create a custom certificate directly via the API. + * Returns the numeric cert ID (from list endpoint), UUID, and name. + */ +async function createCustomCertViaAPI( + baseURL: string, +): Promise<{ id: number; uuid: string; certName: string }> { + const id = generateUniqueId(); + const certName = `export-cert-${id}`; + + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + extraHTTPHeaders: getStorageStateAuthHeaders(), + }); + + try { + const response = await ctx.post('/api/v1/certificates', { + multipart: { + name: certName, + certificate_file: { + name: 'cert.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from(REAL_TEST_CERT), + }, + key_file: { + name: 'key.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from(REAL_TEST_KEY), + }, + }, + }); + + if (!response.ok()) { + throw new Error( + `Failed to create certificate: ${response.status()} ${await response.text()}`, + ); + } + + const createResult = await response.json(); + const certUUID: string = createResult.uuid; + + const listResponse = await ctx.get('/api/v1/certificates'); + if (!listResponse.ok()) { + throw new Error(`Failed to list certificates: ${listResponse.status()}`); + } + const certs: Array<{ id: number; uuid: string }> = await listResponse.json(); + const match = certs.find((c) => c.uuid === certUUID); + if (!match) { + throw new Error(`Certificate with UUID ${certUUID} not found in list after creation`); + } + + return { id: match.id, uuid: certUUID, certName }; + } finally { + await ctx.dispose(); + } +} + +/** + * Delete a certificate directly via the API for cleanup. + */ +async function deleteCertViaAPI(baseURL: string, certId: number): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: STORAGE_STATE, + extraHTTPHeaders: getStorageStateAuthHeaders(), + }); + + try { + await ctx.delete(`/api/v1/certificates/${certId}`); + } finally { + await ctx.dispose(); + } +} + +/** + * Navigate to the certificates page and wait for data to load. + */ +async function navigateToCertificates( + page: import('@playwright/test').Page, +): Promise { + const certsResponse = waitForAPIResponse(page, CERTIFICATES_API); + await page.goto('/certificates'); + await certsResponse; + await waitForLoadingComplete(page); +} + +test.describe('Certificate Export', () => { + const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080'; + const createdCertIds: number[] = []; + + test.beforeEach(async ({ page, adminUser }) => { + await loginUser(page, adminUser); + await waitForLoadingComplete(page); + }); + + test.afterAll(async () => { + for (const certId of createdCertIds) { + await deleteCertViaAPI(baseURL, certId).catch(() => {}); + } + }); + + // --------------------------------------------------------------------------- + // Scenario 1: Export button opens the export dialog + // --------------------------------------------------------------------------- + test('should open export dialog when export button is clicked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Click the export button for the seeded cert', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + + const exportButton = certRow.getByRole('button', { name: /export/i }); + await exportButton.click(); + }); + + await test.step('Verify export dialog opens with correct title', async () => { + const dialog = await waitForDialog(page); + await expect(dialog).toBeVisible(); + + await expect(dialog.getByText(/Export Certificate/i)).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 2: Dialog shows format radio group with PEM selected by default + // --------------------------------------------------------------------------- + test('should show format radio group with PEM selected by default', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + const exportButton = certRow.getByRole('button', { name: /export/i }); + await exportButton.click(); + await waitForDialog(page); + }); + + await test.step('Verify format radio group is present', async () => { + const dialog = page.getByRole('dialog'); + const radioGroup = dialog.getByRole('radiogroup'); + await expect(radioGroup).toBeVisible(); + }); + + await test.step('Verify PEM is selected by default', async () => { + const dialog = page.getByRole('dialog'); + const pemRadio = dialog.getByRole('radio', { name: /PEM/i }); + await expect(pemRadio).toHaveAttribute('aria-checked', 'true'); + }); + + await test.step('Verify all three format options are present', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('radio', { name: /PEM/i })).toBeVisible(); + await expect(dialog.getByRole('radio', { name: /PFX/i })).toBeVisible(); + await expect(dialog.getByRole('radio', { name: /DER/i })).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 3: Selecting a different format updates the radio state + // --------------------------------------------------------------------------- + test('should update radio state when different format is selected', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Select DER format and verify state', async () => { + const dialog = page.getByRole('dialog'); + const derRadio = dialog.getByRole('radio', { name: /DER/i }); + await derRadio.click(); + + await expect(derRadio).toHaveAttribute('aria-checked', 'true'); + + const pemRadio = dialog.getByRole('radio', { name: /PEM/i }); + await expect(pemRadio).toHaveAttribute('aria-checked', 'false'); + }); + + await test.step('Select PFX format and verify state', async () => { + const dialog = page.getByRole('dialog'); + const pfxRadio = dialog.getByRole('radio', { name: /PFX/i }); + await pfxRadio.click(); + + await expect(pfxRadio).toHaveAttribute('aria-checked', 'true'); + + const derRadio = dialog.getByRole('radio', { name: /DER/i }); + await expect(derRadio).toHaveAttribute('aria-checked', 'false'); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 4: Include private key checkbox is visible for cert with key + // --------------------------------------------------------------------------- + test('should show include private key checkbox for cert with key', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate with key via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify include private key checkbox is present', async () => { + const dialog = page.getByRole('dialog'); + const includeKeyCheckbox = dialog.locator('#include-key'); + await expect(includeKeyCheckbox).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 5: Checking include key shows password field + // --------------------------------------------------------------------------- + test('should show password field when include private key is checked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate with key via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify password field is hidden initially', async () => { + const dialog = page.getByRole('dialog'); + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toHaveCount(0); + }); + + await test.step('Check include private key checkbox', async () => { + const dialog = page.getByRole('dialog'); + const includeKeyCheckbox = dialog.locator('#include-key'); + await includeKeyCheckbox.check(); + }); + + await test.step('Verify password field and warning appear', async () => { + const dialog = page.getByRole('dialog'); + + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toBeVisible(); + + const warning = dialog.getByText(/re-authentication/i); + await expect(warning).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 6: Selecting PFX format shows PFX password field + // --------------------------------------------------------------------------- + test('should show PFX password field when PFX format is selected', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify PFX password is hidden with PEM selected', async () => { + const dialog = page.getByRole('dialog'); + const pfxPasswordInput = dialog.locator('#pfx-password'); + await expect(pfxPasswordInput).toHaveCount(0); + }); + + await test.step('Select PFX format and verify PFX password appears', async () => { + const dialog = page.getByRole('dialog'); + const pfxRadio = dialog.getByRole('radio', { name: /PFX/i }); + await pfxRadio.click(); + + const pfxPasswordInput = dialog.locator('#pfx-password'); + await expect(pfxPasswordInput).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 7: Cancel closes dialog without exporting + // --------------------------------------------------------------------------- + test('should close dialog without exporting when Cancel is clicked', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Click Cancel and verify dialog closes', async () => { + const dialog = page.getByRole('dialog'); + const cancelButton = dialog.getByRole('button', { name: /cancel/i }); + await cancelButton.click(); + + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); + + await test.step('Verify certificate row is still present', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 8: Escape key closes dialog + // --------------------------------------------------------------------------- + test('should close dialog when Escape key is pressed', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Press Escape and verify dialog closes', async () => { + const dialog = page.getByRole('dialog'); + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 9: Successful PEM export triggers file download + // --------------------------------------------------------------------------- + test('should download PEM file on successful export', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Submit export with PEM format and verify download', async () => { + const dialog = page.getByRole('dialog'); + + // PEM is default — click export + const downloadPromise = page.waitForEvent('download', { timeout: 15000 }); + const exportButton = dialog.locator('[data-testid="export-certificate-submit"]'); + await exportButton.click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.pem$/); + }); + + await test.step('Verify dialog closed after successful export', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 10: Export with include key but no password is rejected + // --------------------------------------------------------------------------- + test('should require password when exporting with private key', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate with key via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Check include key and try to submit without password', async () => { + const dialog = page.getByRole('dialog'); + const includeKeyCheckbox = dialog.locator('#include-key'); + await includeKeyCheckbox.check(); + + // Password field should be required + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toBeVisible(); + await expect(passwordInput).toHaveAttribute('required', ''); + }); + + await test.step('Submit without password — dialog should remain open', async () => { + const dialog = page.getByRole('dialog'); + const exportButton = dialog.locator('[data-testid="export-certificate-submit"]'); + await exportButton.click(); + + // Dialog should still be visible (HTML5 validation prevents submission) + await expect(dialog).toBeVisible(); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 11: Dialog resets form state when reopened + // --------------------------------------------------------------------------- + test('should reset form state when dialog is reopened', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Change format to DER and check include key', async () => { + const dialog = page.getByRole('dialog'); + const derRadio = dialog.getByRole('radio', { name: /DER/i }); + await derRadio.click(); + + const includeKeyCheckbox = dialog.locator('#include-key'); + await includeKeyCheckbox.check(); + }); + + await test.step('Close the dialog', async () => { + const dialog = page.getByRole('dialog'); + const cancelButton = dialog.getByRole('button', { name: /cancel/i }); + await cancelButton.click(); + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + }); + + await test.step('Reopen dialog and verify form is reset', async () => { + const certRow = page.getByRole('row').filter({ hasText: certName }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + + const dialog = page.getByRole('dialog'); + + // PEM should be selected again (default) + const pemRadio = dialog.getByRole('radio', { name: /PEM/i }); + await expect(pemRadio).toHaveAttribute('aria-checked', 'true'); + + // Include key checkbox should be unchecked + const includeKeyCheckbox = dialog.locator('#include-key'); + await expect(includeKeyCheckbox).not.toBeChecked(); + + // Password field should not be visible + const passwordInput = dialog.locator('#export-password'); + await expect(passwordInput).toHaveCount(0); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 12: Dialog accessibility — proper ARIA roles and labels + // --------------------------------------------------------------------------- + test('should have proper ARIA roles and keyboard accessibility', async ({ page }) => { + let certName: string; + + await test.step('Seed a custom certificate via API', async () => { + const result = await createCustomCertViaAPI(baseURL); + createdCertIds.push(result.id); + certName = result.certName; + }); + + await test.step('Navigate and open export dialog', async () => { + await navigateToCertificates(page); + const certRow = page.getByRole('row').filter({ hasText: certName }); + await expect(certRow).toBeVisible({ timeout: 10000 }); + certRow.getByRole('button', { name: /export/i }).click(); + await waitForDialog(page); + }); + + await test.step('Verify dialog has proper ARIA role', async () => { + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + }); + + await test.step('Verify dialog has a heading', async () => { + const dialog = page.getByRole('dialog'); + const heading = dialog.getByRole('heading'); + await expect(heading).toBeVisible(); + }); + + await test.step('Verify radiogroup has an accessible label', async () => { + const dialog = page.getByRole('dialog'); + const radioGroup = dialog.getByRole('radiogroup'); + await expect(radioGroup).toHaveAttribute('aria-label'); + }); + + await test.step('Verify keyboard navigation through format options', async () => { + // Tab into the dialog — focus should land on an interactive element + await page.keyboard.press('Tab'); + const focusedElement = page.locator(':focus'); + await expect(focusedElement).toBeVisible(); + }); + + await test.step('Verify export button label', async () => { + const dialog = page.getByRole('dialog'); + const exportButton = dialog.locator('[data-testid="export-certificate-submit"]'); + await expect(exportButton).toBeVisible(); + await expect(exportButton).toContainText(/export/i); + }); + }); + + // --------------------------------------------------------------------------- + // Scenario 13: Every cert row has an export button + // --------------------------------------------------------------------------- + test('should show export button for all certificates in the list', async ({ page }) => { + await test.step('Navigate to certificates page', async () => { + await navigateToCertificates(page); + }); + + await test.step('Verify each data row has an export button', async () => { + const rows = page.locator('tbody tr'); + const rowCount = await rows.count(); + + if (rowCount === 0) { + // Only empty state — nothing to verify + return; + } + + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + const cellCount = await row.locator('td').count(); + + // Skip empty-state row (has colspan) + if (cellCount < 4) continue; + + const exportBtn = row.getByRole('button', { name: /export/i }); + await expect(exportBtn).toBeVisible(); + } + }); + }); +}); diff --git a/tests/core/certificates.spec.ts b/tests/core/certificates.spec.ts index b285aa139..9c1b2fca8 100644 --- a/tests/core/certificates.spec.ts +++ b/tests/core/certificates.spec.ts @@ -349,22 +349,19 @@ test.describe('SSL Certificates - CRUD Operations', () => { test('should validate required name field', async ({ page }) => { await test.step('Open upload dialog', async () => { await getAddCertButton(page).click(); - await waitForDialog(page); // Wait for dialog to be fully interactive + await waitForDialog(page); }); - await test.step('Try to submit with empty name', async () => { + await test.step('Verify submit is disabled with empty name', async () => { const dialog = page.getByRole('dialog'); + const nameInput = dialog.locator('#certificate-name'); const uploadButton = dialog.getByRole('button', { name: /upload/i }); - await uploadButton.click(); - // Form should show validation error or prevent submission - const nameInput = dialog.locator('input').first(); - const isInvalid = await nameInput.evaluate((el: HTMLInputElement) => - el.validity.valid === false || el.getAttribute('aria-invalid') === 'true' - ).catch(() => false); + // Name input should have HTML5 required attribute + await expect(nameInput).toHaveAttribute('required', ''); - // HTML5 validation should prevent submission - expect(isInvalid || true).toBeTruthy(); + // Submit button should be disabled when name is empty + await expect(uploadButton).toBeDisabled(); }); await test.step('Close dialog', async () => { @@ -375,21 +372,100 @@ test.describe('SSL Certificates - CRUD Operations', () => { test('should require certificate file', async ({ page }) => { await test.step('Open upload dialog', async () => { await getAddCertButton(page).click(); - await waitForDialog(page); // Wait for dialog to be fully interactive + await waitForDialog(page); }); - await test.step('Fill name but no certificate file', async () => { + await test.step('Verify cert file is required', async () => { const dialog = page.getByRole('dialog'); - const nameInput = dialog.locator('input').first(); + const nameInput = dialog.locator('#certificate-name'); await nameInput.fill('Test Certificate'); + // FileDropZone uses aria-required, not native HTML required + const certFileInput = dialog.locator('#cert-file'); + await expect(certFileInput).toHaveAttribute('aria-required', 'true'); + + // Submit should remain disabled without cert file + const uploadButton = dialog.getByRole('button', { name: /upload/i }); + await expect(uploadButton).toBeDisabled(); + }); + + await test.step('Close dialog', async () => { + await getCancelButton(page).click(); + }); + }); + + test('should show key file as optional when no cert is selected', async ({ page }) => { + await test.step('Open upload dialog', async () => { + await getAddCertButton(page).click(); + await waitForDialog(page); + }); + + await test.step('Verify key file is not aria-required by default', async () => { + const dialog = page.getByRole('dialog'); + const keyFileInput = dialog.locator('#key-file'); + await expect(keyFileInput).toBeVisible(); + // No cert selected — key file should not be required yet + await expect(keyFileInput).not.toHaveAttribute('aria-required', 'true'); + }); + + await test.step('Close dialog', async () => { + await getCancelButton(page).click(); + }); + }); + + test('should require key file when a PEM certificate is selected', async ({ page }) => { + await test.step('Open upload dialog', async () => { + await getAddCertButton(page).click(); + await waitForDialog(page); + }); + + await test.step('Select a PEM cert file', async () => { + const dialog = page.getByRole('dialog'); + const certFileInput = dialog.locator('#cert-file'); + await certFileInput.setInputFiles({ + name: 'server.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from('-----BEGIN CERTIFICATE-----\nMIIFake\n-----END CERTIFICATE-----'), + }); + }); + + await test.step('Verify key file becomes aria-required', async () => { + const dialog = page.getByRole('dialog'); + const keyFileInput = dialog.locator('#key-file'); + await expect(keyFileInput).toBeVisible(); + await expect(keyFileInput).toHaveAttribute('aria-required', 'true'); + + // Submit should still be disabled — no key file provided yet const uploadButton = dialog.getByRole('button', { name: /upload/i }); - await uploadButton.click(); + await expect(uploadButton).toBeDisabled(); + }); + + await test.step('Close dialog', async () => { + await getCancelButton(page).click(); + }); + }); + + test('should hide key file input when a PFX certificate is selected', async ({ page }) => { + await test.step('Open upload dialog', async () => { + await getAddCertButton(page).click(); + await waitForDialog(page); + }); - // Should show validation error for missing file + await test.step('Select a PFX cert file', async () => { + const dialog = page.getByRole('dialog'); const certFileInput = dialog.locator('#cert-file'); - const isRequired = await certFileInput.getAttribute('required'); - expect(isRequired !== null).toBeTruthy(); + await certFileInput.setInputFiles({ + name: 'bundle.pfx', + mimeType: 'application/x-pkcs12', + buffer: Buffer.from('PFX'), + }); + }); + + await test.step('Verify key file input is removed from DOM', async () => { + const dialog = page.getByRole('dialog'); + const keyFileInput = dialog.locator('#key-file'); + // PFX bundles the key — the key file section is unmounted entirely + await expect(keyFileInput).not.toBeAttached(); }); await test.step('Close dialog', async () => { @@ -397,17 +473,39 @@ test.describe('SSL Certificates - CRUD Operations', () => { }); }); - test('should require private key file', async ({ page }) => { + test('should remove key file input when cert format changes from PEM to PFX', async ({ page }) => { await test.step('Open upload dialog', async () => { await getAddCertButton(page).click(); - await waitForDialog(page); // Wait for dialog to be fully interactive + await waitForDialog(page); }); - await test.step('Verify private key is required', async () => { + await test.step('Select a PEM cert first', async () => { const dialog = page.getByRole('dialog'); + const certFileInput = dialog.locator('#cert-file'); + await certFileInput.setInputFiles({ + name: 'server.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from('-----BEGIN CERTIFICATE-----\nMIIFake\n-----END CERTIFICATE-----'), + }); + // Confirm key file becomes required const keyFileInput = dialog.locator('#key-file'); - const isRequired = await keyFileInput.getAttribute('required'); - expect(isRequired !== null).toBeTruthy(); + await expect(keyFileInput).toHaveAttribute('aria-required', 'true'); + }); + + await test.step('Switch to PFX cert', async () => { + const dialog = page.getByRole('dialog'); + const certFileInput = dialog.locator('#cert-file'); + await certFileInput.setInputFiles({ + name: 'bundle.pfx', + mimeType: 'application/x-pkcs12', + buffer: Buffer.from('PFX'), + }); + }); + + await test.step('Verify key file input is removed from DOM after format switch', async () => { + const dialog = page.getByRole('dialog'); + const keyFileInput = dialog.locator('#key-file'); + await expect(keyFileInput).not.toBeAttached(); }); await test.step('Close dialog', async () => { @@ -774,17 +872,23 @@ test.describe('SSL Certificates - CRUD Operations', () => { test.describe('Form Validation', () => { test('should reject empty friendly name', async ({ page }) => { - await test.step('Try to upload with empty name', async () => { + await test.step('Open upload dialog', async () => { await getAddCertButton(page).click(); - await waitForDialog(page); // Wait for dialog to be fully interactive + await waitForDialog(page); + }); + await test.step('Verify upload blocked with empty name', async () => { const dialog = page.getByRole('dialog'); const uploadButton = dialog.getByRole('button', { name: /upload/i }); - await uploadButton.click(); - // Should not close dialog (validation error) + // Submit should be disabled with empty name + await expect(uploadButton).toBeDisabled(); + + // Dialog should remain open await expect(dialog).toBeVisible(); + }); + await test.step('Close dialog', async () => { await getCancelButton(page).click(); }); });