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:
316
internal/webserver/cert_detail_test.go
Normal file
316
internal/webserver/cert_detail_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user