Compare commits

...

81 Commits

Author SHA1 Message Date
Jeremy
26a75f5fe3 Merge branch 'development' into main 2026-04-20 08:26:40 -04:00
Jeremy
877fee487b Merge pull request #958 from Wikid82/bot/update-geolite2-checksum
chore(docker): update GeoLite2-Country.mmdb checksum
2026-04-20 07:57:00 -04:00
Jeremy
9c416599f8 Merge pull request #955 from Wikid82/renovate/development-non-major-updates
chore(deps): update node.js to 8510330 (development)
2026-04-16 09:33:22 -04:00
renovate[bot]
34903cdd49 chore(deps): update node.js to 8510330 2026-04-16 13:26:43 +00:00
Jeremy
a059edf60d Merge pull request #950 from Wikid82/main
chore(config): migrate config .github/renovate.json
2026-04-15 13:22:15 -04:00
Jeremy
c63e4a3d6b Merge pull request #928 from Wikid82/feature/beta-release
feat: Custom Certificate Upload & Management
2026-04-15 12:54:04 -04:00
GitHub Actions
0e8ff1bc2a fix(deps): update @napi-rs/wasm-runtime and postcss to latest versions 2026-04-15 16:09:12 +00:00
Jeremy
683967bbfc Merge pull request #948 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-15 12:05:15 -04:00
renovate[bot]
15947616a9 fix(deps): update non-major-updates 2026-04-15 16:02:03 +00:00
GitHub Actions
813985a903 fix(dependencies): update mongo-driver to v2.5.1 2026-04-15 11:38:35 +00:00
GitHub Actions
bd48c17aab chore: update dependencies for prettier and std-env in package-lock.json 2026-04-15 11:37:28 +00:00
GitHub Actions
8239a94938 chore: Add tests for CertificateList and CertificateUploadDialog components
- Implement test to deselect a row checkbox in CertificateList by clicking it a second time.
- Add test to close detail dialog via the close button in CertificateList.
- Add test to close export dialog via the cancel button in CertificateList.
- Add test to show KEY format badge when a .key file is uploaded in CertificateUploadDialog.
- Add test to ensure no format badge is shown for unknown file extensions in CertificateUploadDialog.
2026-04-15 11:35:10 +00:00
GitHub Actions
fb8d80f6a3 fix: correct CertificateUploadDialog tests to provide required key file 2026-04-14 20:40:26 +00:00
GitHub Actions
8090c12556 feat(proxy-host): enhance certificate handling and update form integration 2026-04-14 20:35:11 +00:00
GitHub Actions
0e0d42c9fd fix(certificates): mark key file as aria-required for PEM/DER cert uploads 2026-04-14 19:10:57 +00:00
GitHub Actions
14b48f23b6 fix: add key file requirement message for PEM/DER certificates in CertificateUploadDialog 2026-04-14 16:35:37 +00:00
GitHub Actions
0c0adf0e5a fix: refactor context handling in Register tests for improved cleanup 2026-04-14 16:33:54 +00:00
GitHub Actions
135edd208c fix: update caniuse-lite to version 1.0.30001788 for improved compatibility 2026-04-14 12:58:15 +00:00
GitHub Actions
81a083a634 fix: resolve CI test failures and close patch coverage gaps 2026-04-14 12:42:22 +00:00
GitHub Actions
149a2071c3 fix: update electron-to-chromium to version 1.5.336 for improved compatibility 2026-04-14 02:35:05 +00:00
GitHub Actions
027a1b1f18 fix: replace fireEvent with userEvent for file uploads in CertificateUploadDialog tests 2026-04-14 02:33:25 +00:00
GitHub Actions
7adf39a6a0 fix: update axe-core to version 4.11.3 for improved functionality and security 2026-04-14 02:33:25 +00:00
Jeremy
5408ebc95b Merge pull request #947 from Wikid82/renovate/feature/beta-release-actions-upload-pages-artifact-5.x
chore(deps): update actions/upload-pages-artifact action to v5 (feature/beta-release)
2026-04-13 22:32:42 -04:00
Jeremy
92a90bb8a1 Merge pull request #946 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-13 22:32:26 -04:00
renovate[bot]
6391532b2d fix(deps): update non-major-updates 2026-04-14 01:08:04 +00:00
renovate[bot]
a161163508 chore(deps): update actions/upload-pages-artifact action to v5 2026-04-13 20:32:41 +00:00
GitHub Actions
5b6bf945d9 fix: add key_file validation for PEM/DER uploads and resolve CI test failures 2026-04-13 19:56:35 +00:00
GitHub Actions
877a32f180 fix: enhance form validation for certificate upload by adding required attributes and adjusting test logic 2026-04-13 17:31:05 +00:00
GitHub Actions
1fe8a79ea3 fix: update @typescript-eslint packages to version 8.58.2 and undici to version 7.25.0 2026-04-13 17:29:26 +00:00
GitHub Actions
7c8e8c001c fix: enhance error handling in ConvertPEMToPFX for empty certificate cases 2026-04-13 14:12:47 +00:00
GitHub Actions
29c56ab283 fix: add context parameter to route registration functions for improved lifecycle management 2026-04-13 14:12:47 +00:00
GitHub Actions
0391f2b3e3 fix: add PFX password parameter to ExportCertificate method and update tests 2026-04-13 14:12:47 +00:00
GitHub Actions
942f585dd1 fix: improve error response format in certificate validation 2026-04-13 14:12:47 +00:00
GitHub Actions
3005db6943 fix: remove unnecessary string checks for key file in Upload method 2026-04-13 14:12:47 +00:00
GitHub Actions
f3c33dc81b fix: update golang.org/x/term to v0.42.0 for compatibility improvements 2026-04-13 14:12:47 +00:00
Jeremy
44e2bdec95 Merge branch 'development' into feature/beta-release 2026-04-13 09:25:51 -04:00
Jeremy
d71fc0b95f Merge pull request #945 from Wikid82/renovate/development-pin-dependencies
chore(deps): pin dependencies (development)
2026-04-13 09:18:48 -04:00
renovate[bot]
f295788ac1 chore(deps): pin dependencies 2026-04-13 13:17:54 +00:00
GitHub Actions
c19aa55fd7 chore: update package-lock.json to upgrade dependencies for improved stability 2026-04-13 13:10:40 +00:00
GitHub Actions
ea3d93253f fix: update CADDY_SECURITY_VERSION to 1.1.62 for improved security 2026-04-13 13:10:40 +00:00
Jeremy
114dca89c6 Merge pull request #944 from Wikid82/renovate/feature/beta-release-major-7-github-artifact-actions
chore(deps): update actions/upload-artifact action to v7 (feature/beta-release)
2026-04-13 09:05:00 -04:00
Jeremy
c7932fa1d9 Merge pull request #942 from Wikid82/renovate/feature/beta-release-actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6 (feature/beta-release)
2026-04-13 09:03:23 -04:00
renovate[bot]
f0ffc27ca7 chore(deps): update actions/upload-artifact action to v7 2026-04-13 13:02:54 +00:00
Jeremy
4dfcf70c08 Merge pull request #941 from Wikid82/renovate/feature/beta-release-actions-github-script-9.x
chore(deps): update actions/github-script action to v9 (feature/beta-release)
2026-04-13 09:02:37 -04:00
Jeremy
71b34061d9 Merge pull request #940 from Wikid82/renovate/feature/beta-release-actions-checkout-6.x
chore(deps): update actions/checkout action to v6 (feature/beta-release)
2026-04-13 09:02:14 -04:00
renovate[bot]
368130b07a chore(deps): update actions/setup-go action to v6 2026-04-13 13:01:36 +00:00
renovate[bot]
85216ba6e0 chore(deps): update actions/github-script action to v9 2026-04-13 13:01:30 +00:00
renovate[bot]
06aacdee98 chore(deps): update actions/checkout action to v6 2026-04-13 13:01:24 +00:00
Jeremy
ef44ae40ec Merge branch 'development' into feature/beta-release 2026-04-13 08:49:52 -04:00
Jeremy
26ea2e9da1 Merge pull request #937 from Wikid82/main
Propagate changes from main into development
2026-04-13 08:49:17 -04:00
Jeremy
b90da3740c Merge pull request #936 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update renovatebot/github-action action to v46.1.9 (feature/beta-release)
2026-04-13 08:48:48 -04:00
GitHub Actions
0ae1dc998a test: update certificate deletion tests to use string UUIDs instead of integers 2026-04-13 12:04:47 +00:00
Jeremy
44f475778f Merge branch 'feature/beta-release' into renovate/feature/beta-release-non-major-updates 2026-04-13 00:42:41 -04:00
GitHub Actions
48f6b7a12b fix: update Dockerfile to include musl and musl-utils in apk upgrade for improved compatibility 2026-04-13 04:40:02 +00:00
renovate[bot]
122e1fc20b chore(deps): update renovatebot/github-action action to v46.1.9 2026-04-13 04:38:53 +00:00
GitHub Actions
850550c5da test: update common name display test to match exact text 2026-04-13 04:38:26 +00:00
GitHub Actions
3b4fa064d6 test: add end-to-end tests for certificate export dialog functionality 2026-04-13 04:32:26 +00:00
GitHub Actions
78a9231c8a chore: add test_output.txt to .gitignore to exclude test output files from version control 2026-04-13 04:24:16 +00:00
GitHub Actions
e88a4c7982 chore: update package-lock.json to remove unused dependencies and improve overall package management 2026-04-13 04:10:16 +00:00
GitHub Actions
9c056faec7 fix: downgrade versions of css-color, brace-expansion, baseline-browser-mapping, and electron-to-chromium for compatibility 2026-04-13 04:07:49 +00:00
GitHub Actions
e865fa2b8b chore: update package.json and package-lock.json to include vitest and coverage dependencies 2026-04-13 04:03:30 +00:00
GitHub Actions
e1bc648dfc test: add certificate feature unit tests and null-safety fix
Add comprehensive unit tests for the certificate upload, export,
and detail management feature:

- CertificateExportDialog: 21 tests covering format selection,
  blob download, error handling, and password-protected exports
- CertificateUploadDialog: 23 tests covering file validation,
  format detection, drag-and-drop, and upload flow
- CertificateDetailDialog: 19 tests covering detail display,
  loading state, missing fields, and branch coverage
- CertificateChainViewer: 8 tests covering chain visualization
- CertificateValidationPreview: 16 tests covering validation display
- FileDropZone: 18 tests covering drag-and-drop interactions
- useCertificates hooks: 10 tests covering all React Query hooks
- certificates API: 7 new tests for previously uncovered endpoints

Fix null-safety issue in ProxyHosts where cert.domains could be
undefined, causing a runtime error on split().

Frontend patch coverage: 90.6%, overall lines: 89.09%
2026-04-13 04:02:31 +00:00
GitHub Actions
9d8d97e556 fix: update @csstools/css-calc, @csstools/css-color-parser, @tanstack/query-core, globals, builtin-modules, knip, and undici to latest versions for improved functionality and security 2026-04-13 04:02:31 +00:00
GitHub Actions
9dc55675ca fix: update Coraza Caddy version to 2.5.0 for compatibility 2026-04-13 04:01:31 +00:00
GitHub Actions
30c9d735aa feat: add certificate export and upload dialogs
- Implemented CertificateExportDialog for exporting certificates in various formats (PEM, PFX, DER) with options to include private keys and set passwords.
- Created CertificateUploadDialog for uploading certificates, including validation and support for multiple file types (certificates, private keys, chain files).
- Updated DeleteCertificateDialog to use 'domains' instead of 'domain' for consistency.
- Refactored BulkDeleteCertificateDialog and DeleteCertificateDialog tests to accommodate changes in certificate structure.
- Added FileDropZone component for improved file upload experience.
- Enhanced translation files with new keys for certificate management features.
- Updated Certificates page to utilize the new CertificateUploadDialog and clean up the upload logic.
- Adjusted Dashboard and ProxyHosts pages to reflect changes in certificate data structure.
2026-04-13 04:01:31 +00:00
GitHub Actions
e49ea7061a fix: add go-pkcs12 v0.7.1 for PKCS#12 support 2026-04-13 04:01:31 +00:00
GitHub Actions
5c50d8b314 fix: update brace-expansion version to 1.1.14 for improved compatibility 2026-04-13 04:01:30 +00:00
Jeremy
af95c1bdb3 Merge pull request #934 from Wikid82/renovate/feature/beta-release-softprops-action-gh-release-3.x
chore(deps): update softprops/action-gh-release action to v3 (feature/beta-release)
2026-04-12 21:14:11 -04:00
renovate[bot]
01e3d910f1 chore(deps): update softprops/action-gh-release action to v3 2026-04-13 01:12:42 +00:00
Jeremy
1230694f55 Merge pull request #933 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-12 21:06:36 -04:00
renovate[bot]
77f15a225f fix(deps): update non-major-updates 2026-04-12 16:50:55 +00:00
Jeremy
d75abb80d1 Merge pull request #932 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-11 16:19:08 -04:00
GitHub Actions
42bc897610 fix: enhance certificate deletion handling with UUID validation and logging improvements 2026-04-11 17:54:42 +00:00
renovate[bot]
b15f7c3fbc fix(deps): update non-major-updates 2026-04-11 17:47:55 +00:00
GitHub Actions
bb99dacecd fix: update zlib and add libcrypto3 and libssl3 for improved security 2026-04-11 17:33:44 +00:00
GitHub Actions
4b925418f2 feat: Add certificate validation service with parsing and metadata extraction
- Implemented certificate parsing for PEM, DER, and PFX formats.
- Added functions to validate key matches and certificate chains.
- Introduced metadata extraction for certificates including common name, domains, and issuer organization.
- Created unit tests for all new functionalities to ensure reliability and correctness.
2026-04-11 07:17:45 +00:00
GitHub Actions
9e82efd23a fix: downgrade delve version from 1.26.2 to 1.26.1 for compatibility 2026-04-11 00:11:25 +00:00
GitHub Actions
8f7c10440c chore: align agent and instruction files with single-PR commit-slicing model
- Rewrote commit slicing guidance in Management, Planning, and subagent
  instruction files to enforce one-feature-one-PR with ordered logical commits
- Removed multi-PR branching logic from the execution workflow
- Prevents partial feature merges that cause user confusion on self-hosted tools
- All cross-references now use "Commit N" instead of "PR-N"
2026-04-10 23:41:05 +00:00
GitHub Actions
a439e1d467 fix: add git to Dockerfile dependencies for improved build capabilities 2026-04-10 21:03:54 +00:00
Jeremy
718a957ad9 Merge branch 'development' into feature/beta-release 2026-04-10 16:53:27 -04:00
GitHub Actions
059ff9c6b4 fix: update Go version from 1.26.1 to 1.26.2 in Dockerfile and documentation for security improvements 2026-04-10 20:48:46 +00:00
107 changed files with 14120 additions and 1547 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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') }}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]"
}

View File

@@ -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"))
}

View File

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

View File

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

View File

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

View File

@@ -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")
}

View File

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

View File

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

View File

@@ -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()...)

View File

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

View File

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

View File

@@ -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")
}

View File

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

View File

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

View File

@@ -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},
})
}

View 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`
}

View File

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

View File

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

View 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
}

View File

@@ -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: &notAfter,
NotBefore: &notBefore,
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
}

View File

@@ -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(&notifications).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(&notifications).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")
}

View 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: &notBefore,
}
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(&notifications)
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)
})
}

View File

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

View File

@@ -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: &notBefore,
})
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: &notBefore,
})
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")
}

View File

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

View File

@@ -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")
})
}

View 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"
}
}

View 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")
}

View File

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

View File

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

View 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)
})
}

View File

@@ -12,6 +12,7 @@ func TestNewRFC2136Provider(t *testing.T) {
if provider == nil {
t.Fatal("NewRFC2136Provider() returned nil")
return
}
if provider.propagationTimeout != RFC2136DefaultPropagationTimeout {

View File

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

File diff suppressed because it is too large Load Diff

View 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
```

View File

@@ -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 ~260320 (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 ~9661020): 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.13.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.73.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.

View 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 169175 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.

View File

@@ -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": {

View File

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

View File

@@ -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();
});
});

View File

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

View File

@@ -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?: {

View 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>
)
}

View File

@@ -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) }}
/>
</>
)
}

View File

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

View 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>
)
}

View File

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

View File

@@ -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',
)
})
})

View File

@@ -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())
})
})

View File

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

View File

@@ -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')
})
})

View File

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

View File

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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

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

View File

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

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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', () => {

View 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>
)
}

View 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')
})
})

View 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 });
});
});
});

View File

@@ -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'] })
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})

View File

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

View File

@@ -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({})

View File

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