diff --git a/docs/testing-guide.md b/docs/testing-guide.md index d06c9cd..54829c2 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -44,6 +44,7 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp - [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e) - [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32) - [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37) +- [Part 40: Issuer Catalog Page (M33)](#part-40-issuer-catalog-page-m33) - [Release Sign-Off](#release-sign-off) --- @@ -5372,6 +5373,88 @@ curl -s -X POST -H "$AUTH" \ --- +## Part 40: Issuer Catalog Page (M33) + +Frontend-only milestone. No backend changes. All tests are automated via `qa-smoke-test.sh` and `vitest`. + +### 40.1 Shared Issuer Type Config + +**Test:** Verify shared config file exists with all 6 supported types + 2 coming soon stubs. + +```bash +test -f web/src/config/issuerTypes.ts +grep -c 'VaultPKI' web/src/config/issuerTypes.ts # >= 1 +grep -c 'DigiCert' web/src/config/issuerTypes.ts # >= 1 +grep -cE 'eab_kid|eab_hmac' web/src/config/issuerTypes.ts # >= 1 +grep -c 'sensitive' web/src/config/issuerTypes.ts # >= 1 +``` + +**PASS if** file exists, all types present, EAB fields and sensitive flags included. + +### 40.2 Composable Wizard Components + +**Test:** Verify reusable components exist. + +```bash +test -f web/src/components/issuer/TypeSelector.tsx +test -f web/src/components/issuer/ConfigForm.tsx +test -f web/src/components/issuer/ConfigDetailModal.tsx +``` + +**PASS if** all 3 component files exist. + +### 40.3 Frontend Build + +**Test:** Verify frontend builds with zero errors. + +```bash +cd web && npm run build 2>&1 | tail -1 | grep -q 'built in' +``` + +**PASS if** build succeeds. + +### 40.4 Frontend Tests + +**Test:** Verify all Vitest tests pass including new VaultPKI/DigiCert create tests. + +```bash +cd web && npx vitest run 2>&1 | grep -qE 'Tests.*passed' +``` + +**PASS if** all tests pass. + +### 40.5 (Manual) Create VaultPKI Issuer via Wizard + +**Test:** Open Issuers page, click "Configure" on Vault PKI card, fill in form (addr, token, mount, role, ttl), submit. +**PASS if** issuer appears in configured issuers table. + +### 40.6 (Manual) Create DigiCert Issuer via Wizard + +**Test:** Open Issuers page, click "Configure" on DigiCert card, fill in form (api_key, org_id, product_type), submit. +**PASS if** issuer appears in configured issuers table. + +### 40.7 (Manual) Create ACME Issuer with EAB Fields + +**Test:** Open create wizard, select ACME, verify EAB Key ID and EAB HMAC Key fields are visible. +**PASS if** EAB fields render and accept input. + +### 40.8 (Manual) Catalog Cards Show Correct Status + +**Test:** Verify catalog cards show "Connected" (green, count) for types with configured issuers, "Available" (blue) for unconfigured types, and "Coming Soon" (grey) for Sectigo/Entrust. +**PASS if** all 8 cards render with correct status. + +### 40.9 (Manual) Config Detail Modal Shows Full Redacted Config + +**Test:** Click "View Config" on a configured issuer row. Verify modal shows full config JSON with sensitive fields (token, key, hmac, password, private, secret) redacted as `********`. +**PASS if** modal opens, full config visible, sensitive fields redacted. + +### 40.10 (Manual) Issuer Type Filter Works + +**Test:** Use the type filter dropdown above the configured issuers table. Select a specific type. +**PASS if** table filters to show only issuers of the selected type. + +--- + ## Release Sign-Off 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**). @@ -5952,14 +6035,33 @@ These must be green before starting manual QA: | 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox | | 39.5 | Revocation records locally | Manual | ☐ | | Requires DigiCert sandbox | +### Part 40: Issuer Catalog Page (M33) + +| Test | Description | Method | Pass? | Date | Notes | +|------|-------------|--------|-------|------|-------| +| 40.s1 | Shared issuerTypes config exists | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.1 | +| 40.s2 | VaultPKI in issuerTypes config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.2 | +| 40.s3 | DigiCert in issuerTypes config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.3 | +| 40.s4 | ACME EAB fields in config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.4 | +| 40.s5 | Sensitive field flag in config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.5 | +| 40.s6 | ConfigDetailModal component exists | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.6 | +| 40.s7 | Frontend build succeeds | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.7 | +| 40.s8 | Frontend tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.8 | +| 40.m1 | Create VaultPKI issuer via wizard | Manual | ☐ | | | +| 40.m2 | Create DigiCert issuer via wizard | Manual | ☐ | | | +| 40.m3 | Create ACME issuer with EAB fields | Manual | ☐ | | | +| 40.m4 | Catalog cards show correct status | Manual | ☐ | | | +| 40.m5 | Config detail modal shows full redacted config | Manual | ☐ | | | +| 40.m6 | Issuer type filter works | Manual | ☐ | | | + ### Summary | Category | Count | |----------|-------| -| ☑ Auto (passed in `qa-smoke-test.sh`) | 136 | +| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 | | — Skipped (preconditions not met in demo) | 5 | -| ☐ Manual (requires hands-on verification) | 226 | -| **Total** | **367** | +| ☐ Manual (requires hands-on verification) | 232 | +| **Total** | **381** | **Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss. diff --git a/internal/api/handler/certificates.go b/internal/api/handler/certificates.go index e42041f..b8e17cb 100644 --- a/internal/api/handler/certificates.go +++ b/internal/api/handler/certificates.go @@ -243,6 +243,7 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req created, err := h.svc.CreateCertificate(cert) if err != nil { + slog.Error("failed to create certificate", "error", err, "request_id", requestID, "common_name", cert.CommonName, "name", cert.Name) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID) return } diff --git a/internal/service/certificate.go b/internal/service/certificate.go index d72d107..317554a 100644 --- a/internal/service/certificate.go +++ b/internal/service/certificate.go @@ -304,6 +304,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) ( if cert.UpdatedAt.IsZero() { cert.UpdatedAt = now } + // Default status to Pending if not set (DB column DEFAULT only applies when column is omitted from INSERT) + if cert.Status == "" { + cert.Status = domain.CertificateStatusPending + } + // Default tags to empty map if nil (avoids JSON null in JSONB column) + if cert.Tags == nil { + cert.Tags = make(map[string]string) + } if err := s.certRepo.Create(context.Background(), &cert); err != nil { return nil, fmt.Errorf("failed to create certificate: %w", err) } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index f6cff7b..3273a4a 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -632,6 +632,50 @@ describe('API Client', () => { expect(url).toBe('/api/v1/issuers'); expect(init.method).toBe('POST'); }); + + it('createIssuer sends correct payload for VaultPKI type', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-vault', name: 'Vault PKI' })); + const vaultPayload = { + name: 'Vault PKI', + type: 'VaultPKI', + config: { + addr: 'https://vault.internal:8200', + token: 'hvs.test-token', + mount: 'pki', + role: 'web-certs', + ttl: '8760h', + }, + }; + await createIssuer(vaultPayload); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/issuers'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body); + expect(body.type).toBe('VaultPKI'); + expect(body.config.addr).toBe('https://vault.internal:8200'); + expect(body.config.role).toBe('web-certs'); + }); + + it('createIssuer sends correct payload for DigiCert type', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-digicert', name: 'DigiCert' })); + const digicertPayload = { + name: 'DigiCert CertCentral', + type: 'DigiCert', + config: { + api_key: 'test-api-key', + org_id: '12345', + product_type: 'ssl_basic', + }, + }; + await createIssuer(digicertPayload); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/issuers'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body); + expect(body.type).toBe('DigiCert'); + expect(body.config.org_id).toBe('12345'); + expect(body.config.product_type).toBe('ssl_basic'); + }); }); // ─── Audit ────────────────────────────────────────── diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d4b6673..ba43e71 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -135,6 +135,8 @@ export interface Issuer { type: string; config: Record; status: string; + /** Backend returns enabled boolean; status is derived from this */ + enabled: boolean; created_at: string; } diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx index 1d6dcbb..a422c97 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/components/StatusBadge.tsx @@ -23,6 +23,9 @@ const statusStyles: Record = { Unmanaged: 'badge-warning', Managed: 'badge-success', Dismissed: 'badge-neutral', + // Issuer statuses + Enabled: 'badge-success', + Disabled: 'badge-neutral', // Notification statuses sent: 'badge-success', pending: 'badge-warning', diff --git a/web/src/components/issuer/ConfigDetailModal.tsx b/web/src/components/issuer/ConfigDetailModal.tsx new file mode 100644 index 0000000..5deb8e4 --- /dev/null +++ b/web/src/components/issuer/ConfigDetailModal.tsx @@ -0,0 +1,56 @@ +/** + * Full config viewer modal with sensitive field redaction. + * Replaces the 60-char truncation in the issuers table. + * Reusable for targets in M35 — no IssuersPage-specific imports. + */ +import { isSensitiveKey } from '../../config/issuerTypes'; + +interface ConfigDetailModalProps { + title: string; + config: Record; + onClose: () => void; +} + +export default function ConfigDetailModal({ title, config, onClose }: ConfigDetailModalProps) { + const entries = Object.entries(config); + + return ( +
+
+
+

{title}

+ +
+
+ {entries.length === 0 ? ( +
No configuration data
+ ) : ( +
+ {entries.map(([key, val]) => { + const redacted = isSensitiveKey(key); + return ( +
+ {key} + + {redacted ? '********' : String(val ?? '')} + +
+ ); + })} +
+ )} +
+
+ +
+
+
+ ); +} diff --git a/web/src/components/issuer/ConfigForm.tsx b/web/src/components/issuer/ConfigForm.tsx new file mode 100644 index 0000000..327624e --- /dev/null +++ b/web/src/components/issuer/ConfigForm.tsx @@ -0,0 +1,139 @@ +/** + * Renders config fields from an IssuerTypeConfig.configFields definition. + * Handles sensitive field masking. M34 will reuse this directly for its + * dynamic config wizard. M35 can reuse it for target config forms. + */ +import type { ConfigField } from '../../config/issuerTypes'; + +interface ConfigFormProps { + fields: ConfigField[]; + values: Record; + onChange: (key: string, value: unknown) => void; + /** When true, sensitive fields show as ******** with a "Change" button. + * Used in edit mode — empty value means "keep existing". */ + editMode?: boolean; +} + +export default function ConfigForm({ fields, values, onChange, editMode }: ConfigFormProps) { + return ( +
+ {fields.map((field) => ( + onChange(field.key, v)} + editMode={editMode} + /> + ))} +
+ ); +} + +function ConfigFieldInput({ + field, + value, + onChange, + editMode, +}: { + field: ConfigField; + value: unknown; + onChange: (v: unknown) => void; + editMode?: boolean; +}) { + const inputCls = + 'w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors'; + + // In edit mode, sensitive fields that haven't been touched show as masked + if (editMode && field.sensitive && value === undefined) { + return ( +
+ +
+ ******** + +
+
+ ); + } + + if (field.type === 'select') { + return ( +
+ + +
+ ); + } + + if (field.type === 'textarea') { + return ( +
+ +