Admins can now revoke or delete certificate records from the cert detail
page in the web UI. Revoked certificates display a [REVOKED] badge and
show revocation metadata (time and actor). Deletion redirects to the
issuer page.
The REST API gains three new authenticated endpoints that mirror the
gRPC surface:
GET /v1/ca/{mount}/cert/{serial} (auth required)
POST /v1/ca/{mount}/cert/{serial}/revoke (admin only)
DELETE /v1/ca/{mount}/cert/{serial} (admin only)
The CA engine stores revocation state (revoked, revoked_at, revoked_by)
directly in the existing CertRecord barrier entry. The proto CertRecord
message is extended with the same three fields (field numbers 10–12).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
325 lines
9.8 KiB
Go
325 lines
9.8 KiB
Go
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) RevokeCert(ctx context.Context, token, mount, serial string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockVault) DeleteCert(ctx context.Context, token, mount, serial string) error {
|
|
return 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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|