Ctrl K
ComunidadGuías…

internal/server/dashboard_recommendations_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"math/big"
	"strings"
	"testing"
	"time"

	"github.com/X4Applegate/caddyui/internal/models"
)

func TestBuildDashboardRecommendationsOperationalRisks(t *testing.T) {
	now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
	lastSync := now.Add(-10 * 24 * time.Hour)
	recs := buildDashboardRecommendations(dashboardRecommendationInput{
		ProxyHosts: []models.ProxyHost{
			{ID: 1, Domains: "plain.example.com", Enabled: true, SSLEnabled: false, DNSProvider: "cloudflare", CertificateID: 2},
			{ID: 2, Domains: "dns.example.com", Enabled: true, SSLEnabled: true, DNSProvider: "cloudflare", DNSProfileID: "deleted-profile"},
		},
		Certificates: []models.Certificate{
			{ID: 2, Source: models.CertSourcePEM, CertPEM: testCertificatePEM(t, now.Add(-24*time.Hour))},
		},
		DNSProfileIDs: map[string]bool{"active-profile": true},
		LastSync:      &lastSync,
		DownCount:     1,
		Now:           now,
	})

	want := []string{
		"Upstreams are down",
		"Enabled hosts have SSL off",
		"Managed DNS is incomplete",
		"DNS profile references are missing",
		"Custom certificates are expired",
		"Last sync is over a week old",
	}
	for _, title := range want {
		if !hasRecommendation(recs, title) {
			t.Fatalf("missing recommendation %q in %#v", title, recs)
		}
	}
	if len(recs) != len(want) {
		t.Fatalf("expected %d recommendations, got %d: %#v", len(want), len(recs), recs)
	}
}

func TestBuildDashboardRecommendationsAdminHardening(t *testing.T) {
	recs := buildDashboardRecommendations(dashboardRecommendationInput{
		IsAdmin:       true,
		AutoSnapshots: true,
		Now:           time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC),
	})

	for _, title := range []string{
		"Two-factor authentication is not required",
		"Admin IP allowlist is empty",
		"No config snapshots yet",
	} {
		if !hasRecommendation(recs, title) {
			t.Fatalf("missing recommendation %q in %#v", title, recs)
		}
	}
}

func TestBuildDashboardRecommendationsNamesAndFiltersUnusedCustomCertificates(t *testing.T) {
	input := dashboardRecommendationInput{
		IsAdmin: true,
		Certificates: []models.Certificate{
			{ID: 10, Name: "legacy-api", Source: models.CertSourcePEM},
			{ID: 11, Name: "old-file", Source: models.CertSourcePath},
			{ID: 12, Name: "managed-wildcard", Source: models.CertSourceManaged},
		},
		Now: time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC),
	}
	recs := buildDashboardRecommendations(input)
	rec, ok := findRecommendation(recs, "Unused custom certificates")
	if !ok {
		t.Fatalf("missing unused certificate recommendation in %#v", recs)
	}
	if rec.URL != "/certificates?usage=unused" {
		t.Fatalf("unused recommendation URL = %q", rec.URL)
	}
	if !strings.Contains(rec.Detail, "legacy-api, old-file") {
		t.Fatalf("unused recommendation does not name certificates: %q", rec.Detail)
	}
	if strings.Contains(rec.Detail, "managed-wildcard") {
		t.Fatalf("managed ACME certificate was incorrectly reported unused: %q", rec.Detail)
	}

	input.DismissedUnused = "10,11"
	if _, ok := findRecommendation(buildDashboardRecommendations(input), "Unused custom certificates"); ok {
		t.Fatal("dismissed unused-certificate set was still recommended")
	}

	input.Certificates = append(input.Certificates, models.Certificate{ID: 13, Name: "new-unused", Source: models.CertSourcePEM})
	if _, ok := findRecommendation(buildDashboardRecommendations(input), "Unused custom certificates"); !ok {
		t.Fatal("recommendation did not return after the unused-certificate set changed")
	}
}

func TestBuildDashboardRecommendationsCapsNoise(t *testing.T) {
	now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
	recs := buildDashboardRecommendations(dashboardRecommendationInput{
		IsAdmin:           true,
		ProxyHosts:        []models.ProxyHost{{ID: 1, Enabled: true, SSLEnabled: false, DNSProvider: "cloudflare", DNSProfileID: "deleted-profile"}},
		Certificates:      []models.Certificate{{ID: 9, Source: models.CertSourcePEM, CertPEM: testCertificatePEM(t, now.Add(-24*time.Hour))}},
		DNSProfileIDs:     map[string]bool{},
		DownCount:         2,
		MaintenanceCount:  1,
		RequireTOTP:       false,
		AdminAllowlistSet: false,
		AutoSnapshots:     false,
		Now:               now,
	})

	if len(recs) != 6 {
		t.Fatalf("expected recommendations to be capped at 6, got %d: %#v", len(recs), recs)
	}
}

func hasRecommendation(recs []dashboardRecommendation, title string) bool {
	_, ok := findRecommendation(recs, title)
	return ok
}

func findRecommendation(recs []dashboardRecommendation, title string) (dashboardRecommendation, bool) {
	for _, rec := range recs {
		if rec.Title == title {
			return rec, true
		}
	}
	return dashboardRecommendation{}, false
}

func testCertificatePEM(t *testing.T, notAfter time.Time) string {
	t.Helper()
	key, err := rsa.GenerateKey(rand.Reader, 1024)
	if err != nil {
		t.Fatal(err)
	}
	tmpl := x509.Certificate{
		SerialNumber: big.NewInt(1),
		NotBefore:    notAfter.Add(-24 * time.Hour),
		NotAfter:     notAfter,
		DNSNames:     []string{"example.com"},
	}
	der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
	if err != nil {
		t.Fatal(err)
	}
	var b strings.Builder
	if err := pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
		t.Fatal(err)
	}
	return b.String()
}