Compare commits
81 Commits
bot/update
...
v0.26.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a75f5fe3 | ||
|
|
877fee487b | ||
|
|
9c416599f8 | ||
|
|
34903cdd49 | ||
|
|
a059edf60d | ||
|
|
c63e4a3d6b | ||
|
|
0e8ff1bc2a | ||
|
|
683967bbfc | ||
|
|
15947616a9 | ||
|
|
813985a903 | ||
|
|
bd48c17aab | ||
|
|
8239a94938 | ||
|
|
fb8d80f6a3 | ||
|
|
8090c12556 | ||
|
|
0e0d42c9fd | ||
|
|
14b48f23b6 | ||
|
|
0c0adf0e5a | ||
|
|
135edd208c | ||
|
|
81a083a634 | ||
|
|
149a2071c3 | ||
|
|
027a1b1f18 | ||
|
|
7adf39a6a0 | ||
|
|
5408ebc95b | ||
|
|
92a90bb8a1 | ||
|
|
6391532b2d | ||
|
|
a161163508 | ||
|
|
5b6bf945d9 | ||
|
|
877a32f180 | ||
|
|
1fe8a79ea3 | ||
|
|
7c8e8c001c | ||
|
|
29c56ab283 | ||
|
|
0391f2b3e3 | ||
|
|
942f585dd1 | ||
|
|
3005db6943 | ||
|
|
f3c33dc81b | ||
|
|
44e2bdec95 | ||
|
|
d71fc0b95f | ||
|
|
f295788ac1 | ||
|
|
c19aa55fd7 | ||
|
|
ea3d93253f | ||
|
|
114dca89c6 | ||
|
|
c7932fa1d9 | ||
|
|
f0ffc27ca7 | ||
|
|
4dfcf70c08 | ||
|
|
71b34061d9 | ||
|
|
368130b07a | ||
|
|
85216ba6e0 | ||
|
|
06aacdee98 | ||
|
|
ef44ae40ec | ||
|
|
26ea2e9da1 | ||
|
|
b90da3740c | ||
|
|
0ae1dc998a | ||
|
|
44f475778f | ||
|
|
48f6b7a12b | ||
|
|
122e1fc20b | ||
|
|
850550c5da | ||
|
|
3b4fa064d6 | ||
|
|
78a9231c8a | ||
|
|
e88a4c7982 | ||
|
|
9c056faec7 | ||
|
|
e865fa2b8b | ||
|
|
e1bc648dfc | ||
|
|
9d8d97e556 | ||
|
|
9dc55675ca | ||
|
|
30c9d735aa | ||
|
|
e49ea7061a | ||
|
|
5c50d8b314 | ||
|
|
af95c1bdb3 | ||
|
|
01e3d910f1 | ||
|
|
1230694f55 | ||
|
|
77f15a225f | ||
|
|
d75abb80d1 | ||
|
|
42bc897610 | ||
|
|
b15f7c3fbc | ||
|
|
bb99dacecd | ||
|
|
4b925418f2 | ||
|
|
9e82efd23a | ||
|
|
8f7c10440c | ||
|
|
a439e1d467 | ||
|
|
718a957ad9 | ||
|
|
059ff9c6b4 |
16
.github/agents/Management.agent.md
vendored
16
.github/agents/Management.agent.md
vendored
@@ -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**:
|
||||
|
||||
8
.github/agents/Planning.agent.md
vendored
8
.github/agents/Planning.agent.md
vendored
@@ -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.
|
||||
|
||||
18
.github/instructions/subagent.instructions.md
vendored
18
.github/instructions/subagent.instructions.md
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@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@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@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: gorm-security-report-${{ github.run_id }}
|
||||
path: docs/reports/gorm-scan-ci-*.txt
|
||||
|
||||
2
.github/workflows/auto-versioning.yml
vendored
2
.github/workflows/auto-versioning.yml
vendored
@@ -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 }}
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -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 }}
|
||||
|
||||
10
.github/workflows/docker-build.yml
vendored
10
.github/workflows/docker-build.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
2
.github/workflows/e2e-tests-split.yml
vendored
2
.github/workflows/e2e-tests-split.yml
vendored
@@ -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') }}
|
||||
|
||||
2
.github/workflows/nightly-build.yml
vendored
2
.github/workflows/nightly-build.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/renovate.yml
vendored
2
.github/workflows/renovate.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
2
.github/workflows/supply-chain-pr.yml
vendored
2
.github/workflows/supply-chain-pr.yml
vendored
@@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
14
Dockerfile
14
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
|
||||
@@ -92,7 +92,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# ---- Frontend Builder ----
|
||||
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
|
||||
# renovate: datasource=docker depName=node
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.1-alpine@sha256:01743339035a5c3c11a373cd7c83aeab6ed1457b55da6a69e014a95ac4e4700b AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.1-alpine@sha256:8510330d3eb72c804231a834b1a8ebb55cb3796c3e4431297a24d246b8add4d5 AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend package files
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}()
|
||||
|
||||
// Read to string
|
||||
// Limit size to avoid DoS (e.g. 1MB)
|
||||
certBytes := make([]byte, 1024*1024)
|
||||
n, _ := certSrc.Read(certBytes)
|
||||
certPEM := string(certBytes[:n])
|
||||
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)
|
||||
}
|
||||
|
||||
keyBytes := make([]byte, 1024*1024)
|
||||
n, _ = keySrc.Read(keyBytes)
|
||||
keyPEM := string(keyBytes[: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")
|
||||
}
|
||||
}()
|
||||
|
||||
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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, 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
}()
|
||||
|
||||
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("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
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
|
||||
}
|
||||
|
||||
// Validate ID range
|
||||
if id == 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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]"
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()...)
|
||||
|
||||
@@ -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. <CaddyConfigDir>/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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
166
backend/internal/caddy/config_customcert_test.go
Normal file
166
backend/internal/caddy/config_customcert_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
38
backend/internal/services/certificate_helpers_test.go
Normal file
38
backend/internal/services/certificate_helpers_test.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
// 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: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse certificate input: %w", err)
|
||||
}
|
||||
|
||||
// Create DB entry
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
520
backend/internal/services/certificate_service_coverage_test.go
Normal file
520
backend/internal/services/certificate_service_coverage_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
524
backend/internal/services/certificate_validator.go
Normal file
524
backend/internal/services/certificate_validator.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
324
backend/internal/services/certificate_validator_coverage_test.go
Normal file
324
backend/internal/services/certificate_validator_coverage_test.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
388
backend/internal/services/certificate_validator_test.go
Normal file
388
backend/internal/services/certificate_validator_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,7 @@ func TestNewRFC2136Provider(t *testing.T) {
|
||||
|
||||
if provider == nil {
|
||||
t.Fatal("NewRFC2136Provider() returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
if provider.propagationTimeout != RFC2136DefaultPropagationTimeout {
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
1808
docs/plans/archive/custom-cert-upload-management-spec-2026-04-15.md
Normal file
1808
docs/plans/archive/custom-cert-upload-management-spec-2026-04-15.md
Normal file
File diff suppressed because it is too large
Load Diff
432
docs/plans/archive/nightly-vuln-remediation-spec.md
Normal file
432
docs/plans/archive/nightly-vuln-remediation-spec.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Nightly Build Vulnerability Remediation Plan
|
||||
|
||||
**Date**: 2026-04-09
|
||||
**Status**: Draft — Awaiting Approval
|
||||
**Scope**: Dependency security patches for 5 HIGH + 3 MEDIUM vulnerability groups
|
||||
**Target**: Single PR — all changes ship together
|
||||
**Archived**: Previous plan (CrowdSec Hub Bootstrapping) → `docs/plans/archive/crowdsec-hub-bootstrap-spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The Charon nightly build is failing container image vulnerability scans with **5 HIGH-severity** and **multiple MEDIUM-severity** findings. These vulnerabilities exist across three compiled binaries embedded in the container image:
|
||||
|
||||
1. **Charon backend** (`/app/charon`) — Go binary built from `backend/go.mod`
|
||||
2. **Caddy** (`/usr/bin/caddy`) — Built via xcaddy in the Dockerfile Caddy builder stage
|
||||
3. **CrowdSec** (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) — Built from source in the Dockerfile CrowdSec builder stage
|
||||
|
||||
Additionally, the **nightly branch** was synced from development before the Go 1.26.2 bump landed, so the nightly image was compiled with Go 1.26.1 (confirmed in `ci_failure.log` line 55: `GO_VERSION: 1.26.1`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Go Version Audit
|
||||
|
||||
All files on `development` / `main` already reference **Go 1.26.2**:
|
||||
|
||||
| File | Current Value | Status |
|
||||
|------|---------------|--------|
|
||||
| `backend/go.mod` | `go 1.26.2` | ✅ Current |
|
||||
| `go.work` | `go 1.26.2` | ✅ Current |
|
||||
| `Dockerfile` (`ARG GO_VERSION`) | `1.26.2` | ✅ Current |
|
||||
| `.github/workflows/nightly-build.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/codecov-upload.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/quality-checks.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/codeql.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/benchmark.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/release-goreleaser.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/e2e-tests-split.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | `'1.26.1'` | ❌ **Stale** |
|
||||
| `scripts/install-go-1.26.0.sh` | `1.26.0` | ⚠️ Old install script (not used in CI/Docker builds) |
|
||||
|
||||
**Root Cause of Go stdlib CVEs**: The nightly branch's last sync predated the 1.26.2 bump. The next nightly sync from development will propagate 1.26.2 automatically. The only file requiring a fix is the example workflow.
|
||||
|
||||
### 2.2 Vulnerability Inventory
|
||||
|
||||
#### HIGH Severity (must fix — merge-blocking)
|
||||
|
||||
| # | CVE / GHSA | Package | Current | Fix | Binary | Dep Type |
|
||||
|---|-----------|---------|---------|-----|--------|----------|
|
||||
| 1 | CVE-2026-39883 | `go.opentelemetry.io/otel/sdk` | v1.40.0 | v1.43.0 | Caddy | Transitive (Caddy plugins → otelhttp → otel/sdk) |
|
||||
| 2 | CVE-2026-34986 | `github.com/go-jose/go-jose/v3` | v3.0.4 | **v3.0.5** | Caddy | Transitive (caddy-security → JWT/JOSE stack) |
|
||||
| 3 | CVE-2026-34986 | `github.com/go-jose/go-jose/v4` | v4.1.3 | **v4.1.4** | Caddy | Transitive (grpc v1.79.3 → go-jose/v4) |
|
||||
| 4 | CVE-2026-32286 | `github.com/jackc/pgproto3/v2` | v2.3.3 | pgx/v4 v4.18.3 ¹ | CrowdSec | Transitive (CrowdSec → pgx/v4 v4.18.2 → pgproto3/v2) |
|
||||
|
||||
¹ pgproto3/v2 has **no patched release**. Fix requires upstream migration to pgx/v5 (uses pgproto3/v3). See §5 Risk Assessment.
|
||||
|
||||
#### MEDIUM Severity (fix in same pass)
|
||||
|
||||
| # | CVE / GHSA | Package(s) | Current | Fix | Binary | Dep Type |
|
||||
|---|-----------|------------|---------|-----|--------|----------|
|
||||
| 5 | GHSA-xmrv-pmrh-hhx2 | AWS SDK v2: `eventstream` v1.7.1, `cloudwatchlogs` v1.57.2, `kinesis` v1.40.1, `s3` v1.87.3 | See left | Bump all | CrowdSec | Direct deps of CrowdSec v1.7.7 |
|
||||
| 6 | CVE-2026-32281, -32288, -32289 | Go stdlib | 1.26.1 | **1.26.2** | All (nightly image) | Toolchain |
|
||||
| 7 | CVE-2026-39882 | OTel HTTP exporters: `otlploghttp` v0.16.0, `otlpmetrichttp` v1.40.0, `otlptracehttp` v1.40.0 | See left | Bump all | Caddy | Transitive (Caddy plugins → OTel exporters) |
|
||||
|
||||
### 2.3 Dependency Chain Analysis
|
||||
|
||||
#### Backend (`backend/go.mod`)
|
||||
|
||||
```
|
||||
charon/backend (direct)
|
||||
└─ docker/docker v28.5.2+incompatible (direct)
|
||||
└─ otelhttp v0.68.0 (indirect)
|
||||
└─ otel/sdk v1.43.0 (indirect) — already at latest
|
||||
└─ grpc v1.79.3 (indirect)
|
||||
└─ otlptracehttp v1.42.0 (indirect) ── CVE-2026-39882
|
||||
```
|
||||
|
||||
Backend resolved versions (verified via `go list -m -json`):
|
||||
|
||||
| Package | Version | Type |
|
||||
|---------|---------|------|
|
||||
| `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` | v1.42.0 | indirect |
|
||||
| `google.golang.org/grpc` | v1.79.3 | indirect |
|
||||
| `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | v0.68.0 | indirect |
|
||||
|
||||
**Not present in backend**: go-jose/v3, go-jose/v4, otel/sdk, pgproto3/v2, AWS SDK, otlploghttp, otlpmetrichttp.
|
||||
|
||||
#### CrowdSec Binary (Dockerfile `crowdsec-builder` stage)
|
||||
|
||||
Source: CrowdSec v1.7.7 `go.mod` (verified via `git clone --depth 1 --branch v1.7.7`):
|
||||
|
||||
```
|
||||
crowdsec v1.7.7
|
||||
└─ pgx/v4 v4.18.2 (direct) → pgproto3/v2 v2.3.3 (indirect) ── CVE-2026-32286
|
||||
└─ aws-sdk-go-v2/service/s3 v1.87.3 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/service/cloudwatchlogs v1.57.2 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/service/kinesis v1.40.1 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 (indirect) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ otel v1.39.0, otel/metric v1.39.0, otel/trace v1.39.0 (indirect)
|
||||
```
|
||||
|
||||
Confirmed by Trivy image scan (`trivy-image-report.json`): pgproto3/v2 v2.3.3 flagged in `usr/local/bin/crowdsec` and `usr/local/bin/cscli`.
|
||||
|
||||
#### Caddy Binary (Dockerfile `caddy-builder` stage)
|
||||
|
||||
Built via xcaddy with plugins. go.mod is generated at build time. Vulnerable packages enter via:
|
||||
|
||||
```
|
||||
xcaddy build (Caddy v2.11.2 + plugins)
|
||||
└─ caddy-security v1.1.61 → go-jose/v3 (JWT auth stack) ── CVE-2026-34986
|
||||
└─ grpc (patched to v1.79.3 in Dockerfile) → go-jose/v4 v4.1.3 ── CVE-2026-34986
|
||||
└─ Caddy/plugins → otel/sdk v1.40.0 ── CVE-2026-39883
|
||||
└─ Caddy/plugins → otlploghttp v0.16.0, otlpmetrichttp v1.40.0, otlptracehttp v1.40.0 ── CVE-2026-39882
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Specifications
|
||||
|
||||
### 3.1 Backend go.mod Changes
|
||||
|
||||
**File**: `backend/go.mod` (+ `backend/go.sum` auto-generated)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Upgrade grpc to v1.80.0 (security patches for transitive deps)
|
||||
go get google.golang.org/grpc@v1.80.0
|
||||
|
||||
# CVE-2026-39882: OTel HTTP exporter (backend only has otlptracehttp)
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
|
||||
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
Expected `go.mod` diff:
|
||||
- `google.golang.org/grpc` v1.79.3 → v1.80.0
|
||||
- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` v1.42.0 → v1.43.0
|
||||
|
||||
### 3.2 Dockerfile — Caddy Builder Stage Patches
|
||||
|
||||
**File**: `Dockerfile`, within the caddy-builder `RUN bash -c '...'` block, in the **Stage 2: Apply security patches** section.
|
||||
|
||||
Add after the existing `go get golang.org/x/net@v${XNET_VERSION};` line:
|
||||
|
||||
```bash
|
||||
# CVE-2026-34986: go-jose JOSE/JWT validation bypass
|
||||
# Fix in v3.0.5 and v4.1.4. Pin here until caddy-security ships fix.
|
||||
# renovate: datasource=go depName=github.com/go-jose/go-jose/v3
|
||||
go get github.com/go-jose/go-jose/v3@v3.0.5; \
|
||||
# renovate: datasource=go depName=github.com/go-jose/go-jose/v4
|
||||
go get github.com/go-jose/go-jose/v4@v4.1.4; \
|
||||
# CVE-2026-39883: OTel SDK resource leak
|
||||
# Fix in v1.43.0. Pin here until Caddy ships with updated OTel.
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/sdk
|
||||
go get go.opentelemetry.io/otel/sdk@v1.43.0; \
|
||||
# CVE-2026-39882: OTel HTTP exporter request smuggling
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp@v0.19.0; \
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0; \
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0; \
|
||||
```
|
||||
|
||||
Update existing grpc patch line from `v1.79.3` → `v1.80.0`:
|
||||
|
||||
```bash
|
||||
# Before:
|
||||
go get google.golang.org/grpc@v1.79.3; \
|
||||
# After:
|
||||
# CVE-2026-33186: gRPC-Go auth bypass (fixed in v1.79.3)
|
||||
# CVE-2026-34986: go-jose/v4 transitive fix (requires grpc >= v1.80.0)
|
||||
# renovate: datasource=go depName=google.golang.org/grpc
|
||||
go get google.golang.org/grpc@v1.80.0; \
|
||||
```
|
||||
|
||||
### 3.3 Dockerfile — CrowdSec Builder Stage Patches
|
||||
|
||||
**File**: `Dockerfile`, within the crowdsec-builder `RUN` block that patches dependencies.
|
||||
|
||||
Add after the existing `go get golang.org/x/net@v${XNET_VERSION}` line:
|
||||
|
||||
```bash
|
||||
# CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch)
|
||||
# renovate: datasource=go depName=github.com/jackc/pgx/v4
|
||||
go get github.com/jackc/pgx/v4@v4.18.3 && \
|
||||
# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
|
||||
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
|
||||
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis
|
||||
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3
|
||||
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \
|
||||
```
|
||||
|
||||
CrowdSec grpc already at v1.80.0 — no change needed.
|
||||
|
||||
### 3.4 Example Workflow Fix
|
||||
|
||||
**File**: `.github/skills/examples/gorm-scanner-ci-workflow.yml` (line 28)
|
||||
|
||||
```yaml
|
||||
# Before:
|
||||
go-version: "1.26.1"
|
||||
# After:
|
||||
go-version: "1.26.2"
|
||||
```
|
||||
|
||||
### 3.5 Go Stdlib CVEs (nightly branch — no code change needed)
|
||||
|
||||
The nightly workflow syncs `development → nightly` via `git merge --ff-only`. Since `development` already has Go 1.26.2 everywhere:
|
||||
- Dockerfile `ARG GO_VERSION=1.26.2` ✓
|
||||
- All CI workflows `GO_VERSION: '1.26.2'` ✓
|
||||
- `backend/go.mod` `go 1.26.2` ✓
|
||||
|
||||
The next nightly run at 09:00 UTC will automatically propagate Go 1.26.2 to the nightly branch and rebuild the image.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Playwright Tests (N/A)
|
||||
|
||||
No UI/UX changes — this is a dependency-only update. Existing E2E tests validate runtime behavior.
|
||||
|
||||
### Phase 2: Backend Implementation
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 2.1 | `backend/go.mod`, `backend/go.sum` | Run `go get` commands from §3.1 |
|
||||
| 2.2 | Verify build | `cd backend && go build ./cmd/api` |
|
||||
| 2.3 | Verify vet | `cd backend && go vet ./...` |
|
||||
| 2.4 | Verify tests | `cd backend && go test ./...` |
|
||||
| 2.5 | Verify vulns | `cd backend && govulncheck ./...` |
|
||||
|
||||
### Phase 3: Dockerfile Implementation
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 3.1 | `Dockerfile` (caddy-builder, ~L258-280) | Add go-jose v3/v4, OTel SDK, OTel exporter patches per §3.2 |
|
||||
| 3.2 | `Dockerfile` (caddy-builder, ~L270) | Update grpc patch v1.79.3 → v1.80.0 |
|
||||
| 3.3 | `Dockerfile` (crowdsec-builder, ~L360-370) | Add pgx, AWS SDK patches per §3.3 |
|
||||
| 3.3a | CrowdSec binaries | After patching deps, run `go build` on CrowdSec binaries before full Docker build for faster compilation feedback |
|
||||
| 3.4 | `Dockerfile` | Verify `docker build .` completes successfully (amd64) |
|
||||
|
||||
### Phase 4: CI / Misc Fixes
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.2 → 1.26.2 |
|
||||
|
||||
### Phase 5: Validation
|
||||
|
||||
| Task | Validation |
|
||||
|------|------------|
|
||||
| 5.1 | `cd backend && go build ./cmd/api` — compiles cleanly |
|
||||
| 5.2 | `cd backend && go test ./...` — all tests pass |
|
||||
| 5.3 | `cd backend && go vet ./...` — no issues |
|
||||
| 5.4 | `cd backend && govulncheck ./...` — 0 findings |
|
||||
| 5.5 | `docker build -t charon:vuln-fix .` — image builds for amd64 |
|
||||
| 5.6 | Trivy scan on built image: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity CRITICAL,HIGH charon:vuln-fix` — 0 HIGH (pgproto3/v2 excepted) |
|
||||
| 5.7 | Container health: `docker run -d -p 8080:8080 charon:vuln-fix && curl -f http://localhost:8080/health` |
|
||||
| 5.8 | E2E Playwright tests pass against rebuilt container |
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk Assessment
|
||||
|
||||
### Low Risk
|
||||
|
||||
| Change | Risk | Rationale |
|
||||
|--------|------|-----------|
|
||||
| `go-jose/v3` v3.0.4 → v3.0.5 | Low | Security patch release only |
|
||||
| `go-jose/v4` v4.1.3 → v4.1.4 | Low | Security patch release only |
|
||||
| `otel/sdk` v1.40.0 → v1.43.0 (Caddy) | Low | Minor bumps, backwards compatible |
|
||||
| `otlptracehttp` v1.42.0 → v1.43.0 (backend) | Low | Minor bump |
|
||||
| OTel exporters (Caddy) | Low | Minor/patch bumps |
|
||||
| Go version example fix | None | Non-runtime file |
|
||||
|
||||
### Medium Risk
|
||||
|
||||
| Change | Risk | Mitigation |
|
||||
|--------|------|------------|
|
||||
| `grpc` v1.79.3 → v1.80.0 | Medium | Minor version bump. gRPC is indirect — Charon doesn't use gRPC directly. Run full test suite. Verify Caddy and CrowdSec still compile. |
|
||||
| AWS SDK major bumps (s3 v1.87→v1.99, cloudwatchlogs v1.57→v1.68, kinesis v1.40→v1.43) | Medium | CrowdSec build may fail if internal APIs changed between versions. Mitigate: run `go mod tidy` after patches and verify CrowdSec binaries compile. **Note:** AWS SDK Go v2 packages use independent semver within the `v1.x.x` line — these are minor version bumps, not major API breaks. |
|
||||
| `pgx/v4` v4.18.2 → v4.18.3 | Medium | Patch release should be safe. May not fully resolve pgproto3/v2 since no patched v2 exists. |
|
||||
|
||||
### Known Limitation: pgproto3/v2 (CVE-2026-32286)
|
||||
|
||||
The `pgproto3/v2` module has **no patched release** — the fix exists only in `pgproto3/v3` (used by `pgx/v5`). CrowdSec v1.7.7 uses `pgx/v4` which depends on `pgproto3/v2`. Remediation:
|
||||
|
||||
1. Bump `pgx/v4` to v4.18.3 (latest v4 patch) — may transitively resolve the issue
|
||||
2. If scanner still flags pgproto3/v2 after the bump: document as **accepted risk with upstream tracking**
|
||||
3. Monitor CrowdSec releases for `pgx/v5` migration
|
||||
4. Consider upgrading `CROWDSEC_VERSION` ARG if a newer CrowdSec release ships with pgx/v5
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance Criteria
|
||||
|
||||
- [ ] `cd backend && go build ./cmd/api` succeeds with zero warnings
|
||||
- [ ] `cd backend && go test ./...` passes with zero failures
|
||||
- [ ] `cd backend && go vet ./...` reports zero issues
|
||||
- [ ] `cd backend && govulncheck ./...` reports zero findings
|
||||
- [ ] Docker image builds successfully for amd64
|
||||
- [ ] Trivy/Grype scan of built image shows 0 new HIGH findings (pgproto3/v2 excepted if upstream unpatched)
|
||||
- [ ] Container starts, health check passes on port 8080
|
||||
- [ ] Existing E2E Playwright tests pass against rebuilt container
|
||||
- [ ] No new compile errors in Caddy or CrowdSec builder stages
|
||||
- [ ] `backend/go.mod` shows updated versions for grpc, otlptracehttp
|
||||
|
||||
---
|
||||
|
||||
## 7. Commit Slicing Strategy
|
||||
|
||||
### Decision: Single PR
|
||||
|
||||
**Rationale**: All changes are dependency version bumps with no feature or behavioral changes. They address a single concern (security vulnerability remediation) and should be reviewed and merged atomically to avoid partial-fix states.
|
||||
|
||||
**Trigger reasons for single PR**:
|
||||
- All changes are security patches — cannot ship partial fixes
|
||||
- Changes span backend + Dockerfile + CI config — logically coupled
|
||||
- No risk of one slice breaking another
|
||||
- Total diff is small (go.mod/go.sum + Dockerfile patch lines + 1 YAML fix)
|
||||
|
||||
### PR-1: Nightly Build Vulnerability Remediation
|
||||
|
||||
**Scope**: All changes in §3.1–§3.4
|
||||
|
||||
**Files modified**:
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) |
|
||||
| `backend/go.sum` | Auto-generated checksum updates |
|
||||
| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages |
|
||||
| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.2 → 1.26.2 |
|
||||
|
||||
**Dependencies**: None (standalone)
|
||||
|
||||
**Validation gates**:
|
||||
1. `go build` / `go test` / `go vet` / `govulncheck` pass
|
||||
2. Docker image builds for amd64
|
||||
3. Trivy/Grype scan passes (0 new HIGH)
|
||||
4. E2E tests pass
|
||||
|
||||
**Rollback**: Revert PR. All changes are version pins — reverting restores previous state with no data migration needed.
|
||||
|
||||
### Post-merge Actions
|
||||
|
||||
1. Nightly build will automatically sync development → nightly and rebuild the image with all patches
|
||||
2. Monitor next nightly scan for zero HIGH findings
|
||||
3. If pgproto3/v2 still flagged: open tracking issue for CrowdSec pgx/v5 upstream migration
|
||||
4. If any AWS SDK bump breaks CrowdSec compilation: pin to intermediate version and document
|
||||
|
||||
---
|
||||
|
||||
## 8. CI Failure Amendment: pgx/v4 Module Path Mismatch
|
||||
|
||||
**Date**: 2026-04-09
|
||||
**Failure**: PR #921 `build-and-push` job, step `crowdsec-builder 7/11`
|
||||
**Error**: `go: github.com/jackc/pgx/v4@v5.9.1: invalid version: go.mod has non-.../v4 module path "github.com/jackc/pgx/v5" (and .../v4/go.mod does not exist) at revision v5.9.1`
|
||||
|
||||
### Root Cause
|
||||
|
||||
Dockerfile line 386 specifies `go get github.com/jackc/pgx/v4@v5.9.1`. This mixes the v4 module path with a v5 version tag. Go's semantic import versioning rejects this because tag `v5.9.1` declares module path `github.com/jackc/pgx/v5` in its go.mod.
|
||||
|
||||
### Fix
|
||||
|
||||
**Dockerfile line 386** — change:
|
||||
```dockerfile
|
||||
go get github.com/jackc/pgx/v4@v5.9.1 && \
|
||||
```
|
||||
to:
|
||||
```dockerfile
|
||||
go get github.com/jackc/pgx/v4@v4.18.3 && \
|
||||
```
|
||||
|
||||
No changes needed to the Renovate annotation (line 385) or the CVE comment (line 384) — both are already correct.
|
||||
|
||||
### Why v4.18.3
|
||||
|
||||
- CrowdSec v1.7.7 uses `github.com/jackc/pgx/v4 v4.18.2` (direct dependency)
|
||||
- v4.18.3 is the latest and likely final v4 release
|
||||
- pgproto3/v2 is archived at v2.3.3 (July 2025) — no fix will be released in the v2 line
|
||||
- The CVE (pgproto3/v2 buffer overflow) can only be fully resolved by CrowdSec migrating to pgx/v5 upstream
|
||||
- Bumping pgx/v4 to v4.18.3 gets the latest v4 maintenance patch; the CVE remains an accepted risk per §5
|
||||
|
||||
### Validation
|
||||
|
||||
The same `docker build` that previously failed at step 7/11 should now pass through the CrowdSec dependency patching stage and proceed to compilation (steps 8-11).
|
||||
|
||||
---
|
||||
|
||||
## 9. Commands Reference
|
||||
|
||||
```bash
|
||||
# === Backend dependency upgrades ===
|
||||
cd /projects/Charon/backend
|
||||
|
||||
go get google.golang.org/grpc@v1.80.0
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
|
||||
go mod tidy
|
||||
|
||||
# === Validate backend ===
|
||||
go build ./cmd/api
|
||||
go test ./...
|
||||
go vet ./...
|
||||
govulncheck ./...
|
||||
|
||||
# === Docker build (after Dockerfile edits) ===
|
||||
cd /projects/Charon
|
||||
docker build -t charon:vuln-fix .
|
||||
|
||||
# === Scan built image ===
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
aquasec/trivy:latest image \
|
||||
--severity CRITICAL,HIGH \
|
||||
charon:vuln-fix
|
||||
|
||||
# === Quick container health check ===
|
||||
docker run -d --name charon-vuln-test -p 8080:8080 charon:vuln-fix
|
||||
sleep 10
|
||||
curl -f http://localhost:8080/health
|
||||
docker stop charon-vuln-test && docker rm charon-vuln-test
|
||||
```
|
||||
@@ -1,432 +1,460 @@
|
||||
# Nightly Build Vulnerability Remediation Plan
|
||||
# Coverage Improvement Plan — Patch Coverage ≥ 90%
|
||||
|
||||
**Date**: 2026-04-09
|
||||
**Date**: 2026-05-02
|
||||
**Status**: Draft — Awaiting Approval
|
||||
**Scope**: Dependency security patches for 5 HIGH + 3 MEDIUM vulnerability groups
|
||||
**Target**: Single PR — all changes ship together
|
||||
**Archived**: Previous plan (CrowdSec Hub Bootstrapping) → `docs/plans/archive/crowdsec-hub-bootstrap-spec.md`
|
||||
**Priority**: High
|
||||
**Archived Previous Plan**: Custom Certificate Upload & Management (Issue #22) → `docs/plans/archive/custom-cert-upload-management-spec-2026-05-02.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
## 1. Introduction
|
||||
|
||||
The Charon nightly build is failing container image vulnerability scans with **5 HIGH-severity** and **multiple MEDIUM-severity** findings. These vulnerabilities exist across three compiled binaries embedded in the container image:
|
||||
This plan identifies exact uncovered branches across the six highest-gap backend source files and two frontend components, and specifies new test cases to close those gaps. The target is to raise overall patch coverage from **85.61% (206 missing lines)** to **≥ 90%**.
|
||||
|
||||
1. **Charon backend** (`/app/charon`) — Go binary built from `backend/go.mod`
|
||||
2. **Caddy** (`/usr/bin/caddy`) — Built via xcaddy in the Dockerfile Caddy builder stage
|
||||
3. **CrowdSec** (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) — Built from source in the Dockerfile CrowdSec builder stage
|
||||
|
||||
Additionally, the **nightly branch** was synced from development before the Go 1.26.2 bump landed, so the nightly image was compiled with Go 1.26.1 (confirmed in `ci_failure.log` line 55: `GO_VERSION: 1.26.1`).
|
||||
**Constraints**:
|
||||
- No source file modifications — test files only
|
||||
- Go tests placed in `*_patch_coverage_test.go` (same package as source)
|
||||
- Frontend tests extend existing `__tests__/*.test.tsx` files
|
||||
- Use testify (Go) and Vitest + React Testing Library (frontend)
|
||||
|
||||
---
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Go Version Audit
|
||||
### 2.1 Coverage Gap Summary
|
||||
|
||||
All files on `development` / `main` already reference **Go 1.26.2**:
|
||||
| Package | File | Missing Lines | Current Coverage |
|
||||
|---|---|---|---|
|
||||
| `handlers` | `certificate_handler.go` | ~54 | 70.28% |
|
||||
| `services` | `certificate_service.go` | ~54 | 82.85% |
|
||||
| `services` | `certificate_validator.go` | ~18 | 88.68% |
|
||||
| `handlers` | `proxy_host_handler.go` | ~12 | 55.17% |
|
||||
| `config` | `config.go` | ~8 | ~92% |
|
||||
| `caddy` | `manager.go` | ~10 | ~88% |
|
||||
| Frontend | `CertificateList.tsx` | moderate | — |
|
||||
| Frontend | `CertificateUploadDialog.tsx` | moderate | — |
|
||||
|
||||
| File | Current Value | Status |
|
||||
|------|---------------|--------|
|
||||
| `backend/go.mod` | `go 1.26.2` | ✅ Current |
|
||||
| `go.work` | `go 1.26.2` | ✅ Current |
|
||||
| `Dockerfile` (`ARG GO_VERSION`) | `1.26.2` | ✅ Current |
|
||||
| `.github/workflows/nightly-build.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/codecov-upload.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/quality-checks.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/codeql.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/benchmark.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/release-goreleaser.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/e2e-tests-split.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | `'1.26.1'` | ❌ **Stale** |
|
||||
| `scripts/install-go-1.26.0.sh` | `1.26.0` | ⚠️ Old install script (not used in CI/Docker builds) |
|
||||
### 2.2 Test Infrastructure (Confirmed)
|
||||
|
||||
**Root Cause of Go stdlib CVEs**: The nightly branch's last sync predated the 1.26.2 bump. The next nightly sync from development will propagate 1.26.2 automatically. The only file requiring a fix is the example workflow.
|
||||
- **In-memory DB**: `gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})`
|
||||
- **Mock auth**: `mockAuthMiddleware()` from `coverage_helpers_test.go`
|
||||
- **Mock backup service**: `&mockBackupService{createFunc: ..., availableSpaceFunc: ...}`
|
||||
- **Manager test hooks**: package-level `generateConfigFunc`, `validateConfigFunc`, `writeFileFunc` vars with `defer` restore pattern
|
||||
- **Frontend mocks**: `vi.mock('../../hooks/...', ...)` and `vi.mock('react-i18next', ...)`
|
||||
|
||||
### 2.2 Vulnerability Inventory
|
||||
### 2.3 Existing Patch Test Files
|
||||
|
||||
#### HIGH Severity (must fix — merge-blocking)
|
||||
|
||||
| # | CVE / GHSA | Package | Current | Fix | Binary | Dep Type |
|
||||
|---|-----------|---------|---------|-----|--------|----------|
|
||||
| 1 | CVE-2026-39883 | `go.opentelemetry.io/otel/sdk` | v1.40.0 | v1.43.0 | Caddy | Transitive (Caddy plugins → otelhttp → otel/sdk) |
|
||||
| 2 | CVE-2026-34986 | `github.com/go-jose/go-jose/v3` | v3.0.4 | **v3.0.5** | Caddy | Transitive (caddy-security → JWT/JOSE stack) |
|
||||
| 3 | CVE-2026-34986 | `github.com/go-jose/go-jose/v4` | v4.1.3 | **v4.1.4** | Caddy | Transitive (grpc v1.79.3 → go-jose/v4) |
|
||||
| 4 | CVE-2026-32286 | `github.com/jackc/pgproto3/v2` | v2.3.3 | pgx/v4 v4.18.3 ¹ | CrowdSec | Transitive (CrowdSec → pgx/v4 v4.18.2 → pgproto3/v2) |
|
||||
|
||||
¹ pgproto3/v2 has **no patched release**. Fix requires upstream migration to pgx/v5 (uses pgproto3/v3). See §5 Risk Assessment.
|
||||
|
||||
#### MEDIUM Severity (fix in same pass)
|
||||
|
||||
| # | CVE / GHSA | Package(s) | Current | Fix | Binary | Dep Type |
|
||||
|---|-----------|------------|---------|-----|--------|----------|
|
||||
| 5 | GHSA-xmrv-pmrh-hhx2 | AWS SDK v2: `eventstream` v1.7.1, `cloudwatchlogs` v1.57.2, `kinesis` v1.40.1, `s3` v1.87.3 | See left | Bump all | CrowdSec | Direct deps of CrowdSec v1.7.7 |
|
||||
| 6 | CVE-2026-32281, -32288, -32289 | Go stdlib | 1.26.1 | **1.26.2** | All (nightly image) | Toolchain |
|
||||
| 7 | CVE-2026-39882 | OTel HTTP exporters: `otlploghttp` v0.16.0, `otlpmetrichttp` v1.40.0, `otlptracehttp` v1.40.0 | See left | Bump all | Caddy | Transitive (Caddy plugins → OTel exporters) |
|
||||
|
||||
### 2.3 Dependency Chain Analysis
|
||||
|
||||
#### Backend (`backend/go.mod`)
|
||||
|
||||
```
|
||||
charon/backend (direct)
|
||||
└─ docker/docker v28.5.2+incompatible (direct)
|
||||
└─ otelhttp v0.68.0 (indirect)
|
||||
└─ otel/sdk v1.43.0 (indirect) — already at latest
|
||||
└─ grpc v1.79.3 (indirect)
|
||||
└─ otlptracehttp v1.42.0 (indirect) ── CVE-2026-39882
|
||||
```
|
||||
|
||||
Backend resolved versions (verified via `go list -m -json`):
|
||||
|
||||
| Package | Version | Type |
|
||||
|---------|---------|------|
|
||||
| `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` | v1.42.0 | indirect |
|
||||
| `google.golang.org/grpc` | v1.79.3 | indirect |
|
||||
| `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | v0.68.0 | indirect |
|
||||
|
||||
**Not present in backend**: go-jose/v3, go-jose/v4, otel/sdk, pgproto3/v2, AWS SDK, otlploghttp, otlpmetrichttp.
|
||||
|
||||
#### CrowdSec Binary (Dockerfile `crowdsec-builder` stage)
|
||||
|
||||
Source: CrowdSec v1.7.7 `go.mod` (verified via `git clone --depth 1 --branch v1.7.7`):
|
||||
|
||||
```
|
||||
crowdsec v1.7.7
|
||||
└─ pgx/v4 v4.18.2 (direct) → pgproto3/v2 v2.3.3 (indirect) ── CVE-2026-32286
|
||||
└─ aws-sdk-go-v2/service/s3 v1.87.3 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/service/cloudwatchlogs v1.57.2 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/service/kinesis v1.40.1 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 (indirect) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ otel v1.39.0, otel/metric v1.39.0, otel/trace v1.39.0 (indirect)
|
||||
```
|
||||
|
||||
Confirmed by Trivy image scan (`trivy-image-report.json`): pgproto3/v2 v2.3.3 flagged in `usr/local/bin/crowdsec` and `usr/local/bin/cscli`.
|
||||
|
||||
#### Caddy Binary (Dockerfile `caddy-builder` stage)
|
||||
|
||||
Built via xcaddy with plugins. go.mod is generated at build time. Vulnerable packages enter via:
|
||||
|
||||
```
|
||||
xcaddy build (Caddy v2.11.2 + plugins)
|
||||
└─ caddy-security v1.1.61 → go-jose/v3 (JWT auth stack) ── CVE-2026-34986
|
||||
└─ grpc (patched to v1.79.3 in Dockerfile) → go-jose/v4 v4.1.3 ── CVE-2026-34986
|
||||
└─ Caddy/plugins → otel/sdk v1.40.0 ── CVE-2026-39883
|
||||
└─ Caddy/plugins → otlploghttp v0.16.0, otlpmetrichttp v1.40.0, otlptracehttp v1.40.0 ── CVE-2026-39882
|
||||
```
|
||||
| File | Existing Tests |
|
||||
|---|---|
|
||||
| `certificate_handler_patch_coverage_test.go` | `TestDelete_UUID_WithBackup_Success`, `_NotFound`, `_InUse` |
|
||||
| `certificate_service_patch_coverage_test.go` | `TestExportCertificate_DER`, `_PFX`, `_P12`, `_UnsupportedFormat` |
|
||||
| `certificate_validator_extra_coverage_test.go` | ECDSA/Ed25519 key match, `ConvertDERToPEM` valid/invalid |
|
||||
| `manager_patch_coverage_test.go` | DNS provider encryption key paths |
|
||||
| `proxy_host_handler_test.go` | Full CRUD + BulkUpdateACL + BulkUpdateSecurityHeaders |
|
||||
| `proxy_host_handler_update_test.go` | Update edge cases, `ParseForwardPortField`, `ParseNullableUintField` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Specifications
|
||||
## 3. Technical Specifications — Per-File Gap Analysis
|
||||
|
||||
### 3.1 Backend go.mod Changes
|
||||
### 3.1 `certificate_handler.go` — Export Re-Auth Path (~18 lines)
|
||||
|
||||
**File**: `backend/go.mod` (+ `backend/go.sum` auto-generated)
|
||||
The `Export` handler re-authenticates the user when `include_key=true`. All six guard branches are uncovered.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
**Gap location**: Lines ~260–320 (password empty check, `user` context key extraction, `map[string]any` cast, `id` field lookup, DB user lookup, bcrypt check)
|
||||
|
||||
# Upgrade grpc to v1.80.0 (security patches for transitive deps)
|
||||
go get google.golang.org/grpc@v1.80.0
|
||||
**New tests** (append to `certificate_handler_patch_coverage_test.go`):
|
||||
|
||||
# CVE-2026-39882: OTel HTTP exporter (backend only has otlptracehttp)
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestExport_IncludeKey_MissingPassword` | POST with `include_key=true`, no `password` field | 403 |
|
||||
| `TestExport_IncludeKey_NoUserContext` | No `"user"` key in gin context | 403 |
|
||||
| `TestExport_IncludeKey_InvalidClaimsType` | `"user"` set to a plain string | 403 |
|
||||
| `TestExport_IncludeKey_UserIDNotInClaims` | `user = map[string]any{}` with no `"id"` key | 403 |
|
||||
| `TestExport_IncludeKey_UserNotFoundInDB` | Valid claims, no matching user row | 403 |
|
||||
| `TestExport_IncludeKey_WrongPassword` | User in DB, wrong plaintext password submitted | 403 |
|
||||
|
||||
go mod tidy
|
||||
```
|
||||
### 3.2 `certificate_handler.go` — Export Service Errors (~4 lines)
|
||||
|
||||
Expected `go.mod` diff:
|
||||
- `google.golang.org/grpc` v1.79.3 → v1.80.0
|
||||
- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` v1.42.0 → v1.43.0
|
||||
**Gap location**: After `ExportCertificate` call — ErrCertNotFound and generic error branches
|
||||
|
||||
### 3.2 Dockerfile — Caddy Builder Stage Patches
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestExport_CertNotFound` | Unknown UUID | 404 |
|
||||
| `TestExport_ServiceError` | Service returns non-not-found error | 500 |
|
||||
|
||||
**File**: `Dockerfile`, within the caddy-builder `RUN bash -c '...'` block, in the **Stage 2: Apply security patches** section.
|
||||
### 3.3 `certificate_handler.go` — Delete Numeric-ID Error Paths (~12 lines)
|
||||
|
||||
Add after the existing `go get golang.org/x/net@v${XNET_VERSION};` line:
|
||||
**Gap location**: `IsCertificateInUse` error, disk space check, backup error, `DeleteCertificateByID` returning `ErrCertInUse` or generic error
|
||||
|
||||
```bash
|
||||
# CVE-2026-34986: go-jose JOSE/JWT validation bypass
|
||||
# Fix in v3.0.5 and v4.1.4. Pin here until caddy-security ships fix.
|
||||
# renovate: datasource=go depName=github.com/go-jose/go-jose/v3
|
||||
go get github.com/go-jose/go-jose/v3@v3.0.5; \
|
||||
# renovate: datasource=go depName=github.com/go-jose/go-jose/v4
|
||||
go get github.com/go-jose/go-jose/v4@v4.1.4; \
|
||||
# CVE-2026-39883: OTel SDK resource leak
|
||||
# Fix in v1.43.0. Pin here until Caddy ships with updated OTel.
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/sdk
|
||||
go get go.opentelemetry.io/otel/sdk@v1.43.0; \
|
||||
# CVE-2026-39882: OTel HTTP exporter request smuggling
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp@v0.19.0; \
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0; \
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0; \
|
||||
```
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestDelete_NumericID_UsageCheckError` | `IsCertificateInUse` returns error | 500 |
|
||||
| `TestDelete_NumericID_LowDiskSpace` | `availableSpaceFunc` returns 0 | 507 |
|
||||
| `TestDelete_NumericID_BackupError` | `createFunc` returns error | 500 |
|
||||
| `TestDelete_NumericID_CertInUse_FromService` | `DeleteCertificateByID` → `ErrCertInUse` | 409 |
|
||||
| `TestDelete_NumericID_DeleteError` | `DeleteCertificateByID` → generic error | 500 |
|
||||
|
||||
Update existing grpc patch line from `v1.79.3` → `v1.80.0`:
|
||||
### 3.4 `certificate_handler.go` — Delete UUID Additional Error Paths (~8 lines)
|
||||
|
||||
```bash
|
||||
# Before:
|
||||
go get google.golang.org/grpc@v1.79.3; \
|
||||
# After:
|
||||
# CVE-2026-33186: gRPC-Go auth bypass (fixed in v1.79.3)
|
||||
# CVE-2026-34986: go-jose/v4 transitive fix (requires grpc >= v1.80.0)
|
||||
# renovate: datasource=go depName=google.golang.org/grpc
|
||||
go get google.golang.org/grpc@v1.80.0; \
|
||||
```
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestDelete_UUID_UsageCheckInternalError` | `IsCertificateInUseByUUID` returns non-ErrCertNotFound error | 500 |
|
||||
| `TestDelete_UUID_LowDiskSpace` | `availableSpaceFunc` returns 0 | 507 |
|
||||
| `TestDelete_UUID_BackupCreationError` | `createFunc` returns error | 500 |
|
||||
| `TestDelete_UUID_CertInUse_FromService` | `DeleteCertificate` → `ErrCertInUse` | 409 |
|
||||
|
||||
### 3.3 Dockerfile — CrowdSec Builder Stage Patches
|
||||
### 3.5 `certificate_handler.go` — Upload/Validate File Open Errors (~8 lines)
|
||||
|
||||
**File**: `Dockerfile`, within the crowdsec-builder `RUN` block that patches dependencies.
|
||||
**Gap location**: `file.Open()` calls on multipart key and chain form files returning errors
|
||||
|
||||
Add after the existing `go get golang.org/x/net@v${XNET_VERSION}` line:
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestUpload_KeyFile_OpenError` | Valid cert file, malformed key multipart entry | 500 |
|
||||
| `TestUpload_ChainFile_OpenError` | Valid cert+key, malformed chain multipart entry | 500 |
|
||||
| `TestValidate_KeyFile_OpenError` | Valid cert, malformed key multipart entry | 500 |
|
||||
| `TestValidate_ChainFile_OpenError` | Valid cert+key, malformed chain multipart entry | 500 |
|
||||
|
||||
```bash
|
||||
# CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch)
|
||||
# renovate: datasource=go depName=github.com/jackc/pgx/v4
|
||||
go get github.com/jackc/pgx/v4@v4.18.3 && \
|
||||
# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
|
||||
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
|
||||
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis
|
||||
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3
|
||||
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \
|
||||
```
|
||||
### 3.6 `certificate_handler.go` — `sendDeleteNotification` Rate-Limit (~2 lines)
|
||||
|
||||
CrowdSec grpc already at v1.80.0 — no change needed.
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestSendDeleteNotification_RateLimit` | Call `sendDeleteNotification` twice within 10-second window | Second call is a no-op |
|
||||
|
||||
### 3.4 Example Workflow Fix
|
||||
---
|
||||
|
||||
**File**: `.github/skills/examples/gorm-scanner-ci-workflow.yml` (line 28)
|
||||
### 3.7 `certificate_service.go` — `SyncFromDisk` Branches (~14 lines)
|
||||
|
||||
```yaml
|
||||
# Before:
|
||||
go-version: "1.26.1"
|
||||
# After:
|
||||
go-version: "1.26.2"
|
||||
```
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestSyncFromDisk_StagingToProductionUpgrade` | DB has staging cert, disk has production cert for same domain | DB cert updated to production provider |
|
||||
| `TestSyncFromDisk_ExpiryOnlyUpdate` | Disk cert content matches DB cert, only expiry changed | Only `expires_at` column updated |
|
||||
| `TestSyncFromDisk_CertRootStatPermissionError` | `os.Chmod(certRoot, 0)` before sync; add skip guard `if os.Getuid() == 0 { t.Skip("chmod permission test cannot run as root") }` | No panic; logs error; function completes |
|
||||
|
||||
### 3.5 Go Stdlib CVEs (nightly branch — no code change needed)
|
||||
### 3.8 `certificate_service.go` — `ListCertificates` Background Goroutine (~4 lines)
|
||||
|
||||
The nightly workflow syncs `development → nightly` via `git merge --ff-only`. Since `development` already has Go 1.26.2 everywhere:
|
||||
- Dockerfile `ARG GO_VERSION=1.26.2` ✓
|
||||
- All CI workflows `GO_VERSION: '1.26.2'` ✓
|
||||
- `backend/go.mod` `go 1.26.2` ✓
|
||||
**Gap location**: `initialized=true` && TTL expired path → spawns background goroutine
|
||||
|
||||
The next nightly run at 09:00 UTC will automatically propagate Go 1.26.2 to the nightly branch and rebuild the image.
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestListCertificates_StaleCache_TriggersBackgroundSync` | `initialized=true`, `lastScan` = 10 min ago | Returns cached list without blocking; background sync completes |
|
||||
|
||||
*Use `require.Eventually(t, func() bool { return svc.lastScan.After(before) }, 2*time.Second, 10*time.Millisecond, "background sync did not update lastScan")` after the call — avoids flaky fixed sleeps.*
|
||||
|
||||
### 3.9 `certificate_service.go` — `GetDecryptedPrivateKey` Nil encSvc and Decrypt Failure (~4 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestGetDecryptedPrivateKey_NoEncSvc` | Service with `nil` encSvc, cert has non-empty `PrivateKeyEncrypted` | Returns error |
|
||||
| `TestGetDecryptedPrivateKey_DecryptFails` | encSvc configured, corrupted ciphertext in DB | Returns wrapped error |
|
||||
|
||||
### 3.10 `certificate_service.go` — `MigratePrivateKeys` Branches (~6 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestMigratePrivateKeys_NoEncSvc` | `encSvc == nil` | Returns nil; logs warning |
|
||||
| `TestMigratePrivateKeys_WithRows` | DB has cert with `private_key` populated, valid encSvc | Row migrated: `private_key` cleared, `private_key_enc` set |
|
||||
|
||||
### 3.11 `certificate_service.go` — `UpdateCertificate` Errors (~4 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestUpdateCertificate_NotFound` | Non-existent UUID | Returns `ErrCertNotFound` |
|
||||
| `TestUpdateCertificate_DBSaveError` | Valid UUID, DB closed before Save | Returns wrapped error |
|
||||
|
||||
### 3.12 `certificate_service.go` — `DeleteCertificate` ACME File Cleanup (~8 lines)
|
||||
|
||||
**Gap location**: `cert.Provider == "letsencrypt"` branch → Walk certRoot and remove `.crt`/`.key`/`.json` files
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestDeleteCertificate_LetsEncryptProvider_FileCleanup` | Create temp `.crt` matching cert domain, delete cert | `.crt` removed from disk |
|
||||
| `TestDeleteCertificate_StagingProvider_FileCleanup` | Provider = `"letsencrypt-staging"` | Same cleanup behavior triggered |
|
||||
|
||||
### 3.13 `certificate_service.go` — `CheckExpiringCertificates` (~8 lines)
|
||||
|
||||
**Implementation** (lines ~966–1020): queries `provider = 'custom'` certs expiring before `threshold`, iterates and sends notification for certs with `daysLeft <= warningDays`.
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestCheckExpiringCertificates_ExpiresInRange` | Custom cert `expires_at = now+5d`, warningDays=30 | Returns slice with 1 cert |
|
||||
| `TestCheckExpiringCertificates_AlreadyExpired` | Custom cert `expires_at = yesterday` | Result contains cert with negative days |
|
||||
| `TestCheckExpiringCertificates_DBError` | DB closed before query | Returns error |
|
||||
|
||||
---
|
||||
|
||||
### 3.14 `certificate_validator.go` — `DetectFormat` Password-Protected PFX (~2 lines)
|
||||
|
||||
**Gap location**: PFX where `pkcs12.DecodeAll("")` fails but first byte is `0x30` (ASN.1 SEQUENCE), DER parse also fails → returns `FormatPFX`
|
||||
|
||||
**New file**: `certificate_validator_patch_coverage_test.go`
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestDetectFormat_PasswordProtectedPFX` | Generate PFX with non-empty password, call `DetectFormat` | Returns `FormatPFX` |
|
||||
|
||||
### 3.15 `certificate_validator.go` — `parsePEMPrivateKey` Additional Block Types (~4 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestParsePEMPrivateKey_PKCS1RSA` | PEM block type `"RSA PRIVATE KEY"` (x509.MarshalPKCS1PrivateKey) | Returns RSA key |
|
||||
| `TestParsePEMPrivateKey_EC` | PEM block type `"EC PRIVATE KEY"` (x509.MarshalECPrivateKey) | Returns ECDSA key |
|
||||
|
||||
### 3.16 `certificate_validator.go` — `detectKeyType` P-384 and Unknown Curves (~4 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestDetectKeyType_ECDSAP384` | P-384 ECDSA key | Returns `"ECDSA-P384"` |
|
||||
| `TestDetectKeyType_ECDSAUnknownCurve` | ECDSA key with custom/unknown curve (e.g. P-224) | Returns `"ECDSA"` |
|
||||
|
||||
### 3.17 `certificate_validator.go` — `ConvertPEMToPFX` Empty Chain (~2 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestConvertPEMToPFX_EmptyChain` | Valid cert+key PEM, empty chain string | Returns PFX bytes without error |
|
||||
|
||||
### 3.18 `certificate_validator.go` — `ConvertPEMToDER` Non-Certificate Block (~2 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestConvertPEMToDER_NonCertBlock` | PEM block type `"PRIVATE KEY"` | Returns nil data and error |
|
||||
|
||||
### 3.19 `certificate_validator.go` — `formatSerial` Nil BigInt (~2 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestFormatSerial_Nil` | `formatSerial(nil)` | Returns `""` |
|
||||
|
||||
---
|
||||
|
||||
### 3.20 `proxy_host_handler.go` — `generateForwardHostWarnings` Private IP (~2 lines)
|
||||
|
||||
**Gap location**: `net.ParseIP(forwardHost) != nil && network.IsPrivateIP(ip)` branch (non-Docker private IP)
|
||||
|
||||
**New file**: `proxy_host_handler_patch_coverage_test.go`
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestGenerateForwardHostWarnings_PrivateIP` | forwardHost = `"192.168.1.100"` (RFC-1918, non-Docker) | Returns warning with field `"forward_host"` |
|
||||
|
||||
### 3.21 `proxy_host_handler.go` — `BulkUpdateSecurityHeaders` Edge Cases (~4 lines)
|
||||
|
||||
| Test Name | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `TestBulkUpdateSecurityHeaders_AllFail_Rollback` | All UUIDs not found → `updated == 0` at end | 400, transaction rolled back |
|
||||
| `TestBulkUpdateSecurityHeaders_ProfileDB_NonNotFoundError` | Profile lookup returns wrapped DB error | 500 |
|
||||
|
||||
---
|
||||
|
||||
### 3.22 Frontend: `CertificateList.tsx` — Untested Branches
|
||||
|
||||
**File**: `frontend/src/components/__tests__/CertificateList.test.tsx`
|
||||
|
||||
| Gap | New Test |
|
||||
|---|---|
|
||||
| `bulkDeleteMutation` success | `'calls bulkDeleteMutation.mutate with selected UUIDs on confirm'` |
|
||||
| `bulkDeleteMutation` error | `'shows error toast on bulk delete failure'` |
|
||||
| Sort direction toggle | `'toggles sort direction when same column clicked twice'` |
|
||||
| `selectedIds` reconciliation | `'reconciles selectedIds when certificate list shrinks'` |
|
||||
| Export dialog open | `'opens export dialog when export button clicked'` |
|
||||
|
||||
### 3.23 Frontend: `CertificateUploadDialog.tsx` — Untested Branches
|
||||
|
||||
**File**: `frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx`
|
||||
|
||||
| Gap | New Test |
|
||||
|---|---|
|
||||
| PFX hides key/chain zones | `'hides key and chain file inputs when PFX file selected'` |
|
||||
| Upload success closes dialog | `'calls onOpenChange(false) on successful upload'` |
|
||||
| Upload error shows toast | `'shows error toast when upload mutation fails'` |
|
||||
| Validate result shown | `'displays validation result after validate clicked'` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Playwright Tests (N/A)
|
||||
### Phase 1: Playwright Smoke Tests (Acceptance Gating)
|
||||
|
||||
No UI/UX changes — this is a dependency-only update. Existing E2E tests validate runtime behavior.
|
||||
Add smoke coverage to confirm certificate export and delete flows reach the backend.
|
||||
|
||||
### Phase 2: Backend Implementation
|
||||
**File**: `tests/certificate-coverage-smoke.spec.ts`
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 2.1 | `backend/go.mod`, `backend/go.sum` | Run `go get` commands from §3.1 |
|
||||
| 2.2 | Verify build | `cd backend && go build ./cmd/api` |
|
||||
| 2.3 | Verify vet | `cd backend && go vet ./...` |
|
||||
| 2.4 | Verify tests | `cd backend && go test ./...` |
|
||||
| 2.5 | Verify vulns | `cd backend && govulncheck ./...` |
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
### Phase 3: Dockerfile Implementation
|
||||
test.describe('Certificate Coverage Smoke', () => {
|
||||
test('export dialog opens when export button clicked', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
// navigate to Certificates, click export on a cert
|
||||
// assert dialog visible
|
||||
})
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 3.1 | `Dockerfile` (caddy-builder, ~L258-280) | Add go-jose v3/v4, OTel SDK, OTel exporter patches per §3.2 |
|
||||
| 3.2 | `Dockerfile` (caddy-builder, ~L270) | Update grpc patch v1.79.3 → v1.80.0 |
|
||||
| 3.3 | `Dockerfile` (crowdsec-builder, ~L360-370) | Add pgx, AWS SDK patches per §3.3 |
|
||||
| 3.3a | CrowdSec binaries | After patching deps, run `go build` on CrowdSec binaries before full Docker build for faster compilation feedback |
|
||||
| 3.4 | `Dockerfile` | Verify `docker build .` completes successfully (amd64) |
|
||||
test('delete dialog opens for deletable certificate', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
// assert delete confirmation dialog appears
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 4: CI / Misc Fixes
|
||||
### Phase 2: Backend — Handler Tests
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.1 → 1.26.2 |
|
||||
**File**: `backend/internal/api/handlers/certificate_handler_patch_coverage_test.go`
|
||||
**Action**: Append all tests from sections 3.1–3.6.
|
||||
|
||||
### Phase 5: Validation
|
||||
Setup pattern for handler tests:
|
||||
|
||||
| Task | Validation |
|
||||
|------|------------|
|
||||
| 5.1 | `cd backend && go build ./cmd/api` — compiles cleanly |
|
||||
| 5.2 | `cd backend && go test ./...` — all tests pass |
|
||||
| 5.3 | `cd backend && go vet ./...` — no issues |
|
||||
| 5.4 | `cd backend && govulncheck ./...` — 0 findings |
|
||||
| 5.5 | `docker build -t charon:vuln-fix .` — image builds for amd64 |
|
||||
| 5.6 | Trivy scan on built image: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity CRITICAL,HIGH charon:vuln-fix` — 0 HIGH (pgproto3/v2 excepted) |
|
||||
| 5.7 | Container health: `docker run -d -p 8080:8080 charon:vuln-fix && curl -f http://localhost:8080/health` |
|
||||
| 5.8 | E2E Playwright tests pass against rebuilt container |
|
||||
```go
|
||||
func setupCertHandlerTest(t *testing.T) (*gin.Engine, *CertificateHandler, *gorm.DB) {
|
||||
t.Helper()
|
||||
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.User{}, &models.ProxyHost{}))
|
||||
tmpDir := t.TempDir()
|
||||
certSvc := services.NewCertificateService(tmpDir, db, nil)
|
||||
backup := &mockBackupService{
|
||||
availableSpaceFunc: func() (int64, error) { return 1 << 30, nil },
|
||||
createFunc: func(string) (string, error) { return "/tmp/backup.db", nil },
|
||||
}
|
||||
h := NewCertificateHandler(certSvc, backup, nil)
|
||||
h.SetDB(db)
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
h.RegisterRoutes(r.Group("/api"))
|
||||
return r, h, db
|
||||
}
|
||||
```
|
||||
|
||||
For `TestExport_IncludeKey_*` tests: inject user into gin context directly using a custom middleware wrapper that sets `"user"` (type `map[string]any`, field `"id"`) to the desired value.
|
||||
|
||||
### Phase 3: Backend — Service Tests
|
||||
|
||||
**File**: `backend/internal/services/certificate_service_patch_coverage_test.go`
|
||||
**Action**: Append all tests from sections 3.7–3.13.
|
||||
|
||||
Setup pattern:
|
||||
|
||||
```go
|
||||
func newTestSvc(t *testing.T) (*CertificateService, *gorm.DB, string) {
|
||||
t.Helper()
|
||||
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()
|
||||
return NewCertificateService(tmpDir, db, nil), db, tmpDir
|
||||
}
|
||||
```
|
||||
|
||||
For `TestMigratePrivateKeys_WithRows`: use `db.Exec("INSERT INTO ssl_certificates (..., private_key) VALUES (...)` raw SQL to bypass GORM's `gorm:"-"` tag.
|
||||
|
||||
### Phase 4: Backend — Validator Tests
|
||||
|
||||
**File**: `backend/internal/services/certificate_validator_patch_coverage_test.go` (new)
|
||||
|
||||
Key helpers needed:
|
||||
|
||||
```go
|
||||
// generatePKCS1RSAKeyPEM returns an RSA key in PKCS#1 "RSA PRIVATE KEY" PEM format.
|
||||
func generatePKCS1RSAKeyPEM(t *testing.T) []byte {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
}
|
||||
|
||||
// generateECKeyPEM returns an EC key in "EC PRIVATE KEY" (SEC1) PEM format.
|
||||
func generateECKeyPEM(t *testing.T, curve elliptic.Curve) []byte {
|
||||
key, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
require.NoError(t, err)
|
||||
b, err := x509.MarshalECPrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Backend — Proxy Host Handler Tests
|
||||
|
||||
**File**: `backend/internal/api/handlers/proxy_host_handler_patch_coverage_test.go` (new)
|
||||
|
||||
Setup pattern mirrors existing `proxy_host_handler_test.go` — use in-memory SQLite, `mockAuthMiddleware`, and `mockCaddyManager` (already available via test hook vars).
|
||||
|
||||
### Phase 6: Frontend Tests
|
||||
|
||||
**Files**:
|
||||
- `frontend/src/components/__tests__/CertificateList.test.tsx`
|
||||
- `frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx`
|
||||
|
||||
Use existing mock structure; add new `it(...)` blocks inside existing `describe` blocks.
|
||||
|
||||
Frontend bulk delete success test pattern:
|
||||
|
||||
```typescript
|
||||
it('calls bulkDeleteMutation.mutate with selected UUIDs on confirm', async () => {
|
||||
const bulkDeleteFn = vi.fn()
|
||||
mockUseBulkDeleteCertificates.mockReturnValue({
|
||||
mutate: bulkDeleteFn,
|
||||
isPending: false,
|
||||
})
|
||||
render(<CertificateList />)
|
||||
// select checkboxes, click bulk delete, confirm dialog
|
||||
expect(bulkDeleteFn).toHaveBeenCalledWith(['uuid-1', 'uuid-2'])
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 7: Validation
|
||||
|
||||
1. `cd /projects/Charon && bash scripts/go-test-coverage.sh`
|
||||
2. `cd /projects/Charon && bash scripts/frontend-test-coverage.sh`
|
||||
3. `bash scripts/local-patch-report.sh` → verify `test-results/local-patch-report.md` shows ≥ 90%
|
||||
4. `bash scripts/scan-gorm-security.sh --check` → zero CRITICAL/HIGH
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk Assessment
|
||||
## 5. Commit Slicing Strategy
|
||||
|
||||
### Low Risk
|
||||
**Decision**: One PR with 5 ordered, independently-reviewable commits.
|
||||
|
||||
| Change | Risk | Rationale |
|
||||
|--------|------|-----------|
|
||||
| `go-jose/v3` v3.0.4 → v3.0.5 | Low | Security patch release only |
|
||||
| `go-jose/v4` v4.1.3 → v4.1.4 | Low | Security patch release only |
|
||||
| `otel/sdk` v1.40.0 → v1.43.0 (Caddy) | Low | Minor bumps, backwards compatible |
|
||||
| `otlptracehttp` v1.42.0 → v1.43.0 (backend) | Low | Minor bump |
|
||||
| OTel exporters (Caddy) | Low | Minor/patch bumps |
|
||||
| Go version example fix | None | Non-runtime file |
|
||||
**Rationale**: Four packages touched across two build systems (Go + Node). Atomic commits allow targeted revert if a mock approach proves brittle for a specific file, without rolling back unrelated coverage gains.
|
||||
|
||||
### Medium Risk
|
||||
| # | Scope | Files | Dependencies | Validation Gate |
|
||||
|---|---|---|---|---|
|
||||
| **Commit 1** | Handler re-auth + delete + file-open errors | `certificate_handler_patch_coverage_test.go` (extend) | None | `go test ./backend/internal/api/handlers/...` |
|
||||
| **Commit 2** | Service SyncFromDisk, ListCerts, GetDecryptedKey, Migrate, Update, Delete, CheckExpiring | `certificate_service_patch_coverage_test.go` (extend) | None | `go test ./backend/internal/services/...` |
|
||||
| **Commit 3** | Validator DetectFormat, parsePEMPrivateKey, detectKeyType, ConvertPEMToPFX/DER, formatSerial | `certificate_validator_patch_coverage_test.go` (new) | Commit 2 not required (separate file) | `go test ./backend/internal/services/...` |
|
||||
| **Commit 4** | Proxy host warnings + BulkUpdateSecurityHeaders edge cases | `proxy_host_handler_patch_coverage_test.go` (new) | None | `go test ./backend/internal/api/handlers/...` |
|
||||
| **Commit 5** | Frontend CertificateList + CertificateUploadDialog | `CertificateList.test.tsx`, `CertificateUploadDialog.test.tsx` (extend) | None | `npm run test` |
|
||||
|
||||
| Change | Risk | Mitigation |
|
||||
|--------|------|------------|
|
||||
| `grpc` v1.79.3 → v1.80.0 | Medium | Minor version bump. gRPC is indirect — Charon doesn't use gRPC directly. Run full test suite. Verify Caddy and CrowdSec still compile. |
|
||||
| AWS SDK major bumps (s3 v1.87→v1.99, cloudwatchlogs v1.57→v1.68, kinesis v1.40→v1.43) | Medium | CrowdSec build may fail if internal APIs changed between versions. Mitigate: run `go mod tidy` after patches and verify CrowdSec binaries compile. **Note:** AWS SDK Go v2 packages use independent semver within the `v1.x.x` line — these are minor version bumps, not major API breaks. |
|
||||
| `pgx/v4` v4.18.2 → v4.18.3 | Medium | Patch release should be safe. May not fully resolve pgproto3/v2 since no patched v2 exists. |
|
||||
**Rollback**: Any commit is safe to revert independently — all changes are additive test-only files.
|
||||
|
||||
### Known Limitation: pgproto3/v2 (CVE-2026-32286)
|
||||
|
||||
The `pgproto3/v2` module has **no patched release** — the fix exists only in `pgproto3/v3` (used by `pgx/v5`). CrowdSec v1.7.7 uses `pgx/v4` which depends on `pgproto3/v2`. Remediation:
|
||||
|
||||
1. Bump `pgx/v4` to v4.18.3 (latest v4 patch) — may transitively resolve the issue
|
||||
2. If scanner still flags pgproto3/v2 after the bump: document as **accepted risk with upstream tracking**
|
||||
3. Monitor CrowdSec releases for `pgx/v5` migration
|
||||
4. Consider upgrading `CROWDSEC_VERSION` ARG if a newer CrowdSec release ships with pgx/v5
|
||||
**Contingency**: If the `Export` handler's re-auth tests require gin context injection that the current router wiring doesn't support cleanly, use a sub-router with a custom test middleware that pre-populates `"user"` (`map[string]any{"id": uint(1)}`) with the specific value under test, bypassing `mockAuthMiddleware` for those cases only.
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance Criteria
|
||||
|
||||
- [ ] `cd backend && go build ./cmd/api` succeeds with zero warnings
|
||||
- [ ] `cd backend && go test ./...` passes with zero failures
|
||||
- [ ] `cd backend && go vet ./...` reports zero issues
|
||||
- [ ] `cd backend && govulncheck ./...` reports zero findings
|
||||
- [ ] Docker image builds successfully for amd64
|
||||
- [ ] Trivy/Grype scan of built image shows 0 new HIGH findings (pgproto3/v2 excepted if upstream unpatched)
|
||||
- [ ] Container starts, health check passes on port 8080
|
||||
- [ ] Existing E2E Playwright tests pass against rebuilt container
|
||||
- [ ] No new compile errors in Caddy or CrowdSec builder stages
|
||||
- [ ] `backend/go.mod` shows updated versions for grpc, otlptracehttp
|
||||
- [ ] `go test -race ./backend/...` — all tests pass, no data races
|
||||
- [ ] Backend patch coverage ≥ 90% for all modified Go files per `test-results/local-patch-report.md`
|
||||
- [ ] `npm run test` — all Vitest tests pass
|
||||
- [ ] Frontend patch coverage ≥ 90% for `CertificateList.tsx` and `CertificateUploadDialog.tsx`
|
||||
- [ ] GORM security scan: zero CRITICAL/HIGH findings
|
||||
- [ ] No new `//nolint` or `//nosec` directives introduced
|
||||
- [ ] No source file modifications — test files only
|
||||
- [ ] All new Go test names follow `TestFunctionName_Scenario` convention
|
||||
- [ ] Previous spec archived to `docs/plans/archive/`
|
||||
|
||||
---
|
||||
|
||||
## 7. Commit Slicing Strategy
|
||||
## 7. Estimated Coverage Impact
|
||||
|
||||
### Decision: Single PR
|
||||
| File | Current | Estimated After | Lines Recovered |
|
||||
|---|---|---|---|
|
||||
| `certificate_handler.go` | 70.28% | ~85% | ~42 lines |
|
||||
| `certificate_service.go` | 82.85% | ~92% | ~44 lines |
|
||||
| `certificate_validator.go` | 88.68% | ~96% | ~18 lines |
|
||||
| `proxy_host_handler.go` | 55.17% | ~60% | ~8 lines |
|
||||
| `CertificateList.tsx` | moderate | high | ~15 lines |
|
||||
| `CertificateUploadDialog.tsx` | moderate | high | ~12 lines |
|
||||
| **Overall patch** | **85.61%** | **≥ 90%** | **~139 lines** |
|
||||
|
||||
**Rationale**: All changes are dependency version bumps with no feature or behavioral changes. They address a single concern (security vulnerability remediation) and should be reviewed and merged atomically to avoid partial-fix states.
|
||||
|
||||
**Trigger reasons for single PR**:
|
||||
- All changes are security patches — cannot ship partial fixes
|
||||
- Changes span backend + Dockerfile + CI config — logically coupled
|
||||
- No risk of one slice breaking another
|
||||
- Total diff is small (go.mod/go.sum + Dockerfile patch lines + 1 YAML fix)
|
||||
|
||||
### PR-1: Nightly Build Vulnerability Remediation
|
||||
|
||||
**Scope**: All changes in §3.1–§3.4
|
||||
|
||||
**Files modified**:
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) |
|
||||
| `backend/go.sum` | Auto-generated checksum updates |
|
||||
| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages |
|
||||
| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.1 → 1.26.2 |
|
||||
|
||||
**Dependencies**: None (standalone)
|
||||
|
||||
**Validation gates**:
|
||||
1. `go build` / `go test` / `go vet` / `govulncheck` pass
|
||||
2. Docker image builds for amd64
|
||||
3. Trivy/Grype scan passes (0 new HIGH)
|
||||
4. E2E tests pass
|
||||
|
||||
**Rollback**: Revert PR. All changes are version pins — reverting restores previous state with no data migration needed.
|
||||
|
||||
### Post-merge Actions
|
||||
|
||||
1. Nightly build will automatically sync development → nightly and rebuild the image with all patches
|
||||
2. Monitor next nightly scan for zero HIGH findings
|
||||
3. If pgproto3/v2 still flagged: open tracking issue for CrowdSec pgx/v5 upstream migration
|
||||
4. If any AWS SDK bump breaks CrowdSec compilation: pin to intermediate version and document
|
||||
|
||||
---
|
||||
|
||||
## 8. CI Failure Amendment: pgx/v4 Module Path Mismatch
|
||||
|
||||
**Date**: 2026-04-09
|
||||
**Failure**: PR #921 `build-and-push` job, step `crowdsec-builder 7/11`
|
||||
**Error**: `go: github.com/jackc/pgx/v4@v5.9.1: invalid version: go.mod has non-.../v4 module path "github.com/jackc/pgx/v5" (and .../v4/go.mod does not exist) at revision v5.9.1`
|
||||
|
||||
### Root Cause
|
||||
|
||||
Dockerfile line 386 specifies `go get github.com/jackc/pgx/v4@v5.9.1`. This mixes the v4 module path with a v5 version tag. Go's semantic import versioning rejects this because tag `v5.9.1` declares module path `github.com/jackc/pgx/v5` in its go.mod.
|
||||
|
||||
### Fix
|
||||
|
||||
**Dockerfile line 386** — change:
|
||||
```dockerfile
|
||||
go get github.com/jackc/pgx/v4@v5.9.1 && \
|
||||
```
|
||||
to:
|
||||
```dockerfile
|
||||
go get github.com/jackc/pgx/v4@v4.18.3 && \
|
||||
```
|
||||
|
||||
No changes needed to the Renovate annotation (line 385) or the CVE comment (line 384) — both are already correct.
|
||||
|
||||
### Why v4.18.3
|
||||
|
||||
- CrowdSec v1.7.7 uses `github.com/jackc/pgx/v4 v4.18.2` (direct dependency)
|
||||
- v4.18.3 is the latest and likely final v4 release
|
||||
- pgproto3/v2 is archived at v2.3.3 (July 2025) — no fix will be released in the v2 line
|
||||
- The CVE (pgproto3/v2 buffer overflow) can only be fully resolved by CrowdSec migrating to pgx/v5 upstream
|
||||
- Bumping pgx/v4 to v4.18.3 gets the latest v4 maintenance patch; the CVE remains an accepted risk per §5
|
||||
|
||||
### Validation
|
||||
|
||||
The same `docker build` that previously failed at step 7/11 should now pass through the CrowdSec dependency patching stage and proceed to compilation (steps 8-11).
|
||||
|
||||
---
|
||||
|
||||
## 9. Commands Reference
|
||||
|
||||
```bash
|
||||
# === Backend dependency upgrades ===
|
||||
cd /projects/Charon/backend
|
||||
|
||||
go get google.golang.org/grpc@v1.80.0
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
|
||||
go mod tidy
|
||||
|
||||
# === Validate backend ===
|
||||
go build ./cmd/api
|
||||
go test ./...
|
||||
go vet ./...
|
||||
govulncheck ./...
|
||||
|
||||
# === Docker build (after Dockerfile edits) ===
|
||||
cd /projects/Charon
|
||||
docker build -t charon:vuln-fix .
|
||||
|
||||
# === Scan built image ===
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
aquasec/trivy:latest image \
|
||||
--severity CRITICAL,HIGH \
|
||||
charon:vuln-fix
|
||||
|
||||
# === Quick container health check ===
|
||||
docker run -d --name charon-vuln-test -p 8080:8080 charon:vuln-fix
|
||||
sleep 10
|
||||
curl -f http://localhost:8080/health
|
||||
docker stop charon-vuln-test && docker rm charon-vuln-test
|
||||
```
|
||||
> **Note**: Proxy host handler remains below 90% after this plan because the `Create`/`Update`/`Delete` handler paths require full Caddy manager mock integration. A follow-up plan should address these with a dedicated `mockCaddyManager` interface.
|
||||
|
||||
47
docs/reports/qa_report_pr928.md
Normal file
47
docs/reports/qa_report_pr928.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# QA Audit Report — PR #928: CI Test Fix
|
||||
|
||||
**Date:** 2026-04-13
|
||||
**Scope:** Targeted fix for two CI test failures across three files
|
||||
**Auditor:** QA Security Agent
|
||||
|
||||
## Modified Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `backend/internal/api/handlers/certificate_handler.go` | Added `key_file` validation guard for non-PFX uploads |
|
||||
| `backend/internal/api/handlers/certificate_handler_test.go` | Added `TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert` test |
|
||||
| `backend/internal/services/certificate_service_coverage_test.go` | Removed duplicate `ALTER TABLE` in `TestCertificateService_MigratePrivateKeys` |
|
||||
|
||||
## Check Results
|
||||
|
||||
| # | Check | Result | Details |
|
||||
|---|---|---|---|
|
||||
| 1 | **Backend Build** (`go build ./...`) | **PASS** | Clean build, no errors |
|
||||
| 2a | **Targeted Test 1** (`TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert`) | **PASS** | Previously failing, now passes |
|
||||
| 2b | **Targeted Test 2** (`TestCertificateService_MigratePrivateKeys`) | **PASS** | Previously failing (duplicate ALTER TABLE), now passes — all 3 subtests pass |
|
||||
| 3a | **Regression: Handler Suite** (`TestCertificateHandler*`, 37 tests) | **PASS** | All 37 tests pass in 1.27s |
|
||||
| 3b | **Regression: Service Suite** (`TestCertificateService*`) | **PASS** | All tests pass in 2.96s |
|
||||
| 4 | **Go Vet** (`go vet ./...`) | **PASS** | No issues |
|
||||
| 5 | **Golangci-lint** (handlers + services) | **PASS** | All warnings are pre-existing in unmodified files (crowdsec, audit_log). Zero new lint issues in modified files |
|
||||
| 6 | **Security Review** | **PASS** | See analysis below |
|
||||
|
||||
## Security Analysis
|
||||
|
||||
The handler change (lines 169–175 of `certificate_handler.go`) was reviewed for:
|
||||
|
||||
| Vector | Assessment |
|
||||
|---|---|
|
||||
| **Injection** | `services.DetectFormat()` operates on already-read `certBytes` (bounded by `maxFileSize` = 1MB via `io.LimitReader`). No additional I/O or shell invocation. |
|
||||
| **Information Disclosure** | Returns a static error string `"key_file is required for PEM/DER certificate uploads"`. No user-controlled data reflected in the response. |
|
||||
| **Auth Bypass** | Route `POST /certificates` is registered inside the `management` group (confirmed at `routes.go:696`), which requires authentication. The guard is additive — it rejects earlier, not later. |
|
||||
| **DoS** | No new allocations or expensive operations. `DetectFormat` is a simple byte-header check on data already in memory. |
|
||||
|
||||
**Verdict:** No new attack surface introduced. The change is a pure input validation tightening.
|
||||
|
||||
## Warnings
|
||||
|
||||
- **Pre-existing lint warnings** exist in unmodified files (`crowdsec_handler.go`, `crowdsec_*_test.go`, `audit_log_handler_test.go`). These are tracked separately and are not related to this PR.
|
||||
|
||||
## Final Verdict
|
||||
|
||||
**PASS** — All six checks pass. Both previously failing CI tests now succeed. No regressions detected in the broader handler and service suites. No security concerns with the changes.
|
||||
330
frontend/package-lock.json
generated
330
frontend/package-lock.json
generated
@@ -14,20 +14,20 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.97.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next": "^26.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-i18next": "^17.0.3",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tldts": "^7.0.28"
|
||||
@@ -46,15 +46,15 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@typescript-eslint/utils": "^8.58.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"@typescript-eslint/utils": "^8.58.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@vitest/eslint-plugin": "^1.6.15",
|
||||
"@vitest/eslint-plugin": "^1.6.16",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
@@ -70,11 +70,11 @@
|
||||
"eslint-plugin-unicorn": "^64.0.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"jsdom": "29.0.2",
|
||||
"knip": "^6.3.1",
|
||||
"postcss": "^8.5.9",
|
||||
"knip": "^6.4.1",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4",
|
||||
"zod-validation-error": "^5.0.0"
|
||||
@@ -101,9 +101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz",
|
||||
"integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==",
|
||||
"version": "5.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz",
|
||||
"integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -584,9 +584,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -608,9 +608,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
|
||||
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -625,7 +625,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.1.1"
|
||||
"@csstools/css-calc": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -659,9 +659,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz",
|
||||
"integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -868,9 +868,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/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": {
|
||||
@@ -1164,9 +1164,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1224,9 +1224,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,
|
||||
@@ -3288,9 +3288,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz",
|
||||
"integrity": "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==",
|
||||
"version": "5.99.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz",
|
||||
"integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3298,12 +3298,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz",
|
||||
"integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==",
|
||||
"version": "5.99.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
|
||||
"integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.97.0"
|
||||
"@tanstack/query-core": "5.99.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3609,9 +3609,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint-plugin-jsx-a11y/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": {
|
||||
@@ -3849,17 +3849,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
|
||||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
|
||||
"integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/type-utils": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/type-utils": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -3872,22 +3872,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
|
||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3903,14 +3903,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz",
|
||||
"integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
|
||||
"integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.58.1",
|
||||
"@typescript-eslint/types": "^8.58.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.58.2",
|
||||
"@typescript-eslint/types": "^8.58.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3925,14 +3925,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
|
||||
"integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
|
||||
"integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1"
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3943,9 +3943,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz",
|
||||
"integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
|
||||
"integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3960,15 +3960,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz",
|
||||
"integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
|
||||
"integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -3985,9 +3985,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
|
||||
"integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
|
||||
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3999,16 +3999,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
|
||||
"integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
|
||||
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.58.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/visitor-keys": "8.58.1",
|
||||
"@typescript-eslint/project-service": "8.58.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/visitor-keys": "8.58.2",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -4027,16 +4027,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
|
||||
"integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
|
||||
"integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1"
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -4051,13 +4051,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
|
||||
"integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
|
||||
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4446,9 +4446,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.15.tgz",
|
||||
"integrity": "sha512-dTMjrdngmcB+DxomlKQ+SUubCTvd0m2hQQFpv5sx+GRodmeoxr2PVbphk57SVp250vpxphk9Ccwyv6fQ6+2gkA==",
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.16.tgz",
|
||||
"integrity": "sha512-2pBN1F1JXq6zTSaYC58CMJa7pGxXIRsLfOioeZM4cPE3pRdSh1ySTSoHPQlOTEF5WgoVzWZQxhGQ3ygT78hOVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4858,9 +4858,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
"integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4878,8 +4878,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001774",
|
||||
"browserslist": "^4.28.2",
|
||||
"caniuse-lite": "^1.0.30001787",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -4911,9 +4911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.11.2",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz",
|
||||
"integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==",
|
||||
"version": "4.11.3",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
|
||||
"integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
@@ -4952,9 +4952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.17",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz",
|
||||
"integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==",
|
||||
"version": "2.10.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
|
||||
"integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -5117,9 +5117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001787",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
||||
"integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
|
||||
"version": "1.0.30001788",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
|
||||
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5777,9 +5777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.334",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
|
||||
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
|
||||
"version": "1.5.336",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz",
|
||||
"integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -6204,9 +6204,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y/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": {
|
||||
@@ -6810,9 +6810,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7080,9 +7080,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
|
||||
"integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
|
||||
"version": "17.5.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
|
||||
"integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7275,9 +7275,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.0.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.4.tgz",
|
||||
"integrity": "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==",
|
||||
"version": "26.0.5",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz",
|
||||
"integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7486,9 +7486,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-builtin-module/node_modules/builtin-modules": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz",
|
||||
"integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.1.0.tgz",
|
||||
"integrity": "sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7945,9 +7945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
|
||||
"integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -8055,9 +8055,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.3.1.tgz",
|
||||
"integrity": "sha512-22kLJloVcOVOAudCxlFOC0ICAMme7dKsS7pVTEnrmyKGpswb8ieznvAiSKUeFVDJhb01ect6dkDc1Ha1g1sPpg==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.4.1.tgz",
|
||||
"integrity": "sha512-Ry+ywmDFSZvKp/jx7LxMgsZWRTs931alV84e60lh0Stf6kSRYqSIUTkviyyDFRcSO3yY1Kpbi83OirN+4lA2Xw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -9929,9 +9929,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": [
|
||||
{
|
||||
@@ -10085,9 +10085,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz",
|
||||
"integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==",
|
||||
"version": "17.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.3.tgz",
|
||||
"integrity": "sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -10189,9 +10189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
|
||||
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
|
||||
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -10211,12 +10211,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
|
||||
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
|
||||
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.0"
|
||||
"react-router": "7.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -10875,9 +10875,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
|
||||
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
|
||||
"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"
|
||||
},
|
||||
@@ -11288,16 +11288,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
|
||||
"integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
|
||||
"integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"@typescript-eslint/typescript-estree": "8.58.1",
|
||||
"@typescript-eslint/utils": "8.58.1"
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@typescript-eslint/typescript-estree": "8.58.2",
|
||||
"@typescript-eslint/utils": "8.58.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -11341,9 +11341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
|
||||
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -33,20 +33,20 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.97.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next": "^26.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-i18next": "^17.0.3",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tldts": "^7.0.28"
|
||||
@@ -65,15 +65,15 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@typescript-eslint/utils": "^8.58.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"@typescript-eslint/utils": "^8.58.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@vitest/eslint-plugin": "^1.6.15",
|
||||
"@vitest/eslint-plugin": "^1.6.16",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
@@ -89,11 +89,11 @@
|
||||
"eslint-plugin-unicorn": "^64.0.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"jsdom": "29.0.2",
|
||||
"knip": "^6.3.1",
|
||||
"postcss": "^8.5.9",
|
||||
"knip": "^6.4.1",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4",
|
||||
"zod-validation-error": "^5.0.0"
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { getCertificates, uploadCertificate, deleteCertificate, type Certificate } from '../certificates';
|
||||
import {
|
||||
getCertificates,
|
||||
getCertificateDetail,
|
||||
uploadCertificate,
|
||||
updateCertificate,
|
||||
deleteCertificate,
|
||||
exportCertificate,
|
||||
validateCertificate,
|
||||
type Certificate,
|
||||
type CertificateDetail,
|
||||
} from '../certificates';
|
||||
import client from '../client';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -17,12 +28,14 @@ describe('certificates API', () => {
|
||||
});
|
||||
|
||||
const mockCert: Certificate = {
|
||||
id: 1,
|
||||
domain: 'example.com',
|
||||
uuid: 'abc-123',
|
||||
domains: 'example.com',
|
||||
issuer: 'Let\'s Encrypt',
|
||||
expires_at: '2023-01-01',
|
||||
status: 'valid',
|
||||
provider: 'letsencrypt',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
};
|
||||
|
||||
it('getCertificates calls client.get', async () => {
|
||||
@@ -47,7 +60,76 @@ describe('certificates API', () => {
|
||||
|
||||
it('deleteCertificate calls client.delete', async () => {
|
||||
vi.mocked(client.delete).mockResolvedValue({ data: {} });
|
||||
await deleteCertificate(1);
|
||||
expect(client.delete).toHaveBeenCalledWith('/certificates/1');
|
||||
await deleteCertificate('abc-123');
|
||||
expect(client.delete).toHaveBeenCalledWith('/certificates/abc-123');
|
||||
});
|
||||
|
||||
it('getCertificateDetail calls client.get with uuid', async () => {
|
||||
const detail: CertificateDetail = {
|
||||
...mockCert,
|
||||
assigned_hosts: [],
|
||||
chain: [],
|
||||
auto_renew: false,
|
||||
created_at: '2023-01-01',
|
||||
updated_at: '2023-01-01',
|
||||
};
|
||||
vi.mocked(client.get).mockResolvedValue({ data: detail });
|
||||
const result = await getCertificateDetail('abc-123');
|
||||
expect(client.get).toHaveBeenCalledWith('/certificates/abc-123');
|
||||
expect(result).toEqual(detail);
|
||||
});
|
||||
|
||||
it('updateCertificate calls client.put with name', async () => {
|
||||
vi.mocked(client.put).mockResolvedValue({ data: mockCert });
|
||||
const result = await updateCertificate('abc-123', 'New Name');
|
||||
expect(client.put).toHaveBeenCalledWith('/certificates/abc-123', { name: 'New Name' });
|
||||
expect(result).toEqual(mockCert);
|
||||
});
|
||||
|
||||
it('exportCertificate calls client.post with blob response type', async () => {
|
||||
const blob = new Blob(['data']);
|
||||
vi.mocked(client.post).mockResolvedValue({ data: blob });
|
||||
const result = await exportCertificate('abc-123', 'pem', true, 'pass', 'pfx-pass');
|
||||
expect(client.post).toHaveBeenCalledWith(
|
||||
'/certificates/abc-123/export',
|
||||
{ format: 'pem', include_key: true, password: 'pass', pfx_password: 'pfx-pass' },
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
expect(result).toEqual(blob);
|
||||
});
|
||||
|
||||
it('validateCertificate calls client.post with FormData', async () => {
|
||||
const validation = { valid: true, common_name: 'example.com', domains: ['example.com'], issuer_org: 'LE', expires_at: '2024-01-01', key_match: true, chain_valid: true, chain_depth: 1, warnings: [], errors: [] };
|
||||
vi.mocked(client.post).mockResolvedValue({ data: validation });
|
||||
const certFile = new File(['cert'], 'cert.pem', { type: 'text/plain' });
|
||||
const keyFile = new File(['key'], 'key.pem', { type: 'text/plain' });
|
||||
|
||||
const result = await validateCertificate(certFile, keyFile);
|
||||
expect(client.post).toHaveBeenCalledWith('/certificates/validate', expect.any(FormData), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
expect(result).toEqual(validation);
|
||||
});
|
||||
|
||||
it('uploadCertificate includes chain file when provided', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockCert });
|
||||
const certFile = new File(['cert'], 'cert.pem');
|
||||
const keyFile = new File(['key'], 'key.pem');
|
||||
const chainFile = new File(['chain'], 'chain.pem');
|
||||
|
||||
await uploadCertificate('My Cert', certFile, keyFile, chainFile);
|
||||
const formData = vi.mocked(client.post).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('chain_file')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('validateCertificate includes chain file when provided', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: {} });
|
||||
const certFile = new File(['cert'], 'cert.pem');
|
||||
const chainFile = new File(['chain'], 'chain.pem');
|
||||
|
||||
await validateCertificate(certFile, undefined, chainFile);
|
||||
const formData = vi.mocked(client.post).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('chain_file')).toBeTruthy();
|
||||
expect(formData.get('key_file')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +1,123 @@
|
||||
import client from './client'
|
||||
|
||||
/** Represents an SSL/TLS certificate. */
|
||||
export interface Certificate {
|
||||
id?: number
|
||||
uuid: string
|
||||
name?: string
|
||||
domain: 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
|
||||
/** @deprecated Use uuid instead */
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface AssignedHost {
|
||||
uuid: string
|
||||
name: string
|
||||
domain_names: string
|
||||
}
|
||||
|
||||
export interface ChainEntry {
|
||||
subject: string
|
||||
issuer: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface CertificateDetail extends Certificate {
|
||||
assigned_hosts: AssignedHost[]
|
||||
chain: ChainEntry[]
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all SSL certificates.
|
||||
* @returns Promise resolving to array of Certificate objects
|
||||
* @throws {AxiosError} If the request fails
|
||||
*/
|
||||
export async function getCertificates(): Promise<Certificate[]> {
|
||||
const response = await client.get<Certificate[]>('/certificates')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a new SSL certificate with its private key.
|
||||
* @param name - Display name for the certificate
|
||||
* @param certFile - The certificate file (PEM format)
|
||||
* @param keyFile - The private key file (PEM format)
|
||||
* @returns Promise resolving to the created Certificate
|
||||
* @throws {AxiosError} If upload fails or certificate is invalid
|
||||
*/
|
||||
export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise<Certificate> {
|
||||
export async function getCertificateDetail(uuid: string): Promise<CertificateDetail> {
|
||||
const response = await client.get<CertificateDetail>(`/certificates/${uuid}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function uploadCertificate(
|
||||
name: string,
|
||||
certFile: File,
|
||||
keyFile?: File,
|
||||
chainFile?: File,
|
||||
): Promise<Certificate> {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name)
|
||||
formData.append('certificate_file', certFile)
|
||||
formData.append('key_file', keyFile)
|
||||
if (keyFile) formData.append('key_file', keyFile)
|
||||
if (chainFile) formData.append('chain_file', chainFile)
|
||||
|
||||
const response = await client.post<Certificate>('/certificates', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an SSL certificate.
|
||||
* @param id - The ID of the certificate to delete
|
||||
* @throws {AxiosError} If deletion fails or certificate not found
|
||||
*/
|
||||
export async function deleteCertificate(id: number): Promise<void> {
|
||||
await client.delete(`/certificates/${id}`)
|
||||
export async function updateCertificate(uuid: string, name: string): Promise<Certificate> {
|
||||
const response = await client.put<Certificate>(`/certificates/${uuid}`, { name })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteCertificate(uuid: string): Promise<void> {
|
||||
await client.delete(`/certificates/${uuid}`)
|
||||
}
|
||||
|
||||
export async function exportCertificate(
|
||||
uuid: string,
|
||||
format: string,
|
||||
includeKey: boolean,
|
||||
password?: string,
|
||||
pfxPassword?: string,
|
||||
): Promise<Blob> {
|
||||
const response = await client.post(
|
||||
`/certificates/${uuid}/export`,
|
||||
{ format, include_key: includeKey, password, pfx_password: pfxPassword },
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
return response.data as Blob
|
||||
}
|
||||
|
||||
export async function validateCertificate(
|
||||
certFile: File,
|
||||
keyFile?: File,
|
||||
chainFile?: File,
|
||||
): Promise<ValidationResult> {
|
||||
const formData = new FormData()
|
||||
formData.append('certificate_file', certFile)
|
||||
if (keyFile) formData.append('key_file', keyFile)
|
||||
if (chainFile) formData.append('chain_file', chainFile)
|
||||
|
||||
const response = await client.post<ValidationResult>('/certificates/validate', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface Location {
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
id: number;
|
||||
id?: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
@@ -40,7 +40,7 @@ export interface ProxyHost {
|
||||
advanced_config?: string;
|
||||
advanced_config_backup?: string;
|
||||
enabled: boolean;
|
||||
certificate_id?: number | null;
|
||||
certificate_id?: number | string | null;
|
||||
certificate?: Certificate | null;
|
||||
access_list_id?: number | string | null;
|
||||
access_list?: {
|
||||
|
||||
72
frontend/src/components/CertificateChainViewer.tsx
Normal file
72
frontend/src/components/CertificateChainViewer.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Link2, ShieldCheck } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { ChainEntry } from '../api/certificates'
|
||||
|
||||
interface CertificateChainViewerProps {
|
||||
chain: ChainEntry[]
|
||||
}
|
||||
|
||||
function getChainLabel(index: number, total: number, t: (key: string) => string): string {
|
||||
if (index === 0) return t('certificates.chainLeaf')
|
||||
if (index === total - 1 && total > 1) return t('certificates.chainRoot')
|
||||
return t('certificates.chainIntermediate')
|
||||
}
|
||||
|
||||
export default function CertificateChainViewer({ chain }: CertificateChainViewerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!chain || chain.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-content-muted italic">{t('certificates.noChainData')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-0"
|
||||
role="list"
|
||||
aria-label={t('certificates.certificateChain')}
|
||||
>
|
||||
{chain.map((entry, index) => {
|
||||
const label = getChainLabel(index, chain.length, t)
|
||||
const isLast = index === chain.length - 1
|
||||
|
||||
return (
|
||||
<div key={index} role="listitem">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-700 bg-surface-muted">
|
||||
{index === 0 ? (
|
||||
<ShieldCheck className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
) : (
|
||||
<Link2 className="h-4 w-4 text-content-muted" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-px h-6 bg-gray-700" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-content-muted">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-primary truncate" title={entry.subject}>
|
||||
{entry.subject}
|
||||
</p>
|
||||
<p className="text-xs text-content-muted truncate" title={entry.issuer}>
|
||||
{t('certificates.issuerOrg')}: {entry.issuer}
|
||||
</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
{t('certificates.expiresAt')}: {new Date(entry.expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Download, Eye, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BulkDeleteCertificateDialog from './dialogs/BulkDeleteCertificateDialog'
|
||||
import CertificateDetailDialog from './dialogs/CertificateDetailDialog'
|
||||
import CertificateExportDialog from './dialogs/CertificateExportDialog'
|
||||
import DeleteCertificateDialog from './dialogs/DeleteCertificateDialog'
|
||||
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
|
||||
import { Button } from './ui/Button'
|
||||
import { Checkbox } from './ui/Checkbox'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/Tooltip'
|
||||
import { deleteCertificate, type Certificate } from '../api/certificates'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { type Certificate } from '../api/certificates'
|
||||
import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../hooks/useCertificates'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
|
||||
type SortColumn = 'name' | 'expires'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export function isInUse(cert: Certificate, hosts: ProxyHost[]): boolean {
|
||||
if (!cert.id) return false
|
||||
return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id)
|
||||
export function isInUse(cert: Certificate): boolean {
|
||||
return cert.in_use
|
||||
}
|
||||
|
||||
export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
|
||||
if (!cert.id) return false
|
||||
if (isInUse(cert, hosts)) return false
|
||||
export function isDeletable(cert: Certificate): boolean {
|
||||
if (cert.in_use) return false
|
||||
return (
|
||||
cert.provider === 'custom' ||
|
||||
cert.provider === 'letsencrypt-staging' ||
|
||||
@@ -35,65 +31,48 @@ export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function daysUntilExpiry(expiresAt: string): number {
|
||||
const now = new Date()
|
||||
const expiry = new Date(expiresAt)
|
||||
return Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export default function CertificateList() {
|
||||
const { certificates, isLoading, error } = useCertificates()
|
||||
const { hosts } = useProxyHosts()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation()
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
const [certToDelete, setCertToDelete] = useState<Certificate | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [certToView, setCertToView] = useState<Certificate | null>(null)
|
||||
const [certToExport, setCertToExport] = useState<Certificate | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false)
|
||||
|
||||
const deleteMutation = useDeleteCertificate()
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(prev => {
|
||||
const validIds = new Set(certificates.map(c => c.id).filter((id): id is number => id != null))
|
||||
const validIds = new Set(certificates.map(c => c.uuid).filter(Boolean))
|
||||
const reconciled = new Set([...prev].filter(id => validIds.has(id)))
|
||||
if (reconciled.size === prev.size) return prev
|
||||
return reconciled
|
||||
})
|
||||
}, [certificates])
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await deleteCertificate(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
toast.success(t('certificates.deleteSuccess'))
|
||||
setCertToDelete(null)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
|
||||
setCertToDelete(null)
|
||||
},
|
||||
})
|
||||
const handleDelete = (cert: Certificate) => {
|
||||
deleteMutation.mutate(cert.uuid, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('certificates.deleteSuccess'))
|
||||
setCertToDelete(null)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
|
||||
setCertToDelete(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const results = await Promise.allSettled(ids.map(id => deleteCertificate(id)))
|
||||
const failed = results.filter(r => r.status === 'rejected').length
|
||||
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
||||
return { succeeded, failed }
|
||||
},
|
||||
onSuccess: ({ succeeded, failed }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
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)
|
||||
},
|
||||
})
|
||||
const bulkDeleteMutation = useBulkDeleteCertificates()
|
||||
|
||||
const sortedCertificates = useMemo(() => {
|
||||
return [...certificates].sort((a, b) => {
|
||||
@@ -101,8 +80,8 @@ export default function CertificateList() {
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'name': {
|
||||
const aName = (a.name || a.domain || '').toLowerCase()
|
||||
const bName = (b.name || b.domain || '').toLowerCase()
|
||||
const aName = (a.name || a.domains || '').toLowerCase()
|
||||
const bName = (b.name || b.domains || '').toLowerCase()
|
||||
comparison = aName.localeCompare(bName)
|
||||
break
|
||||
}
|
||||
@@ -118,15 +97,15 @@ export default function CertificateList() {
|
||||
})
|
||||
}, [certificates, sortColumn, sortDirection])
|
||||
|
||||
const selectableCertIds = useMemo<Set<number>>(() => {
|
||||
const ids = new Set<number>()
|
||||
const selectableCertIds = useMemo<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const cert of sortedCertificates) {
|
||||
if (isDeletable(cert, hosts) && cert.id) {
|
||||
ids.add(cert.id)
|
||||
if (isDeletable(cert) && cert.uuid) {
|
||||
ids.add(cert.uuid)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [sortedCertificates, hosts])
|
||||
}, [sortedCertificates])
|
||||
|
||||
const allSelectableSelected =
|
||||
selectableCertIds.size > 0 && selectedIds.size === selectableCertIds.size
|
||||
@@ -141,12 +120,12 @@ export default function CertificateList() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectRow = (id: number) => {
|
||||
const handleSelectRow = (uuid: string) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
if (next.has(uuid)) {
|
||||
next.delete(uuid)
|
||||
} else {
|
||||
next.add(id)
|
||||
next.add(uuid)
|
||||
}
|
||||
setSelectedIds(next)
|
||||
}
|
||||
@@ -243,18 +222,19 @@ export default function CertificateList() {
|
||||
</tr>
|
||||
) : (
|
||||
sortedCertificates.map((cert) => {
|
||||
const inUse = isInUse(cert, hosts)
|
||||
const deletable = isDeletable(cert, hosts)
|
||||
const inUse = isInUse(cert)
|
||||
const deletable = isDeletable(cert)
|
||||
const isInUseDeletableCategory = inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired' || cert.status === 'expiring')
|
||||
const days = daysUntilExpiry(cert.expires_at)
|
||||
|
||||
return (
|
||||
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
|
||||
<tr key={cert.uuid} className="hover:bg-gray-800/50 transition-colors">
|
||||
{deletable && !inUse ? (
|
||||
<td className="w-12 px-4 py-4">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(cert.id!)}
|
||||
onCheckedChange={() => handleSelectRow(cert.id!)}
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
|
||||
checked={selectedIds.has(cert.uuid)}
|
||||
onCheckedChange={() => handleSelectRow(cert.uuid)}
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
|
||||
/>
|
||||
</td>
|
||||
) : isInUseDeletableCategory ? (
|
||||
@@ -267,7 +247,7 @@ export default function CertificateList() {
|
||||
checked={false}
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -279,7 +259,7 @@ export default function CertificateList() {
|
||||
<td className="w-12 px-4 py-4" aria-hidden="true" />
|
||||
)}
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.domains}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{cert.issuer}</span>
|
||||
@@ -291,49 +271,80 @@ export default function CertificateList() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(cert.expires_at).toLocaleDateString()}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={days <= 0 ? 'text-red-400' : days <= 30 ? 'text-yellow-400' : ''}>
|
||||
{new Date(cert.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{days > 0
|
||||
? t('certificates.expiresInDays', { days })
|
||||
: t('certificates.expiredAgo', { days: Math.abs(days) })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={cert.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{(() => {
|
||||
if (cert.id && inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
className="text-red-400/40 cursor-not-allowed transition-colors"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('certificates.deleteInUse')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCertToView(cert)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={t('certificates.viewDetails')}
|
||||
data-testid={`view-cert-${cert.uuid}`}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCertToExport(cert)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={t('certificates.export')}
|
||||
data-testid={`export-cert-${cert.uuid}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{(() => {
|
||||
if (inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
className="text-red-400/40 cursor-not-allowed transition-colors"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('certificates.deleteInUse')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (deletable) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setCertToDelete(cert)}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (deletable) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setCertToDelete(cert)}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})()}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -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}
|
||||
/>
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={sortedCertificates.filter(c => 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}
|
||||
/>
|
||||
<CertificateDetailDialog
|
||||
certificate={certToView}
|
||||
open={certToView !== null}
|
||||
onOpenChange={(open) => { if (!open) setCertToView(null) }}
|
||||
/>
|
||||
<CertificateExportDialog
|
||||
certificate={certToExport}
|
||||
open={certToExport !== null}
|
||||
onOpenChange={(open) => { if (!open) setCertToExport(null) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ export default function CertificateStatusCard({ certificates, hosts, isLoading }
|
||||
const domains = new Set<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
107
frontend/src/components/CertificateValidationPreview.tsx
Normal file
107
frontend/src/components/CertificateValidationPreview.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="rounded-lg border border-gray-700 bg-surface-muted/50 p-4 space-y-3"
|
||||
data-testid="certificate-validation-preview"
|
||||
role="region"
|
||||
aria-label={t('certificates.validationPreview')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.valid ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400" aria-hidden="true" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
)}
|
||||
<span className="font-medium text-content-primary">
|
||||
{result.valid
|
||||
? t('certificates.validCertificate')
|
||||
: t('certificates.invalidCertificate')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<dt className="text-content-muted">{t('certificates.commonName')}</dt>
|
||||
<dd className="text-content-primary">{result.common_name || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.domains')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{result.domains?.length ? result.domains.join(', ') : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.issuerOrg')}</dt>
|
||||
<dd className="text-content-primary">{result.issuer_org || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.expiresAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{result.expires_at ? new Date(result.expires_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.keyMatch')}</dt>
|
||||
<dd>
|
||||
{result.key_match ? (
|
||||
<span className="text-green-400">Yes</span>
|
||||
) : (
|
||||
<span className="text-yellow-400">No key provided</span>
|
||||
)}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.chainValid')}</dt>
|
||||
<dd>
|
||||
{result.chain_valid ? (
|
||||
<span className="text-green-400">Yes</span>
|
||||
) : (
|
||||
<span className="text-yellow-400">Not verified</span>
|
||||
)}
|
||||
</dd>
|
||||
|
||||
{result.chain_depth > 0 && (
|
||||
<>
|
||||
<dt className="text-content-muted">{t('certificates.chainDepth')}</dt>
|
||||
<dd className="text-content-primary">{result.chain_depth}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{result.warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-900/50 bg-yellow-900/10 p-3">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-400 mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-yellow-400">{t('certificates.warnings')}</p>
|
||||
<ul className="list-disc list-inside text-sm text-yellow-300/80 space-y-0.5">
|
||||
{result.warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-900/50 bg-red-900/10 p-3">
|
||||
<XCircle className="h-4 w-4 text-red-400 mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-red-400">{t('certificates.errors')}</p>
|
||||
<ul className="list-disc list-inside text-sm text-red-300/80 space-y-0.5">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function buildInitialFormData(host?: ProxyHost): Partial<ProxyHost> & {
|
||||
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<Partial<ProxyHost>, 'access_list_id' | 'security_header_profile_id'> & {
|
||||
type ProxyHostFormState = Omit<Partial<ProxyHost>, '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
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
SSL Certificate
|
||||
</label>
|
||||
<Select value={String(formData.certificate_id || 0)} onValueChange={e => setFormData(prev => ({ ...prev, certificate_id: parseInt(e) || null }))}>
|
||||
<Select
|
||||
value={resolveSelectToken(formData.certificate_id as number | string | null | undefined)}
|
||||
onValueChange={token => setFormData(prev => ({ ...prev, certificate_id: resolveTokenToFormValue(token) }))}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="SSL Certificate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Auto-manage with Let's Encrypt (recommended)</SelectItem>
|
||||
{certificates.map(cert => (
|
||||
<SelectItem key={cert.id || cert.domain} value={String(cert.id ?? 0)}>
|
||||
{(cert.name || cert.domain)}
|
||||
{cert.provider ? ` (${cert.provider})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">Auto-manage with Let's Encrypt (recommended)</SelectItem>
|
||||
{certificates.map(cert => {
|
||||
const token = getEntityToken(cert)
|
||||
if (!token) return null
|
||||
return (
|
||||
<SelectItem key={cert.uuid} value={token}>
|
||||
{cert.name || cert.domains}
|
||||
{cert.provider ? ` (${cert.provider})` : ''}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
|
||||
@@ -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(<CertificateChainViewer chain={[]} />)
|
||||
expect(screen.getByText('certificates.noChainData')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders single entry as leaf', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(1)} />)
|
||||
expect(screen.getByText('certificates.chainLeaf')).toBeTruthy()
|
||||
expect(screen.getByText('Subject 0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders two entries as leaf + root', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(2)} />)
|
||||
expect(screen.getByText('certificates.chainLeaf')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.chainRoot')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders three entries as leaf + intermediate + root', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(3)} />)
|
||||
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(<CertificateChainViewer chain={makeChain(2)} />)
|
||||
expect(screen.getByText(/Issuer 0/)).toBeTruthy()
|
||||
expect(screen.getByText(/Issuer 1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays formatted expiration dates', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(1)} />)
|
||||
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(<CertificateChainViewer chain={makeChain(2)} />)
|
||||
expect(screen.getByRole('list')).toBeTruthy()
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has aria-label on list', () => {
|
||||
render(<CertificateChainViewer chain={makeChain(1)} />)
|
||||
expect(screen.getByRole('list').getAttribute('aria-label')).toBe(
|
||||
'certificates.certificateChain',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
const makeCert = (overrides: Partial<Certificate> = {}): 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<ReturnType<typeof useCertificates>> = {}) => {
|
||||
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<ReturnType<typeof useCertifi
|
||||
}
|
||||
}
|
||||
|
||||
const createProxyHost = (overrides: Partial<ProxyHost> = {}): 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<typeof useProxyHosts>> = {}): ReturnType<typeof useProxyHosts> => ({
|
||||
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<typeof vi.fn>
|
||||
let bulkDeleteMutateFn: ReturnType<typeof vi.fn>
|
||||
|
||||
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<typeof useDeleteCertificate>)
|
||||
vi.mocked(useBulkDeleteCertificates).mockReturnValue({
|
||||
mutate: bulkDeleteMutateFn,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useBulkDeleteCertificates>)
|
||||
})
|
||||
|
||||
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(<CertificateList />)
|
||||
@@ -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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
@@ -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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
@@ -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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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(<CertificateList />)
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
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(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
@@ -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(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
@@ -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(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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> = {}): 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(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('certificates.validCertificate')).toBeTruthy()
|
||||
expect(screen.getByTestId('certificate-validation-preview')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders invalid certificate state', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ valid: false })} />,
|
||||
)
|
||||
expect(screen.getByText('certificates.invalidCertificate')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays common name', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays domains joined by comma', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('example.com, www.example.com')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays dash when no domains provided', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ domains: [] })} />,
|
||||
)
|
||||
const dashes = screen.getAllByText('-')
|
||||
expect(dashes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('displays issuer organization', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
expect(screen.getByText('Test CA')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays formatted expiration date', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
const dateStr = new Date('2026-06-01T00:00:00Z').toLocaleDateString()
|
||||
expect(screen.getByText(dateStr)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Yes for key match', () => {
|
||||
render(<CertificateValidationPreview result={makeResult({ key_match: true, chain_valid: false })} />)
|
||||
expect(screen.getByText('Yes')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows No key provided when no key match', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ key_match: false })} />,
|
||||
)
|
||||
expect(screen.getByText('No key provided')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows chain depth when > 0', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ chain_depth: 3 })} />,
|
||||
)
|
||||
expect(screen.getByText('3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show chain depth when 0', () => {
|
||||
render(
|
||||
<CertificateValidationPreview result={makeResult({ chain_depth: 0 })} />,
|
||||
)
|
||||
expect(screen.queryByText('certificates.chainDepth')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('renders warnings when present', () => {
|
||||
render(
|
||||
<CertificateValidationPreview
|
||||
result={makeResult({ warnings: ['Expiring soon', 'Weak key'] })}
|
||||
/>,
|
||||
)
|
||||
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(<CertificateValidationPreview result={makeResult({ warnings: [] })} />)
|
||||
expect(screen.queryByText('certificates.warnings')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('renders errors when present', () => {
|
||||
render(
|
||||
<CertificateValidationPreview
|
||||
result={makeResult({ errors: ['Certificate revoked'] })}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('certificates.errors')).toBeTruthy()
|
||||
expect(screen.getByText('Certificate revoked')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render errors section when empty', () => {
|
||||
render(<CertificateValidationPreview result={makeResult({ errors: [] })} />)
|
||||
expect(screen.queryByText('certificates.errors')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('has correct region role and aria-label', () => {
|
||||
render(<CertificateValidationPreview result={makeResult()} />)
|
||||
const region = screen.getByRole('region')
|
||||
expect(region.getAttribute('aria-label')).toBe('certificates.validationPreview')
|
||||
})
|
||||
})
|
||||
@@ -64,10 +64,10 @@ export default function BulkDeleteCertificateDialog({
|
||||
>
|
||||
{certificates.map((cert) => (
|
||||
<li
|
||||
key={cert.id ?? cert.domain}
|
||||
key={cert.uuid}
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
>
|
||||
<span className="text-sm text-white">{cert.name || cert.domain}</span>
|
||||
<span className="text-sm text-white">{cert.name || cert.domains}</span>
|
||||
<span className="text-xs text-gray-500">{providerLabel(cert, t)}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{certificates.map((cert) => (
|
||||
<li key={cert.id} className="text-xs text-gray-300 flex items-center gap-2">
|
||||
<li key={cert.uuid} className="text-xs text-gray-300 flex items-center gap-2">
|
||||
<span className="text-orange-400">→</span>
|
||||
<span className="font-medium">{cert.name || cert.domain}</span>
|
||||
<span className="text-gray-500">({cert.domain})</span>
|
||||
|
||||
143
frontend/src/components/dialogs/CertificateDetailDialog.tsx
Normal file
143
frontend/src/components/dialogs/CertificateDetailDialog.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
data-testid="certificate-detail-dialog"
|
||||
className="max-w-lg max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.detailTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<div className="space-y-6 py-2">
|
||||
<section>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-content-muted">{t('certificates.friendlyName')}</dt>
|
||||
<dd className="text-content-primary">{detail.name || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.commonName')}</dt>
|
||||
<dd className="text-content-primary">{detail.common_name || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.domains')}</dt>
|
||||
<dd className="text-content-primary">{detail.domains || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.issuerOrg')}</dt>
|
||||
<dd className="text-content-primary">{detail.issuer_org || detail.issuer || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.fingerprint')}</dt>
|
||||
<dd className="text-content-primary font-mono text-xs break-all">
|
||||
{detail.fingerprint || '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.serialNumber')}</dt>
|
||||
<dd className="text-content-primary font-mono text-xs break-all">
|
||||
{detail.serial_number || '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.keyType')}</dt>
|
||||
<dd className="text-content-primary">{detail.key_type || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.status')}</dt>
|
||||
<dd className="text-content-primary capitalize">{detail.status}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.provider')}</dt>
|
||||
<dd className="text-content-primary">{detail.provider}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.notBefore')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.not_before ? new Date(detail.not_before).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.expiresAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.expires_at ? new Date(detail.expires_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.autoRenew')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.auto_renew ? t('common.yes') : t('common.no')}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.createdAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.created_at ? new Date(detail.created_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.updatedAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.updated_at ? new Date(detail.updated_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-content-primary mb-3">
|
||||
{t('certificates.assignedHosts')}
|
||||
</h3>
|
||||
{detail.assigned_hosts?.length > 0 ? (
|
||||
<ul className="space-y-1.5">
|
||||
{detail.assigned_hosts.map((host) => (
|
||||
<li
|
||||
key={host.uuid}
|
||||
className="flex items-center justify-between rounded-md border border-gray-700 bg-surface-muted/30 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-content-primary font-medium">{host.name}</span>
|
||||
<span className="text-content-muted text-xs">{host.domain_names}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-content-muted italic">
|
||||
{t('certificates.noAssignedHosts')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-content-primary mb-3">
|
||||
{t('certificates.certificateChain')}
|
||||
</h3>
|
||||
<CertificateChainViewer chain={detail.chain || []} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
187
frontend/src/components/dialogs/CertificateExportDialog.tsx
Normal file
187
frontend/src/components/dialogs/CertificateExportDialog.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent data-testid="certificate-export-dialog" className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Download className="inline h-5 w-5 mr-2" aria-hidden="true" />
|
||||
{t('certificates.exportTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="export-format">{t('certificates.exportFormat')}</Label>
|
||||
<div className="flex gap-2 mt-1.5" role="radiogroup" aria-label={t('certificates.exportFormat')}>
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === opt.value}
|
||||
onClick={() => setFormat(opt.value)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ${
|
||||
format === opt.value
|
||||
? 'border-brand-500 bg-brand-500/20 text-brand-400'
|
||||
: 'border-gray-700 text-content-muted hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{t(`certificates.${opt.label}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{certificate?.has_key && (
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="include-key"
|
||||
type="checkbox"
|
||||
checked={includeKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="include-key" className="cursor-pointer">
|
||||
{t('certificates.includePrivateKey')}
|
||||
</Label>
|
||||
{includeKey && (
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
{t('certificates.includePrivateKeyWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{includeKey && (
|
||||
<Input
|
||||
id="export-password"
|
||||
label={t('certificates.exportPassword')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
)}
|
||||
|
||||
{format === 'pfx' && (
|
||||
<Input
|
||||
id="pfx-password"
|
||||
label={t('certificates.exportPfxPassword')}
|
||||
type="password"
|
||||
value={pfxPassword}
|
||||
onChange={(e) => setPfxPassword(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => handleClose(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={exportMutation.isPending}
|
||||
data-testid="export-certificate-submit"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t('certificates.exportButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
213
frontend/src/components/dialogs/CertificateUploadDialog.tsx
Normal file
213
frontend/src/components/dialogs/CertificateUploadDialog.tsx
Normal file
@@ -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<File | null>(null)
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null)
|
||||
const [chainFile, setChainFile] = useState<File | null>(null)
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent data-testid="certificate-upload-dialog" className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<Input
|
||||
id="certificate-name"
|
||||
label={t('certificates.friendlyName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. My Custom Cert"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
|
||||
<FileDropZone
|
||||
id="cert-file"
|
||||
label={t('certificates.certificateFile')}
|
||||
accept=".pem,.crt,.cer,.pfx,.p12,.der"
|
||||
file={certFile}
|
||||
onFileChange={(f) => {
|
||||
setCertFile(f)
|
||||
setValidationResult(null)
|
||||
}}
|
||||
required
|
||||
formatBadge={certFormat}
|
||||
/>
|
||||
|
||||
{isPfx && (
|
||||
<p className="text-xs text-content-muted italic">
|
||||
{t('certificates.pfxDetected')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isPfx && (
|
||||
<>
|
||||
<FileDropZone
|
||||
id="key-file"
|
||||
required={!!certFile}
|
||||
label={t('certificates.privateKeyFile')}
|
||||
accept=".pem,.key"
|
||||
file={keyFile}
|
||||
onFileChange={(f) => {
|
||||
setKeyFile(f)
|
||||
setValidationResult(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FileDropZone
|
||||
id="chain-file"
|
||||
label={t('certificates.chainFile')}
|
||||
accept=".pem,.crt,.cer"
|
||||
file={chainFile}
|
||||
onFileChange={(f) => {
|
||||
setChainFile(f)
|
||||
setValidationResult(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsKeyFile && (
|
||||
<p role="alert" className="text-xs text-red-500">
|
||||
{t('certificates.keyFileRequired')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{certFile && !validationResult && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleValidate}
|
||||
disabled={!canValidate}
|
||||
isLoading={validateMutation.isPending}
|
||||
data-testid="validate-certificate-btn"
|
||||
>
|
||||
{validateMutation.isPending
|
||||
? t('certificates.validating')
|
||||
: t('certificates.validate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{validationResult && (
|
||||
<CertificateValidationPreview result={validationResult} />
|
||||
)}
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => handleClose(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
isLoading={uploadMutation.isPending}
|
||||
data-testid="upload-certificate-submit"
|
||||
>
|
||||
{t('certificates.uploadAndSave')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function DeleteCertificateDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{certificate.name || certificate.domain}
|
||||
{certificate.name || certificate.domains}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function DeleteCertificateDialog({
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-gray-500">{t('certificates.domain')}</dt>
|
||||
<dd className="text-white">{certificate.domain}</dd>
|
||||
<dd className="text-white">{certificate.domains}</dd>
|
||||
<dt className="text-gray-500">{t('certificates.status')}</dt>
|
||||
<dd className="text-white capitalize">{certificate.status}</dd>
|
||||
<dt className="text-gray-500">{t('certificates.provider')}</dt>
|
||||
|
||||
@@ -7,20 +7,22 @@ import BulkDeleteCertificateDialog from '../../dialogs/BulkDeleteCertificateDial
|
||||
import type { Certificate } from '../../../api/certificates'
|
||||
|
||||
const makeCert = (overrides: Partial<Certificate>): 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(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={[expiringCert]}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import type { Certificate, CertificateDetail } from '../../../api/certificates'
|
||||
import { useCertificateDetail } from '../../../hooks/useCertificates'
|
||||
import { createTestQueryClient } from '../../../test/createTestQueryClient'
|
||||
import CertificateDetailDialog from '../CertificateDetailDialog'
|
||||
|
||||
const mockDetail: CertificateDetail = {
|
||||
uuid: 'cert-1',
|
||||
name: 'My Cert',
|
||||
common_name: 'app.example.com',
|
||||
domains: 'app.example.com, api.example.com',
|
||||
issuer: 'Test CA',
|
||||
issuer_org: 'Test Org',
|
||||
fingerprint: 'AA:BB:CC:DD',
|
||||
serial_number: '1234567890',
|
||||
key_type: 'RSA 2048',
|
||||
expires_at: '2026-06-01T00:00:00Z',
|
||||
not_before: '2024-03-15T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
has_key: true,
|
||||
in_use: true,
|
||||
auto_renew: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-08-20T00:00:00Z',
|
||||
assigned_hosts: [
|
||||
{ uuid: 'host-1', name: 'Web Server', domain_names: 'web.example.com' },
|
||||
],
|
||||
chain: [
|
||||
{ subject: 'app.example.com', issuer: 'Test CA', expires_at: '2026-06-01T00:00:00Z' },
|
||||
{ subject: 'Test CA', issuer: 'Root CA', expires_at: '2030-01-01T00:00:00Z' },
|
||||
],
|
||||
}
|
||||
|
||||
vi.mock('../../../hooks/useCertificates', () => ({
|
||||
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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CertificateDetailDialog
|
||||
certificate={certificate}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CertificateExportDialog
|
||||
certificate={certificate}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CertificateUploadDialog open={open} onOpenChange={onOpenChange} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
136
frontend/src/components/ui/FileDropZone.tsx
Normal file
136
frontend/src/components/ui/FileDropZone.tsx
Normal file
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-content-secondary mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-error ml-0.5" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={file ? `${label}: ${file.name}` : label}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-elevated',
|
||||
isDragOver && !disabled
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600 bg-surface-muted/30',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
aria-required={required}
|
||||
required={required}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{file ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Upload className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
<span className="text-content-primary font-medium truncate max-w-[200px]">
|
||||
{file.name}
|
||||
</span>
|
||||
{formatBadge && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-500/20 text-brand-400 border border-brand-500/30">
|
||||
{formatBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-sm text-content-muted">
|
||||
<Upload className="h-5 w-5 mb-1" aria-hidden="true" />
|
||||
<span>{t('certificates.dropFileHere')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
frontend/src/components/ui/__tests__/FileDropZone.test.tsx
Normal file
157
frontend/src/components/ui/__tests__/FileDropZone.test.tsx
Normal file
@@ -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(<FileDropZone {...defaultProps} />)
|
||||
expect(screen.getByText('Certificate File')).toBeTruthy()
|
||||
expect(screen.getByText('certificates.dropFileHere')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows required asterisk when required', () => {
|
||||
render(<FileDropZone {...defaultProps} required />)
|
||||
expect(screen.getByText('*')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays file name when a file is provided', () => {
|
||||
const file = createFile('my-cert.pem')
|
||||
render(<FileDropZone {...defaultProps} file={file} />)
|
||||
expect(screen.getByText('my-cert.pem')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays format badge when file is provided', () => {
|
||||
const file = createFile('my-cert.pem')
|
||||
render(<FileDropZone {...defaultProps} file={file} formatBadge="PEM" />)
|
||||
expect(screen.getByText('PEM')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('triggers file input on click', async () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
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(<FileDropZone {...defaultProps} />)
|
||||
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(<FileDropZone {...defaultProps} />)
|
||||
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(<FileDropZone {...defaultProps} disabled />)
|
||||
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(<FileDropZone {...defaultProps} />)
|
||||
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(<FileDropZone {...defaultProps} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
fireEvent.keyDown(dropZone, { key: ' ' })
|
||||
})
|
||||
|
||||
it('does not activate via keyboard when disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} disabled />)
|
||||
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(<FileDropZone {...defaultProps} disabled />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
expect(dropZone.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('has tabIndex=-1 when disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} disabled />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
expect(dropZone.tabIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('has tabIndex=0 when not disabled', () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
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(<FileDropZone {...defaultProps} file={file} />)
|
||||
const dropZone = screen.getByRole('button')
|
||||
expect(dropZone.getAttribute('aria-label')).toBe('Certificate File: cert.pem')
|
||||
})
|
||||
|
||||
it('handles dragLeave event', () => {
|
||||
render(<FileDropZone {...defaultProps} />)
|
||||
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(<FileDropZone {...defaultProps} accept=".pem,.crt" />)
|
||||
const input = document.getElementById('cert-file') as HTMLInputElement
|
||||
expect(input.getAttribute('accept')).toBe('.pem,.crt')
|
||||
})
|
||||
|
||||
it('sets aria-required on input when required', () => {
|
||||
render(<FileDropZone {...defaultProps} required />)
|
||||
const input = document.getElementById('cert-file') as HTMLInputElement
|
||||
expect(input.getAttribute('aria-required')).toBe('true')
|
||||
})
|
||||
})
|
||||
238
frontend/src/hooks/__tests__/useCertificates.test.tsx
Normal file
238
frontend/src/hooks/__tests__/useCertificates.test.tsx
Normal file
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<File | null>(null)
|
||||
const [keyFile, setKeyFile] = useState<File | null>(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 = (
|
||||
<Button onClick={() => setIsModalOpen(true)} data-testid="add-certificate-btn">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Button onClick={() => setIsUploadOpen(true)} data-testid="add-certificate-btn">
|
||||
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
{t('certificates.addCertificate')}
|
||||
</Button>
|
||||
)
|
||||
@@ -70,56 +30,7 @@ export default function Certificates() {
|
||||
|
||||
<CertificateList />
|
||||
|
||||
{/* Upload Certificate Dialog */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent data-testid="certificate-upload-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||
<Input
|
||||
id="certificate-name"
|
||||
label={t('certificates.friendlyName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. My Custom Cert"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="cert-file">{t('certificates.certificatePem')}</Label>
|
||||
<input
|
||||
id="cert-file"
|
||||
data-testid="certificate-file-input"
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer"
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="key-file">{t('certificates.privateKeyPem')}</Label>
|
||||
<input
|
||||
id="key-file"
|
||||
data-testid="certificate-key-input"
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={uploadMutation.isPending}>
|
||||
{t('common.upload')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CertificateUploadDialog open={isUploadOpen} onOpenChange={setIsUploadOpen} />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export default function Dashboard() {
|
||||
const certifiedDomains = new Set<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Set<number>>(new Set())
|
||||
@@ -103,7 +103,7 @@ export default function ProxyHosts() {
|
||||
const certStatusByDomain = useMemo(() => {
|
||||
const map: Record<string, { status: string; provider: string }> = {}
|
||||
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<number, { id: number; name: string; domain: string }> = new Map()
|
||||
const certsToConsider: Map<string, { uuid: string; name: string; domain: string }> = 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
|
||||
})
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<string, unknown>) => {
|
||||
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: () => <div>CertificateList</div>,
|
||||
default: () => <div data-testid="certificate-list">CertificateList</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
uploadCertificate: vi.fn(),
|
||||
}))
|
||||
|
||||
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 ? (
|
||||
<div role="dialog" data-testid="upload-dialog">
|
||||
<button onClick={() => onOpenChange(false)}>Close</button>
|
||||
</div>
|
||||
) : 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)
|
||||
|
||||
const user = userEvent.setup()
|
||||
const { queryClient } = renderWithQueryClient(<Certificates />)
|
||||
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)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile)
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] })
|
||||
expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument()
|
||||
})
|
||||
it('renders the page with certificate list and add button', () => {
|
||||
renderWithQueryClient(<Certificates />)
|
||||
expect(screen.getByText('certificates.addCertificate')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('certificate-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces upload errors', async () => {
|
||||
vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
it('opens upload dialog when add button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Certificates />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
|
||||
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
|
||||
|
||||
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')
|
||||
})
|
||||
it('closes upload dialog via onOpenChange callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Certificates />)
|
||||
|
||||
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
|
||||
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
|
||||
await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' }))
|
||||
expect(screen.getByTestId('upload-dialog')).toBeInTheDocument()
|
||||
|
||||
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
|
||||
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
|
||||
await user.click(screen.getByText('Close'))
|
||||
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
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 waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`)
|
||||
})
|
||||
it('renders info alert with note text', () => {
|
||||
renderWithQueryClient(<Certificates />)
|
||||
expect(screen.getByText('certificates.noteText')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<Dashboard />)
|
||||
|
||||
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
|
||||
// "1 valid" still renders even though cert.domains is undefined
|
||||
expect(screen.getByText('1 valid')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user