mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:31:34 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8fc177118 | |||
| 20378ea7bb |
@@ -250,6 +250,8 @@ paths:
|
||||
$ref: "#/components/schemas/ManagedCertificate"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
delete:
|
||||
@@ -261,6 +263,8 @@ paths:
|
||||
responses:
|
||||
"204":
|
||||
description: Certificate archived
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -306,6 +310,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -820,6 +830,8 @@ paths:
|
||||
$ref: "#/components/schemas/Agent"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -877,6 +889,8 @@ paths:
|
||||
$ref: "#/components/schemas/StatusResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -2469,6 +2483,12 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
Conflict:
|
||||
description: Resource conflict
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
InternalError:
|
||||
description: Internal server error
|
||||
content:
|
||||
@@ -2571,6 +2591,13 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- name
|
||||
- common_name
|
||||
- renewal_policy_id
|
||||
- issuer_id
|
||||
- owner_id
|
||||
- team_id
|
||||
|
||||
CertificateVersion:
|
||||
type: object
|
||||
|
||||
+510
-37
@@ -5071,44 +5071,517 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
|
||||
|
||||
## Release Sign-Off
|
||||
|
||||
All 34 parts must pass before tagging v2.1.0.
|
||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||
|
||||
| Section | Pass? | Tester | Date | Notes |
|
||||
|---------|-------|--------|------|-------|
|
||||
| Part 1: Infrastructure & Deployment | ☐ | | | |
|
||||
| Part 2: Authentication & Security | ☐ | | | |
|
||||
| Part 3: Certificate Lifecycle (CRUD) | ☐ | | | |
|
||||
| Part 4: Renewal Workflow | ☐ | | | |
|
||||
| Part 5: Revocation | ☐ | | | |
|
||||
| Part 6: Issuer Connectors | ☐ | | | |
|
||||
| Part 7: Target Connectors & Deployment | ☐ | | | |
|
||||
| Part 8: Agent Operations | ☐ | | | |
|
||||
| Part 9: Job System | ☐ | | | |
|
||||
| Part 10: Policies & Profiles | ☐ | | | |
|
||||
| Part 11: Ownership, Teams & Agent Groups | ☐ | | | |
|
||||
| Part 12: Notifications | ☐ | | | |
|
||||
| Part 13: Observability (JSON + Prometheus) | ☐ | | | |
|
||||
| Part 14: Audit Trail | ☐ | | | |
|
||||
| Part 15: Certificate Discovery (Filesystem + Network) | ☐ | | | |
|
||||
| Part 16: Enhanced Query API | ☐ | | | |
|
||||
| Part 17: CLI Tool | ☐ | | | |
|
||||
| Part 18: MCP Server | ☐ | | | |
|
||||
| Part 19: GUI Testing | ☐ | | | |
|
||||
| Part 20: Background Scheduler | ☐ | | | |
|
||||
| Part 21: Error Handling | ☐ | | | |
|
||||
| Part 22: Performance Spot Checks | ☐ | | | |
|
||||
| Part 23: Structured Logging | ☐ | | | |
|
||||
| Part 24: Documentation Verification | ☐ | | | |
|
||||
| Part 25: Regression Tests | ☐ | | | |
|
||||
| Part 26: EST Server (RFC 7030) | ☐ | | | |
|
||||
| Part 27: Post-Deployment TLS Verification | ☐ | | | |
|
||||
| Part 28: Traefik & Caddy Target Connectors | ☐ | | | |
|
||||
| Part 29: Certificate Export (PEM & PKCS#12) | ☐ | | | |
|
||||
| Part 30: S/MIME & EKU Support | ☐ | | | |
|
||||
| Part 31: OCSP Responder & DER CRL | ☐ | | | |
|
||||
| Part 32: Request Body Size Limits | ☐ | | | |
|
||||
| Part 33: Apache & HAProxy Target Connectors | ☐ | | | |
|
||||
| Part 34: Sub-CA Mode | ☐ | | | |
|
||||
### Automated Prerequisites
|
||||
|
||||
These must be green before starting manual QA:
|
||||
|
||||
| Gate | Pass? | Date | Notes |
|
||||
|------|-------|------|-------|
|
||||
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
|
||||
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
|
||||
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
|
||||
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 121 pass, 0 fail, 5 skip |
|
||||
|
||||
### Part 1: Infrastructure & Deployment
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 1.1.1 | PostgreSQL is accepting connections | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.2 | Database schema applied (21 tables) | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.3 | Server liveness probe | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.4 | Server readiness probe | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.5 | Agent container is running | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.1.6 | Demo seed data loaded (all 9 resource types) | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.2.1 | Server shuts down cleanly on SIGTERM | Manual | ☐ | | |
|
||||
| 1.2.2 | Data persists across full restart | Manual | ☐ | | |
|
||||
| 1.3.1 | Custom port binding | Manual | ☐ | | |
|
||||
| 1.3.2 | Debug logging | Manual | ☐ | | |
|
||||
| 1.3.3 | Auth disabled with explicit none | Auto | ☑ | 2026-03-30 | |
|
||||
| 1.3.4 | Auth none produces warning log | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 2: Authentication & Security
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 2.1.1 | Request without auth header returns 401 | Manual | ☐ | | |
|
||||
| 2.1.2 | Request with wrong API key returns 401 | Manual | ☐ | | |
|
||||
| 2.1.3 | Request with valid API key returns 200 | Manual | ☐ | | |
|
||||
| 2.1.4 | /health accessible without auth (always) | Manual | ☐ | | |
|
||||
| 2.1.5 | /ready accessible without auth (always) | Manual | ☐ | | |
|
||||
| 2.1.6 | /api/v1/auth/info accessible without auth (GUI bootstrap) | Manual | ☐ | | |
|
||||
| 2.1.7 | /api/v1/auth/check with valid key returns 200 | Manual | ☐ | | |
|
||||
| 2.1.8 | /api/v1/auth/check without key returns 401 | Manual | ☐ | | |
|
||||
| 2.2.1 | Burst exceeds limit, returns 429 with Retry-After | Manual | ☐ | | |
|
||||
| 2.2.2 | 429 response includes Retry-After header | Manual | ☐ | | |
|
||||
| 2.2.3 | Rate limit bucket refills after waiting | Manual | ☐ | | |
|
||||
| 2.3.1 | Preflight OPTIONS with allowed origin returns CORS headers | Manual | ☐ | | |
|
||||
| 2.3.2 | Request from disallowed origin has no CORS headers | Manual | ☐ | | |
|
||||
| 2.3.3 | Wildcard CORS mode | Manual | ☐ | | |
|
||||
| 2.4.1 | Private keys never in API responses (certificate detail) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.2 | Private keys never in API responses (certificate versions) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.3 | Private keys never in API responses (agent work) | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.4 | Private keys never in server logs | Auto | ☑ | 2026-03-30 | |
|
||||
| 2.4.5 | API key stored as SHA-256 hash (not plaintext) | Manual | ☐ | | |
|
||||
|
||||
### Part 3: Certificate Lifecycle (CRUD)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 3.1.1 | Create certificate with minimal fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.1.2 | Create certificate with all fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.1.3 | Create certificate with duplicate common_name | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.1 | List certificates with pagination metadata | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.2 | Filter by status | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.3 | Filter by owner | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.4 | Filter by issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.5 | Filter by environment | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.6 | Pagination: page 2 | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.7 | Sort descending by notAfter | Manual | ☐ | | |
|
||||
| 3.2.8 | Sort ascending by commonName | Manual | ☐ | | |
|
||||
| 3.2.9 | Sparse fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.10 | Cursor pagination: first page | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.2.11 | Cursor pagination: second page | Manual | ☐ | | |
|
||||
| 3.2.12 | Time-range filter: expires_before | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.1 | Get single certificate by ID | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.2 | Get nonexistent certificate returns 404 | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.3 | Update certificate fields | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.4 | Archive (soft delete) certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.3.5 | Get archived certificate behavior | Manual | ☐ | | |
|
||||
| 3.4.1 | Get certificate versions | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.4.2 | Get certificate deployments | Auto | ☑ | 2026-03-30 | |
|
||||
| 3.4.3 | Trigger deployment creates a job | Manual | ☐ | | |
|
||||
|
||||
### Part 4: Renewal Workflow
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 4.1.1 | Trigger renewal creates job | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.1.2 | Renewal job appears in jobs list | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.1.3 | Renewal on nonexistent certificate returns 404 | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.2.1 | Server keygen mode: job completes automatically | Manual | ☐ | | |
|
||||
| 4.3.1 | Approve a job | Manual | ☐ | | |
|
||||
| 4.3.2 | Reject a job with reason | Manual | ☐ | | |
|
||||
| 4.4.1 | Agent work endpoint returns pending jobs | Auto | ☑ | 2026-03-30 | |
|
||||
| 4.4.2 | Agent reports job status | Manual | ☐ | | |
|
||||
|
||||
### Part 5: Revocation
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 5.1.1 | Revoke with default reason | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.1.2 | Revoke with reason: keyCompromise | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.1.3 | Revoke with reason: caCompromise | Manual | ☐ | | |
|
||||
| 5.1.4 | Revoke with reason: affiliationChanged | Manual | ☐ | | |
|
||||
| 5.1.5 | Revoke with reason: superseded | Manual | ☐ | | |
|
||||
| 5.1.6 | Revoke with reason: cessationOfOperation | Manual | ☐ | | |
|
||||
| 5.1.7 | Revoke with reason: certificateHold | Manual | ☐ | | |
|
||||
| 5.1.8 | Revoke with reason: privilegeWithdrawn | Manual | ☐ | | |
|
||||
| 5.2.1 | Revoke already-revoked certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.2 | Revoke nonexistent certificate | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.3 | Revoke with invalid reason | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.2.4 | Revocation appears in audit trail | Manual | ☐ | | |
|
||||
| 5.3.1 | JSON CRL endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.2 | DER CRL endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.3 | OCSP: good response for non-revoked cert | Auto | ☑ | 2026-03-30 | |
|
||||
| 5.3.4 | OCSP: revoked response for revoked cert | Manual | ☐ | | |
|
||||
| 5.3.5 | OCSP: unknown serial | Manual | ☐ | | |
|
||||
|
||||
### Part 6: Issuer Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 6.1.1 | List issuers shows seed data | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.2 | Get issuer detail | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.3 | Create issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.4 | Update issuer | Manual | ☐ | | |
|
||||
| 6.1.5 | Delete issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.6 | Test issuer connection | Manual | ☐ | | |
|
||||
| 6.1.7 | Create issuer with missing name returns validation error | Auto | ☑ | 2026-03-30 | |
|
||||
| 6.1.8 | Create issuer with invalid type | Manual | ☐ | | |
|
||||
| 6.2.1 | List ACME issuer with DNS-01 configuration | Manual | ☐ | | |
|
||||
| 6.2.2 | Create ACME issuer with DNS-PERSIST-01 | Manual | ☐ | | |
|
||||
| 6.2.3 | Configure ACME with External Account Binding (ZeroSSL) | Manual | ☐ | | |
|
||||
|
||||
### Part 7: Target Connectors & Deployment
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 7.1.1 | List targets shows seed data | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.2 | Create NGINX target | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.3 | Create Apache target | Manual | ☐ | | |
|
||||
| 7.1.4 | Create HAProxy target | Manual | ☐ | | |
|
||||
| 7.1.5 | Create F5 BIG-IP target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.6 | Create IIS target (stub) | Auto | ☑ | 2026-03-30 | |
|
||||
| 7.1.7 | Get target verifies type-specific config stored | Manual | ☐ | | |
|
||||
| 7.1.8 | Update target config | Manual | ☐ | | |
|
||||
| 7.1.9 | Delete target returns 204 | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 8: Agent Operations
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 8.1.1 | Register new agent | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.1.2 | List agents includes new agent | Manual | ☐ | | |
|
||||
| 8.1.3 | Get agent detail with metadata | Manual | ☐ | | |
|
||||
| 8.2.1 | Agent heartbeat updates last_heartbeat_at | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.2.2 | Heartbeat metadata stored | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.2.3 | Heartbeat for nonexistent agent | Auto | ☑ | 2026-03-30 | |
|
||||
| 8.3.1 | Agent work polling returns jobs | Manual | ☐ | | |
|
||||
| 8.3.2 | Agent work polling with no pending work | Manual | ☐ | | |
|
||||
| 8.3.3 | Agent certificate pickup | Manual | ☐ | | |
|
||||
| 8.3.4 | Delete agent for cleanup | Auto | — | 2026-03-30 | Skipped — DELETE not implemented |
|
||||
|
||||
### Part 9: Job System
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 9.1.1 | List jobs with pagination | Auto | ☑ | 2026-03-30 | |
|
||||
| 9.1.2 | Filter jobs by status | Manual | ☐ | | |
|
||||
| 9.1.3 | Filter jobs by type | Manual | ☐ | | |
|
||||
| 9.1.4 | Get job detail | Manual | ☐ | | |
|
||||
| 9.1.5 | Get nonexistent job | Auto | ☑ | 2026-03-30 | |
|
||||
| 9.2.1 | Cancel pending job | Manual | ☐ | | |
|
||||
| 9.2.2 | Cancel already-completed job | Manual | ☐ | | |
|
||||
|
||||
### Part 10: Policies & Profiles
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 10.1.1 | List policies | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.2 | Create policy | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.3 | Get policy | Manual | ☐ | | |
|
||||
| 10.1.4 | Update policy | Manual | ☐ | | |
|
||||
| 10.1.5 | Delete policy | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.1.6 | Policy violations endpoint | Manual | ☐ | | |
|
||||
| 10.1.7 | Invalid policy type returns 400 | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.1 | List profiles | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.2 | Create profile with crypto constraints | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.3 | Get profile | Manual | ☐ | | |
|
||||
| 10.2.4 | Update profile | Manual | ☐ | | |
|
||||
| 10.2.5 | Delete profile | Auto | ☑ | 2026-03-30 | |
|
||||
| 10.2.6 | Short-lived profile exists (TTL < 1 hour) | Manual | ☐ | | |
|
||||
|
||||
### Part 11: Ownership, Teams & Agent Groups
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 11.1.1 | List teams | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.1.2 | Team CRUD cycle | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.2.1 | Owner CRUD with team assignment | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.2.2 | Get, update, delete owner | Manual | ☐ | | |
|
||||
| 11.3.1 | List agent groups | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.3.2 | Create agent group with dynamic criteria | Manual | ☐ | | |
|
||||
| 11.3.3 | Agent group membership endpoint | Manual | ☐ | | |
|
||||
| 11.3.4 | Delete agent group returns 204 | Manual | ☐ | | |
|
||||
| 11.4.1 | Delete owner with assigned certificates (expect 409) | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.4.2 | Delete issuer with assigned certificates (expect 409) | Auto | ☑ | 2026-03-30 | |
|
||||
| 11.4.3 | Delete team cascades successfully | Manual | ☐ | | |
|
||||
|
||||
### Part 12: Notifications
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 12.1.1 | List notifications with pagination | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.2 | Get single notification | Manual | ☐ | | |
|
||||
| 12.1.3 | Mark notification as read | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.4 | Mark already-read notification (idempotent) | Manual | ☐ | | |
|
||||
| 12.1.5 | Get nonexistent notification | Auto | ☑ | 2026-03-30 | |
|
||||
| 12.1.6 | Verify notification created from revocation | Manual | ☐ | | |
|
||||
|
||||
### Part 13: Observability
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 13.1.1 | Dashboard summary | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.2 | Certificates by status | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.3 | Expiration timeline | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.4 | Job trends | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.5 | Issuance rate | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.1.6 | Stats with invalid days parameter | Manual | ☐ | | |
|
||||
| 13.2.1 | JSON metrics endpoint | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.2.2 | Metric values are non-negative | Manual | ☐ | | |
|
||||
| 13.2.3 | Uptime is positive | Manual | ☐ | | |
|
||||
| 13.3.1 | Prometheus content type | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.2 | Prometheus output contains HELP lines | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.3 | Prometheus output contains TYPE lines | Manual | ☐ | | |
|
||||
| 13.3.4 | All documented Prometheus metrics present | Auto | ☑ | 2026-03-30 | |
|
||||
| 13.3.5 | Prometheus metric values are parseable numbers | Manual | ☐ | | |
|
||||
| 13.3.6 | Method not allowed on metrics (POST) | Manual | ☐ | | |
|
||||
|
||||
### Part 14: Audit Trail
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 14.1.1 | List audit events | Auto | ☑ | 2026-03-30 | |
|
||||
| 14.1.2 | Get single audit event | Manual | ☐ | | |
|
||||
| 14.1.3 | Filter audit by time range | Manual | ☐ | | |
|
||||
| 14.1.4 | Filter audit by actor | Manual | ☐ | | |
|
||||
| 14.1.5 | Filter audit by resource type | Auto | ☑ | 2026-03-30 | |
|
||||
| 14.1.6 | Filter audit by action | Manual | ☐ | | |
|
||||
| 14.1.7 | API calls create audit entries | Manual | ☐ | | |
|
||||
| 14.1.8 | Audit immutability (no PUT/DELETE) | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 15: Certificate Discovery
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 15.1.1 | Submit discovery report | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.1.2 | Submit report with multiple certificates | Manual | ☐ | | |
|
||||
| 15.1.3 | Duplicate fingerprint deduplication | Manual | ☐ | | |
|
||||
| 15.1.4 | List discovered certificates | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.1.5 | Filter by status: Unmanaged | Manual | ☐ | | |
|
||||
| 15.1.6 | Filter by agent_id | Manual | ☐ | | |
|
||||
| 15.1.7 | Get discovered certificate detail | Manual | ☐ | | |
|
||||
| 15.1.8 | Claim discovered certificate | Manual | ☐ | | |
|
||||
| 15.1.9 | Dismiss discovered certificate | Manual | ☐ | | |
|
||||
| 15.1.10 | List discovery scans | Manual | ☐ | | |
|
||||
| 15.1.11 | Discovery summary | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.1 | List network scan targets (seed data) | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.2 | Create network scan target | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.3 | Get scan target detail | Manual | ☐ | | |
|
||||
| 15.2.4 | Update scan target | Manual | ☐ | | |
|
||||
| 15.2.5 | Delete scan target | Auto | ☑ | 2026-03-30 | |
|
||||
| 15.2.6 | Trigger manual scan | Manual | ☐ | | |
|
||||
| 15.2.7 | Invalid CIDR validation | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 16: Enhanced Query API
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 16.1.1 | Sparse fields: only requested fields returned | Manual | ☐ | | |
|
||||
| 16.1.2 | Sort ascending: commonName | Manual | ☐ | | |
|
||||
| 16.1.3 | Sort descending: notAfter | Manual | ☐ | | |
|
||||
| 16.1.4 | Sort by invalid field | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.5 | Cursor pagination first page | Manual | ☐ | | |
|
||||
| 16.1.6 | Cursor pagination second page | Manual | ☐ | | |
|
||||
| 16.1.7 | Time-range: expires_before | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.8 | Time-range: created_after | Auto | ☑ | 2026-03-30 | |
|
||||
| 16.1.9 | Combined filters | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 17: CLI Tool
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 17.2.1 | List certificates (table format) | Manual | ☐ | | |
|
||||
| 17.2.2 | List certificates (JSON format) | Manual | ☐ | | |
|
||||
| 17.2.3 | Get specific certificate | Manual | ☐ | | |
|
||||
| 17.2.4 | Get nonexistent certificate | Manual | ☐ | | |
|
||||
| 17.2.5 | Renew certificate | Manual | ☐ | | |
|
||||
| 17.2.6 | Revoke certificate with reason | Manual | ☐ | | |
|
||||
| 17.3.1 | List agents | Manual | ☐ | | |
|
||||
| 17.3.2 | List jobs | Manual | ☐ | | |
|
||||
| 17.4.1 | Server status/health | Manual | ☐ | | |
|
||||
| 17.4.2 | CLI version | Manual | ☐ | | |
|
||||
| 17.5.1 | Import single PEM file | Manual | ☐ | | |
|
||||
| 17.6.1 | --server flag overrides env var | Manual | ☐ | | |
|
||||
| 17.6.2 | --api-key flag overrides env var | Manual | ☐ | | |
|
||||
| 17.6.3 | Missing server URL produces error | Manual | ☐ | | |
|
||||
|
||||
### Part 18: MCP Server
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 18.1.1 | Binary builds successfully | Manual | ☐ | | |
|
||||
| 18.1.2 | Startup with valid env vars | Manual | ☐ | | |
|
||||
| 18.1.3 | Missing CERTCTL_SERVER_URL behavior | Manual | ☐ | | |
|
||||
| 18.2.1 | Tool count verification (78 tools) | Manual | ☐ | | |
|
||||
| 18.2.2 | All 16 resource domains present | Manual | ☐ | | |
|
||||
| 18.3.1 | List certificates via MCP | Manual | ☐ | | |
|
||||
| 18.3.2 | Get specific certificate via MCP | Manual | ☐ | | |
|
||||
|
||||
### Part 19: GUI Testing
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 19.1 | Authentication Flow | Manual | ☐ | | |
|
||||
| 19.2 | Dashboard Page | Manual | ☐ | | |
|
||||
| 19.3 | Certificates Page | Manual | ☐ | | |
|
||||
| 19.4 | Certificate Detail Page | Manual | ☐ | | |
|
||||
| 19.5 | Jobs Page — Approval Workflow | Manual | ☐ | | |
|
||||
| 19.6 | Discovery Triage Page | Manual | ☐ | | |
|
||||
| 19.7 | Network Scan Management Page | Manual | ☐ | | |
|
||||
| 19.8 | Other Pages (agents, policies, audit, etc.) | Manual | ☐ | | |
|
||||
| 19.9 | Cross-Cutting (responsive, error states, dark theme) | Manual | ☐ | | |
|
||||
|
||||
### Part 20: Background Scheduler
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 20.1.1 | Scheduler startup: all 7 loops registered | Manual | ☐ | | |
|
||||
| 20.1.2 | Job processor loop fires (30s interval) | Manual | ☐ | | |
|
||||
| 20.1.3 | Agent health check marks offline (2m interval) | Manual | ☐ | | |
|
||||
| 20.1.4 | Notification processor fires (1m interval) | Manual | ☐ | | |
|
||||
| 20.1.5 | Short-lived expiry check (30s interval) | Manual | ☐ | | |
|
||||
| 20.1.6 | Network scanner loop (conditional on env var) | Manual | ☐ | | |
|
||||
| 20.1.7 | Renewal check loop (1h interval — log verification) | Manual | ☐ | | |
|
||||
| 20.1.8 | Scheduler graceful stop | Manual | ☐ | | |
|
||||
|
||||
### Part 21: Error Handling
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 21.1.1 | Malformed JSON body | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.2 | Missing required field | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.3 | Method not allowed | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.4 | Invalid query parameter | Manual | ☐ | | |
|
||||
| 21.1.5 | UTF-8 in common name | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.6 | Concurrent requests (parallel curl) | Manual | ☐ | | |
|
||||
| 21.1.7 | Server survives internal error | Auto | ☑ | 2026-03-30 | |
|
||||
| 21.1.8 | Empty request body on POST | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 22: Performance Spot Checks
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 22.1.1 | List certificates < 200ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.2 | Stats summary < 500ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.3 | Metrics < 200ms | Auto | ☑ | 2026-03-30 | |
|
||||
| 22.1.4 | 50 health checks < 5 seconds total | Manual | ☐ | | |
|
||||
|
||||
### Part 23: Structured Logging
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 23.1.1 | Server logs are valid JSON | Manual | ☐ | | |
|
||||
| 23.1.2 | Log lines contain level field | Manual | ☐ | | |
|
||||
| 23.1.3 | Request ID propagation | Manual | ☐ | | |
|
||||
| 23.1.4 | Error logs at ERROR level | Manual | ☐ | | |
|
||||
| 23.1.5 | No unstructured output in log stream | Manual | ☐ | | |
|
||||
|
||||
### Part 24: Documentation Verification
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 24.1 | OpenAPI spec matches router, README accuracy | Manual | ☐ | | |
|
||||
|
||||
### Part 25: Regression Tests
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 25.1.1 | DELETE endpoints return 204, not 200 | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.2 | per_page exceeding max falls back to default | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.3 | Seed demo network scan targets present | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.4 | GUI delete on FK-restricted entities shows error, not silent f... | Auto | ☑ | 2026-03-30 | |
|
||||
| 25.1.5 | OpenAPI spec operations match router | Manual | ☐ | | |
|
||||
| 25.1.6 | Go service tests use strings.Contains, not errors.Is | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 26: EST Server (RFC 7030)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 26.1 | GET /.well-known/est/cacerts returns PKCS#7 CA chain | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.2 | GET /cacerts method enforcement | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.3 | POST /.well-known/est/simpleenroll with PEM CSR | Manual | ☐ | | |
|
||||
| 26.4 | POST /simpleenroll with base64-encoded DER CSR | Manual | ☐ | | |
|
||||
| 26.5 | POST /simpleenroll with empty body | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.6 | POST /simpleenroll with invalid CSR | Manual | ☐ | | |
|
||||
| 26.7 | POST /simpleenroll with CSR missing Common Name | Manual | ☐ | | |
|
||||
| 26.8 | POST /simpleenroll method enforcement (GET not allowed) | Manual | ☐ | | |
|
||||
| 26.9 | POST /.well-known/est/simplereenroll (re-enrollment) | Manual | ☐ | | |
|
||||
| 26.10 | GET /simplereenroll method enforcement | Manual | ☐ | | |
|
||||
| 26.11 | GET /.well-known/est/csrattrs returns 204 (no required attrs) | Auto | — | 2026-03-30 | Skipped — EST not enabled in demo |
|
||||
| 26.12 | POST /csrattrs method enforcement | Manual | ☐ | | |
|
||||
| 26.13 | EST enrollment creates audit event | Manual | ☐ | | |
|
||||
| 26.14 | EST disabled returns 404 | Manual | ☐ | | |
|
||||
| 26.15 | EST with profile binding | Manual | ☐ | | |
|
||||
|
||||
### Part 27: Post-Deployment TLS Verification
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 27.1 | Submit Verification Result (Success) | Manual | ☐ | | |
|
||||
| 27.2 | Submit Verification Result (Failure — Fingerprint Mismatch) | Manual | ☐ | | |
|
||||
| 27.3 | Get Verification Status | Manual | ☐ | | |
|
||||
| 27.4 | Missing Required Fields | Manual | ☐ | | |
|
||||
| 27.5 | Audit Trail | Manual | ☐ | | |
|
||||
| 27.6 | Database Schema Verification | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 28: Traefik & Caddy Target Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 28.1 | Traefik File Provider Deployment | Manual | ☐ | | |
|
||||
| 28.2 | Caddy API Mode Deployment | Manual | ☐ | | |
|
||||
| 28.3 | Caddy File Mode Deployment | Manual | ☐ | | |
|
||||
| 28.4 | Agent Connector Dispatch | Manual | ☐ | | |
|
||||
| 28.5 | Connector Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 29: Certificate Export
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 29.1 | Export PEM (JSON Response) | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.2 | Export PEM (File Download) | Manual | ☐ | | |
|
||||
| 29.3 | Export PEM — Not Found | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.4 | Export PKCS#12 | Auto | ☑ | 2026-03-30 | |
|
||||
| 29.5 | Export PKCS#12 — Empty Password | Manual | ☐ | | |
|
||||
| 29.6 | Export Audit Trail | Manual | ☐ | | |
|
||||
| 29.7 | Export Unit Tests | Manual | ☐ | | |
|
||||
| 29.8 | GUI Export Buttons | Manual | ☐ | | |
|
||||
|
||||
### Part 30: S/MIME & EKU Support
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 30.1 | S/MIME Profile Exists in Seed Data | Auto | ☑ | 2026-03-30 | |
|
||||
| 30.2 | All Five Profiles Present | Auto | ☑ | 2026-03-30 | |
|
||||
| 30.3 | EKU Strings in Profile API | Manual | ☐ | | |
|
||||
| 30.4 | Agent CSR SAN Splitting (Email vs DNS) | Manual | ☐ | | |
|
||||
| 30.5 | EKU Service-Layer Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 31: OCSP Responder & DER CRL
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 31.1 | DER-Encoded CRL | Auto | ☑ | 2026-03-30 | |
|
||||
| 31.2 | DER CRL — Nonexistent Issuer | Auto | ☑ | 2026-03-30 | |
|
||||
| 31.3 | OCSP Responder — Good Status | Manual | ☐ | | |
|
||||
| 31.4 | OCSP Responder — Revoked Status | Manual | ☐ | | |
|
||||
| 31.5 | OCSP — Unknown Certificate | Manual | ☐ | | |
|
||||
| 31.6 | Short-Lived Certificate CRL Exemption | Manual | ☐ | | |
|
||||
| 31.7 | OCSP / CRL Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 32: Request Body Size Limits
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 32.1 | Default 1MB Limit | Manual | ☐ | | |
|
||||
| 32.2 | Normal-Sized Requests Work | Auto | ☑ | 2026-03-30 | |
|
||||
| 32.3 | Custom Body Size via Environment Variable | Manual | ☐ | | |
|
||||
| 32.4 | Requests Without Bodies Are Unaffected | Auto | ☑ | 2026-03-30 | |
|
||||
|
||||
### Part 33: Apache & HAProxy Target Connectors
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 33.1 | Create Apache Target | Manual | ☐ | | |
|
||||
| 33.2 | Apache Config — Separate Files | Manual | ☐ | | |
|
||||
| 33.3 | Create HAProxy Target | Manual | ☐ | | |
|
||||
| 33.4 | HAProxy Combined PEM Requirement | Manual | ☐ | | |
|
||||
| 33.5 | Shell Command Injection Prevention | Manual | ☐ | | |
|
||||
| 33.6 | Connector Unit Tests | Manual | ☐ | | |
|
||||
|
||||
### Part 34: Sub-CA Mode
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 34.1 | Self-Signed Mode (Default) | Manual | ☐ | | |
|
||||
| 34.2 | Sub-CA Mode — Configuration | Manual | ☐ | | |
|
||||
| 34.3 | Sub-CA Chain Construction | Manual | ☐ | | |
|
||||
| 34.4 | Sub-CA Validation — Non-CA Cert Rejected | Manual | ☐ | | |
|
||||
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
|
||||
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 121 |
|
||||
| — Skipped (preconditions not met in demo) | 5 |
|
||||
| ☐ Manual (requires hands-on verification) | 194 |
|
||||
| **Total** | **320** |
|
||||
|
||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -134,6 +135,11 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
created, err := h.svc.RegisterAgent(r.Context(), agent)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, "Agent with this name already exists", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||
return
|
||||
}
|
||||
@@ -184,6 +190,11 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.svc.Heartbeat(r.Context(), agentID, metadata); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("Heartbeat failed", "agent_id", agentID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -353,11 +353,12 @@ func TestCreateCertificate_Success(t *testing.T) {
|
||||
handler := NewCertificateHandler(mock)
|
||||
|
||||
certBody := domain.ManagedCertificate{
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
}
|
||||
body, _ := json.Marshal(certBody)
|
||||
|
||||
@@ -410,11 +411,12 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
|
||||
handler := NewCertificateHandler(mock)
|
||||
|
||||
certBody := domain.ManagedCertificate{
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
Name: "Production Cert",
|
||||
CommonName: "example.com",
|
||||
OwnerID: "o-alice",
|
||||
TeamID: "t-platform",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "rp-standard",
|
||||
}
|
||||
body, _ := json.Marshal(certBody)
|
||||
|
||||
@@ -534,8 +536,8 @@ func TestArchiveCertificate_NotFound(t *testing.T) {
|
||||
|
||||
handler.ArchiveCertificate(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -231,6 +232,14 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("name", cert.Name); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
if err := ValidateRequired("renewal_policy_id", cert.RenewalPolicyID); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.svc.CreateCertificate(cert)
|
||||
if err != nil {
|
||||
@@ -287,6 +296,11 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
updated, err := h.svc.UpdateCertificate(id, cert)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
||||
return
|
||||
}
|
||||
@@ -311,6 +325,10 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
if err := h.svc.ArchiveCertificate(id); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
|
||||
return
|
||||
}
|
||||
@@ -353,7 +371,12 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
|
||||
|
||||
versions, total, err := h.svc.GetCertificateVersions(certID, page, perPage)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate versions", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -387,6 +410,19 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
|
||||
certID := parts[0]
|
||||
|
||||
if err := h.svc.TriggerRenewal(certID); err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "cannot renew") {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "already in progress") {
|
||||
ErrorWithRequestID(w, http.StatusConflict, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
|
||||
return
|
||||
}
|
||||
@@ -480,7 +516,7 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") {
|
||||
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") || strings.Contains(errMsg, "failed to get") {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -49,6 +50,7 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
||||
return
|
||||
}
|
||||
@@ -96,6 +98,11 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot be parsed") || strings.Contains(err.Error(), "no certificates found") {
|
||||
ErrorWithRequestID(w, http.StatusUnprocessableEntity, "Certificate data cannot be parsed as X.509", requestID)
|
||||
return
|
||||
}
|
||||
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_create_certificate",
|
||||
Description: "Create a new managed certificate. Requires common_name and issuer_id at minimum.",
|
||||
Description: "Create a new managed certificate. Requires name, common_name, renewal_policy_id, issuer_id, owner_id, and team_id.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateCertificateInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/certificates", input)
|
||||
if err != nil {
|
||||
@@ -144,7 +144,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_trigger_renewal",
|
||||
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202).",
|
||||
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202). Returns 404 if certificate not found, 400 if certificate is archived/expired, 409 if renewal already in progress.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/certificates/"+input.ID+"/renew", nil)
|
||||
if err != nil {
|
||||
@@ -385,7 +385,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_register_agent",
|
||||
Description: "Register a new agent. Requires name and hostname.",
|
||||
Description: "Register a new agent. Requires name and hostname. Returns 409 if an agent with the same name already exists.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input RegisterAgentInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/agents", input)
|
||||
if err != nil {
|
||||
@@ -396,7 +396,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_agent_heartbeat",
|
||||
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version).",
|
||||
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version). Returns 404 if agent not found.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input struct {
|
||||
ID string `json:"id" jsonschema:"Agent ID"`
|
||||
Version string `json:"version,omitempty" jsonschema:"Agent version"`
|
||||
|
||||
@@ -349,7 +349,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
|
||||
func (r *CertificateRepository) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, certificate_id, serial_number, not_before, not_after,
|
||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
||||
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||
FROM certificate_versions
|
||||
WHERE certificate_id = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -363,10 +363,12 @@ func (r *CertificateRepository) ListVersions(ctx context.Context, certID string)
|
||||
var versions []*domain.CertificateVersion
|
||||
for rows.Next() {
|
||||
var v domain.CertificateVersion
|
||||
var csrPEM sql.NullString
|
||||
if err := rows.Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
||||
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt); err != nil {
|
||||
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan certificate version: %w", err)
|
||||
}
|
||||
v.CSRPEM = csrPEM.String
|
||||
versions = append(versions, &v)
|
||||
}
|
||||
|
||||
@@ -386,11 +388,11 @@ func (r *CertificateRepository) CreateVersion(ctx context.Context, version *doma
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO certificate_versions (
|
||||
id, certificate_id, serial_number, not_before, not_after,
|
||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`, version.ID, version.CertificateID, version.SerialNumber, version.NotBefore, version.NotAfter,
|
||||
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.KeyAlgorithm, version.KeySize, version.CreatedAt).Scan(&version.ID)
|
||||
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.CreatedAt).Scan(&version.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create certificate version: %w", err)
|
||||
@@ -433,15 +435,17 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
|
||||
// GetLatestVersion returns the most recent certificate version for a certificate.
|
||||
func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
||||
var v domain.CertificateVersion
|
||||
var csrPEM sql.NullString
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, certificate_id, serial_number, not_before, not_after,
|
||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
||||
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||
FROM certificate_versions
|
||||
WHERE certificate_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, certID).Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
||||
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt)
|
||||
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt)
|
||||
v.CSRPEM = csrPEM.String
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get latest certificate version: %w", err)
|
||||
|
||||
@@ -311,12 +311,56 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
||||
}
|
||||
|
||||
// UpdateCertificate modifies a certificate (handler interface method).
|
||||
func (s *CertificateService) UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
cert.ID = id
|
||||
if err := s.certRepo.Update(context.Background(), &cert); err != nil {
|
||||
func (s *CertificateService) UpdateCertificate(id string, patch domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Fetch existing certificate so partial updates don't zero out fields
|
||||
existing, err := s.certRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certificate not found: %w", err)
|
||||
}
|
||||
|
||||
// Merge non-zero fields from patch into existing
|
||||
if patch.Name != "" {
|
||||
existing.Name = patch.Name
|
||||
}
|
||||
if patch.CommonName != "" {
|
||||
existing.CommonName = patch.CommonName
|
||||
}
|
||||
if len(patch.SANs) > 0 {
|
||||
existing.SANs = patch.SANs
|
||||
}
|
||||
if patch.Environment != "" {
|
||||
existing.Environment = patch.Environment
|
||||
}
|
||||
if patch.OwnerID != "" {
|
||||
existing.OwnerID = patch.OwnerID
|
||||
}
|
||||
if patch.TeamID != "" {
|
||||
existing.TeamID = patch.TeamID
|
||||
}
|
||||
if patch.IssuerID != "" {
|
||||
existing.IssuerID = patch.IssuerID
|
||||
}
|
||||
if patch.RenewalPolicyID != "" {
|
||||
existing.RenewalPolicyID = patch.RenewalPolicyID
|
||||
}
|
||||
if patch.CertificateProfileID != "" {
|
||||
existing.CertificateProfileID = patch.CertificateProfileID
|
||||
}
|
||||
if patch.Status != "" {
|
||||
existing.Status = patch.Status
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
existing.Tags = patch.Tags
|
||||
}
|
||||
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.certRepo.Update(ctx, existing); err != nil {
|
||||
return nil, fmt.Errorf("failed to update certificate: %w", err)
|
||||
}
|
||||
return &cert, nil
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// ArchiveCertificate marks a certificate as archived (handler interface method).
|
||||
|
||||
@@ -40,6 +40,11 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
|
||||
return nil, fmt.Errorf("report must contain at least one certificate or error")
|
||||
}
|
||||
|
||||
// Ensure directories is never nil (PostgreSQL TEXT[] NOT NULL)
|
||||
if report.Directories == nil {
|
||||
report.Directories = []string{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
scan := &domain.DiscoveryScan{
|
||||
ID: generateID("dscan"),
|
||||
@@ -52,6 +57,11 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
|
||||
CompletedAt: &now,
|
||||
}
|
||||
|
||||
// Store the scan record first (discovered certs reference scan via FK)
|
||||
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
|
||||
return nil, fmt.Errorf("failed to create scan record: %w", err)
|
||||
}
|
||||
|
||||
// Upsert each discovered certificate
|
||||
newCount := 0
|
||||
for _, entry := range report.Certificates {
|
||||
@@ -105,11 +115,6 @@ func (s *DiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *d
|
||||
|
||||
scan.CertificatesNew = newCount
|
||||
|
||||
// Store the scan record
|
||||
if err := s.discoveryRepo.CreateScan(ctx, scan); err != nil {
|
||||
return nil, fmt.Errorf("failed to create scan record: %w", err)
|
||||
}
|
||||
|
||||
// Audit trail
|
||||
if err := s.auditService.RecordEvent(ctx, report.AgentID, domain.ActorTypeSystem,
|
||||
"discovery_scan_completed", "discovery_scan", scan.ID,
|
||||
|
||||
@@ -88,7 +88,7 @@ func (s *ExportService) ExportPKCS12(ctx context.Context, certID string, passwor
|
||||
// Parse PEM chain into x509.Certificate objects
|
||||
certs, err := parsePEMCertificates(version.PEMChain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate chain: %w", err)
|
||||
return nil, fmt.Errorf("certificate data cannot be parsed as X.509: %w", err)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
|
||||
@@ -321,8 +321,8 @@ func TestTeamService_Create_EmptyName(t *testing.T) {
|
||||
t.Fatalf("expected validation error for empty name, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, errors.New("team name is required")) {
|
||||
t.Logf("error: %v", err)
|
||||
if !strings.Contains(err.Error(), "team name is required") {
|
||||
t.Errorf("expected error containing 'team name is required', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user