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@jetbrains.com>
This commit is contained in:
2026-03-15 13:24:05 -07:00
parent b4dbc088cb
commit 74e35ce63e
3 changed files with 340 additions and 2 deletions

View File

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

View File

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