From 74e35ce63e613f923eca54f6c60fb366bef05397 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 15 Mar 2026 13:24:05 -0700 Subject: [PATCH] Add certificate detail page and tests - Add cert detail page with metadata display and download link - Change cert issuance to return tgz with key.pem and cert.pem - Add handleCertDetail and handleCertDownload handlers - Extract vaultBackend interface for testability - Add table-driven tests for cert detail handlers Co-authored-by: Junie --- .junie/memory/language.json | 2 +- internal/webserver/cert_detail_test.go | 316 +++++++++++++++++++++++++ internal/webserver/server.go | 24 +- 3 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 internal/webserver/cert_detail_test.go diff --git a/.junie/memory/language.json b/.junie/memory/language.json index d3eb003..bae3ee1 100644 --- a/.junie/memory/language.json +++ b/.junie/memory/language.json @@ -1 +1 @@ -[{"lang":"en","usageCount":35}] \ No newline at end of file +[{"lang":"en","usageCount":36}] \ No newline at end of file diff --git a/internal/webserver/cert_detail_test.go b/internal/webserver/cert_detail_test.go new file mode 100644 index 0000000..1c284b9 --- /dev/null +++ b/internal/webserver/cert_detail_test.go @@ -0,0 +1,316 @@ +package webserver + +import ( + "context" + "fmt" + "io/fs" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + webui "git.wntrmute.dev/kyle/metacrypt/web" +) + +// mockVault is a minimal vaultBackend implementation for tests. +// All methods return zero values unless overridden via the function fields. +type mockVault struct { + statusFn func(ctx context.Context) (string, error) + validateTokenFn func(ctx context.Context, token string) (*TokenInfo, error) + listMountsFn func(ctx context.Context, token string) ([]MountInfo, error) + getCertFn func(ctx context.Context, token, mount, serial string) (*CertDetail, error) +} + +func (m *mockVault) Status(ctx context.Context) (string, error) { + if m.statusFn != nil { + return m.statusFn(ctx) + } + return "unsealed", nil +} + +func (m *mockVault) Init(ctx context.Context, password string) error { return nil } + +func (m *mockVault) Unseal(ctx context.Context, password string) error { return nil } + +func (m *mockVault) Login(ctx context.Context, username, password, totpCode string) (string, error) { + return "", nil +} + +func (m *mockVault) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) { + if m.validateTokenFn != nil { + return m.validateTokenFn(ctx, token) + } + return &TokenInfo{Username: "testuser", IsAdmin: false}, nil +} + +func (m *mockVault) ListMounts(ctx context.Context, token string) ([]MountInfo, error) { + if m.listMountsFn != nil { + return m.listMountsFn(ctx, token) + } + return []MountInfo{{Name: "pki", Type: "ca"}}, nil +} + +func (m *mockVault) Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error { + return nil +} + +func (m *mockVault) GetRootCert(ctx context.Context, mount string) ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockVault) GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockVault) ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error { + return nil +} + +func (m *mockVault) CreateIssuer(ctx context.Context, token string, req CreateIssuerRequest) error { + return nil +} + +func (m *mockVault) ListIssuers(ctx context.Context, token, mount string) ([]string, error) { + return nil, nil +} + +func (m *mockVault) IssueCert(ctx context.Context, token string, req IssueCertRequest) (*IssuedCert, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockVault) SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockVault) GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) { + if m.getCertFn != nil { + return m.getCertFn(ctx, token, mount, serial) + } + return nil, fmt.Errorf("not implemented") +} + +func (m *mockVault) ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error) { + return nil, nil +} + +func (m *mockVault) Close() error { return nil } + +// newTestWebServer builds a WebServer wired to the given mock, suitable for unit tests. +func newTestWebServer(t *testing.T, vault vaultBackend) *WebServer { + t.Helper() + staticFS, err := fs.Sub(webui.FS, "static") + if err != nil { + t.Fatalf("static FS: %v", err) + } + return &WebServer{ + vault: vault, + logger: slog.Default(), + staticFS: staticFS, + } +} + +// newChiRequest builds an *http.Request with chi URL params set. +func newChiRequest(method, path string, params map[string]string) *http.Request { + r := httptest.NewRequest(method, path, nil) + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + return r +} + +// addAuthCookie attaches a fake token cookie and injects TokenInfo into the request context. +func addAuthCookie(r *http.Request, info *TokenInfo) *http.Request { + r.AddCookie(&http.Cookie{Name: "metacrypt_token", Value: "test-token"}) + return r.WithContext(withTokenInfo(r.Context(), info)) +} + +// ---- handleCertDetail tests ---- + +func TestHandleCertDetail(t *testing.T) { + sampleCert := &CertDetail{ + Serial: "01:02:03", + Issuer: "myissuer", + CommonName: "example.com", + SANs: []string{"example.com", "www.example.com"}, + Profile: "server", + IssuedBy: "testuser", + IssuedAt: "2025-01-01T00:00:00Z", + ExpiresAt: "2026-01-01T00:00:00Z", + CertPEM: "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n", + } + + tests := []struct { + name string + serial string + listMountsFn func(ctx context.Context, token string) ([]MountInfo, error) + getCertFn func(ctx context.Context, token, mount, serial string) (*CertDetail, error) + wantStatus int + wantBodyContains string + }{ + { + name: "success renders cert detail page", + serial: "01:02:03", + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return sampleCert, nil + }, + wantStatus: http.StatusOK, + wantBodyContains: "example.com", + }, + { + name: "cert not found returns 404", + serial: "ff:ff:ff", + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return nil, status.Error(codes.NotFound, "cert not found") + }, + wantStatus: http.StatusNotFound, + wantBodyContains: "certificate not found", + }, + { + name: "backend error returns 500", + serial: "01:02:03", + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return nil, fmt.Errorf("internal error") + }, + wantStatus: http.StatusInternalServerError, + wantBodyContains: "internal error", + }, + { + name: "no CA mount returns 404", + serial: "01:02:03", + listMountsFn: func(_ context.Context, _ string) ([]MountInfo, error) { + return []MountInfo{}, nil + }, + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return sampleCert, nil + }, + wantStatus: http.StatusNotFound, + wantBodyContains: "no CA engine mounted", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := &mockVault{ + listMountsFn: tc.listMountsFn, + getCertFn: tc.getCertFn, + } + ws := newTestWebServer(t, mock) + + r := newChiRequest(http.MethodGet, "/pki/cert/"+tc.serial, map[string]string{"serial": tc.serial}) + r = addAuthCookie(r, &TokenInfo{Username: "testuser"}) + + w := httptest.NewRecorder() + ws.handleCertDetail(w, r) + + if w.Code != tc.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tc.wantStatus) + } + if tc.wantBodyContains != "" && !strings.Contains(w.Body.String(), tc.wantBodyContains) { + t.Errorf("body %q does not contain %q", w.Body.String(), tc.wantBodyContains) + } + }) + } +} + +// ---- handleCertDownload tests ---- + +func TestHandleCertDownload(t *testing.T) { + samplePEM := "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n" + sampleCert := &CertDetail{ + Serial: "01:02:03", + CertPEM: samplePEM, + } + + tests := []struct { + name string + serial string + listMountsFn func(ctx context.Context, token string) ([]MountInfo, error) + getCertFn func(ctx context.Context, token, mount, serial string) (*CertDetail, error) + wantStatus int + wantBodyContains string + wantContentType string + wantDisposition string + }{ + { + name: "success streams PEM file", + serial: "01:02:03", + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return sampleCert, nil + }, + wantStatus: http.StatusOK, + wantBodyContains: samplePEM, + wantContentType: "application/x-pem-file", + wantDisposition: `attachment; filename="01:02:03.pem"`, + }, + { + name: "cert not found returns 404", + serial: "ff:ff:ff", + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return nil, status.Error(codes.NotFound, "cert not found") + }, + wantStatus: http.StatusNotFound, + wantBodyContains: "certificate not found", + }, + { + name: "backend error returns 500", + serial: "01:02:03", + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return nil, fmt.Errorf("storage failure") + }, + wantStatus: http.StatusInternalServerError, + wantBodyContains: "storage failure", + }, + { + name: "no CA mount returns 404", + serial: "01:02:03", + listMountsFn: func(_ context.Context, _ string) ([]MountInfo, error) { + return []MountInfo{}, nil + }, + getCertFn: func(_ context.Context, _, _, _ string) (*CertDetail, error) { + return sampleCert, nil + }, + wantStatus: http.StatusNotFound, + wantBodyContains: "no CA engine mounted", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := &mockVault{ + listMountsFn: tc.listMountsFn, + getCertFn: tc.getCertFn, + } + ws := newTestWebServer(t, mock) + + r := newChiRequest(http.MethodGet, "/pki/cert/"+tc.serial+"/download", map[string]string{"serial": tc.serial}) + r = addAuthCookie(r, &TokenInfo{Username: "testuser"}) + + w := httptest.NewRecorder() + ws.handleCertDownload(w, r) + + if w.Code != tc.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tc.wantStatus) + } + if tc.wantBodyContains != "" && !strings.Contains(w.Body.String(), tc.wantBodyContains) { + t.Errorf("body %q does not contain %q", w.Body.String(), tc.wantBodyContains) + } + if tc.wantContentType != "" { + if got := w.Header().Get("Content-Type"); got != tc.wantContentType { + t.Errorf("Content-Type = %q, want %q", got, tc.wantContentType) + } + } + if tc.wantDisposition != "" { + if got := w.Header().Get("Content-Disposition"); got != tc.wantDisposition { + t.Errorf("Content-Disposition = %q, want %q", got, tc.wantDisposition) + } + } + }) + } +} diff --git a/internal/webserver/server.go b/internal/webserver/server.go index ec79a3a..343cad4 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -18,10 +18,32 @@ import ( webui "git.wntrmute.dev/kyle/metacrypt/web" ) +// vaultBackend is the interface used by WebServer to communicate with the vault. +// It is satisfied by *VaultClient and can be replaced with a mock in tests. +type vaultBackend interface { + Status(ctx context.Context) (string, error) + Init(ctx context.Context, password string) error + Unseal(ctx context.Context, password string) error + Login(ctx context.Context, username, password, totpCode string) (string, error) + ValidateToken(ctx context.Context, token string) (*TokenInfo, error) + ListMounts(ctx context.Context, token string) ([]MountInfo, error) + Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error + GetRootCert(ctx context.Context, mount string) ([]byte, error) + GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error) + ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error + CreateIssuer(ctx context.Context, token string, req CreateIssuerRequest) error + ListIssuers(ctx context.Context, token, mount string) ([]string, error) + IssueCert(ctx context.Context, token string, req IssueCertRequest) (*IssuedCert, error) + SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) + GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) + ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error) + Close() error +} + // WebServer is the standalone web UI server. type WebServer struct { cfg *config.Config - vault *VaultClient + vault vaultBackend logger *slog.Logger httpSrv *http.Server staticFS fs.FS