From 417883bb77e12ca5a8354bf1756b97ba5bc74404 Mon Sep 17 00:00:00 2001 From: Scott Brown Date: Wed, 7 May 2025 17:00:00 -0600 Subject: [PATCH] Closes #26 by adding support for PDF reports --- cmd/cmd_report.go | 17 +- cmd/flag.go | 2 +- go.mod | 1 + go.sum | 13 ++ report.go | 423 ++++++++++++++++++++++++++++++++++++++++++++-- report_test.go | 180 +++++++++++++++++++- 6 files changed, 612 insertions(+), 24 deletions(-) diff --git a/cmd/cmd_report.go b/cmd/cmd_report.go index 76f8985..597d1e7 100644 --- a/cmd/cmd_report.go +++ b/cmd/cmd_report.go @@ -74,7 +74,7 @@ func runReportCmd(cmd *cobra.Command, args []string) { reportGenerator := pulse.NewReportGenerator(scoreCalculator, thresholdLabelType) // Generate the report - var reportContent string + var reportOutput *pulse.ReportOutput var reportErr error reportFormat := pulse.TextFormat @@ -83,12 +83,14 @@ func runReportCmd(cmd *cobra.Command, args []string) { reportFormat = pulse.JSONFormat case "table": reportFormat = pulse.TableFormat + case "pdf": + reportFormat = pulse.PDFFormat } if category != "" { - reportContent, reportErr = reportGenerator.GenerateCategoryReport(category, reportFormat) + reportOutput, reportErr = reportGenerator.GenerateCategoryReport(category, reportFormat) } else { - reportContent, reportErr = reportGenerator.GenerateOverallReport(reportFormat) + reportOutput, reportErr = reportGenerator.GenerateOverallReport(reportFormat) } if reportErr != nil { @@ -103,13 +105,18 @@ func runReportCmd(cmd *cobra.Command, args []string) { // Output the report if outputFile != "" { - err := os.WriteFile(outputFile, []byte(reportContent), 0600) + err := os.WriteFile(outputFile, reportOutput.Content, 0600) if err != nil { fmt.Printf("Error writing report to file: %v\n", err) os.Exit(1) } fmt.Printf("Report written to %s\n", outputFile) } else { - fmt.Println(reportContent) + if reportOutput.ContentType == "binary" { + fmt.Println("PDF output requires an output file. Please specify one with --output/-o flag.") + os.Exit(1) + } else { + fmt.Println(string(reportOutput.Content)) + } } } diff --git a/cmd/flag.go b/cmd/flag.go index f0f76ae..9cbc803 100644 --- a/cmd/flag.go +++ b/cmd/flag.go @@ -18,7 +18,7 @@ func setupFlags(defaultConfigDir, defaultDataDir string) { rootCmd.PersistentFlags().StringVar(&dataDir, "data-dir", defaultDataDir, "Directory containing data files") reportCmd.Flags().StringVarP(&category, "category", "c", "", "Generate report for a specific category") - reportCmd.Flags().StringVarP(&format, "format", "f", "text", "Report format (text, json, or table)") + reportCmd.Flags().StringVarP(&format, "format", "f", "text", "Report format (text, json, table, or pdf)") reportCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file (default: stdout)") reportCmd.Flags().StringVar(&scoringMethod, "scoring-method", "median", "Scoring method to use (median or average)") reportCmd.Flags().StringVar(&thresholdLabels, "threshold-labels", "emoji", "Threshold label format (emoji or text)") diff --git a/go.mod b/go.mod index 935b520..df2ba21 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.2 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/phpdave11/gofpdf v1.4.3 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 65f8c12..adb1c83 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,24 @@ +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= +github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= +github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/report.go b/report.go index 522ac24..8ea810b 100644 --- a/report.go +++ b/report.go @@ -9,6 +9,8 @@ import ( "strings" "text/tabwriter" "time" + + "github.com/phpdave11/gofpdf" ) // JSON report types @@ -81,43 +83,74 @@ const ( TextFormat ReportFormat = "text" JSONFormat ReportFormat = "json" TableFormat ReportFormat = "table" + PDFFormat ReportFormat = "pdf" ) +// ReportOutput represents the output of a report generation +type ReportOutput struct { + Content []byte + ContentType string // "text" or "binary" +} + // GenerateOverallReport generates an overall security posture report -func (r *ReportGenerator) GenerateOverallReport(format ReportFormat) (string, error) { +func (r *ReportGenerator) GenerateOverallReport(format ReportFormat) (*ReportOutput, error) { overallScore, err := r.scoreCalculator.CalculateOverallScore() if err != nil { - return "", err + return nil, err } switch format { case TextFormat: - return r.formatOverallReportAsText(overallScore), nil + content := r.formatOverallReportAsText(overallScore) + return &ReportOutput{Content: []byte(content), ContentType: "text"}, nil case JSONFormat: - return r.formatOverallReportAsJSON(overallScore) + content, err := r.formatOverallReportAsJSON(overallScore) + if err != nil { + return nil, err + } + return &ReportOutput{Content: []byte(content), ContentType: "text"}, nil case TableFormat: - return r.formatOverallReportAsTable(overallScore), nil + content := r.formatOverallReportAsTable(overallScore) + return &ReportOutput{Content: []byte(content), ContentType: "text"}, nil + case PDFFormat: + content, err := r.formatOverallReportAsPDF(overallScore) + if err != nil { + return nil, err + } + return &ReportOutput{Content: content, ContentType: "binary"}, nil default: - return "", fmt.Errorf("unsupported report format: %s", format) + return nil, fmt.Errorf("unsupported report format: %s", format) } } // GenerateCategoryReport generates a report for a specific category -func (r *ReportGenerator) GenerateCategoryReport(categoryID string, format ReportFormat) (string, error) { +func (r *ReportGenerator) GenerateCategoryReport(categoryID string, format ReportFormat) (*ReportOutput, error) { categoryScore, err := r.scoreCalculator.CalculateCategoryScore(categoryID) if err != nil { - return "", err + return nil, err } switch format { case TextFormat: - return r.formatCategoryReportAsText(categoryScore), nil + content := r.formatCategoryReportAsText(categoryScore) + return &ReportOutput{Content: []byte(content), ContentType: "text"}, nil case JSONFormat: - return r.formatCategoryReportAsJSON(categoryScore) + content, err := r.formatCategoryReportAsJSON(categoryScore) + if err != nil { + return nil, err + } + return &ReportOutput{Content: []byte(content), ContentType: "text"}, nil case TableFormat: - return r.formatCategoryReportAsTable(categoryScore), nil + content := r.formatCategoryReportAsTable(categoryScore) + return &ReportOutput{Content: []byte(content), ContentType: "text"}, nil + case PDFFormat: + content, err := r.formatCategoryReportAsPDF(categoryScore) + if err != nil { + return nil, err + } + return &ReportOutput{Content: content, ContentType: "binary"}, nil default: - return "", fmt.Errorf("unsupported report format: %s", format) + return nil, fmt.Errorf("unsupported report format: %s", format) } } @@ -521,3 +554,369 @@ func (r *ReportGenerator) formatCategoryReportAsTable(score *CategoryScore) stri w.Flush() return buf.String() } + +// formatOverallReportAsPDF formats the overall report as a PDF +func (r *ReportGenerator) formatOverallReportAsPDF(score *OverallScore) ([]byte, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.AddPage() + + // Set up fonts + pdf.SetFont("Arial", "B", 16) + + // Title + pdf.CellFormat(190, 10, "SECURITY POSTURE REPORT", "", 1, "C", false, 0, "") + pdf.Ln(15) + + // Summary + pdf.SetFont("Arial", "", 12) + pdf.CellFormat(40, 10, "KPI Score:", "", 0, "", false, 0, "") + pdf.CellFormat(20, 10, fmt.Sprintf("%d", score.KPIScore), "", 0, "", false, 0, "") + r.formatPDFStatus(pdf, score.KPIStatus) + pdf.Ln(10) + + pdf.CellFormat(40, 10, "KRI Score:", "", 0, "", false, 0, "") + pdf.CellFormat(20, 10, fmt.Sprintf("%d", score.KRIScore), "", 0, "", false, 0, "") + r.formatPDFStatus(pdf, score.KRIStatus) + pdf.Ln(10) + + pdf.CellFormat(40, 10, "Report Date:", "", 0, "", false, 0, "") + pdf.CellFormat(60, 10, time.Now().Format("2006-01-02 15:04:05"), "", 0, "", false, 0, "") + pdf.Ln(15) + + // Category scores table + pdf.SetFont("Arial", "B", 12) + pdf.CellFormat(190, 10, "CATEGORY SCORES:", "", 1, "", false, 0, "") + pdf.Ln(10) + + // Table header + pdf.SetFillColor(200, 200, 200) + pdf.SetFont("Arial", "B", 10) + + // Define table dimensions + colWidths := []float64{60, 20, 25, 30, 25, 30} + + pdf.CellFormat(colWidths[0], 10, "Category", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[1], 10, "Weight", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[2], 10, "KPI Score", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[3], 10, "KPI Status", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[4], 10, "KRI Score", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[5], 10, "KRI Status", "1", 1, "C", true, 0, "") + + // Table rows + pdf.SetFont("Arial", "", 10) + for _, category := range score.Categories { + // Get the weight for this category + weight, exists := r.scoreCalculator.metricsProcessor.leversConfig.Weights.Categories[category.ID] + if !exists { + // Use equal weights if not specified + weight = 1.0 / float64(len(score.Categories)) + } + + // Format weight as percentage + weightPercentage := int(weight * 100) + + // Draw the row cells + pdf.CellFormat(colWidths[0], 10, sanitizeString(category.Name), "1", 0, "L", false, 0, "") + pdf.CellFormat(colWidths[1], 10, fmt.Sprintf("%d%%", weightPercentage), "1", 0, "C", false, 0, "") + pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%d", category.KPIScore), "1", 0, "C", false, 0, "") + + // KPI Status + statusText := "" + switch category.KPIStatus { + case Green: + pdf.SetTextColor(0, 128, 0) // Dark green + statusText = "GREEN" + case Yellow: + pdf.SetTextColor(255, 165, 0) // Orange + statusText = "YELLOW" + case Red: + pdf.SetTextColor(255, 0, 0) // Red + statusText = "RED" + default: + pdf.SetTextColor(128, 128, 128) // Gray + statusText = "UNKNOWN" + } + pdf.CellFormat(colWidths[3], 10, statusText, "1", 0, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + + // KRI Score and Status + pdf.CellFormat(colWidths[4], 10, fmt.Sprintf("%d", category.KRIScore), "1", 0, "C", false, 0, "") + + // KRI Status + statusText = "" + switch category.KRIStatus { + case Green: + pdf.SetTextColor(0, 128, 0) // Dark green + statusText = "GREEN" + case Yellow: + pdf.SetTextColor(255, 165, 0) // Orange + statusText = "YELLOW" + case Red: + pdf.SetTextColor(255, 0, 0) // Red + statusText = "RED" + default: + pdf.SetTextColor(128, 128, 128) // Gray + statusText = "UNKNOWN" + } + pdf.CellFormat(colWidths[5], 10, statusText, "1", 1, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + } + + pdf.Ln(15) + + // Detailed metrics table + pdf.SetFont("Arial", "B", 12) + pdf.CellFormat(190, 10, "DETAILED METRICS:", "", 1, "", false, 0, "") + pdf.Ln(10) + + // Table header + pdf.SetFillColor(200, 200, 200) + pdf.SetFont("Arial", "B", 10) + + // Define table dimensions for detailed metrics + detailColWidths := []float64{60, 30, 30, 30, 40} + + pdf.CellFormat(detailColWidths[0], 10, "Category", "1", 0, "C", true, 0, "") + pdf.CellFormat(detailColWidths[1], 10, "Metric Type", "1", 0, "C", true, 0, "") + pdf.CellFormat(detailColWidths[2], 10, "Metric ID", "1", 0, "C", true, 0, "") + pdf.CellFormat(detailColWidths[3], 10, "Score", "1", 0, "C", true, 0, "") + pdf.CellFormat(detailColWidths[4], 10, "Status", "1", 1, "C", true, 0, "") + + // Table rows + pdf.SetFont("Arial", "", 10) + for _, category := range score.Categories { + for _, metric := range category.Metrics { + parts := strings.Split(metric.Reference, ".") + if len(parts) == 3 { + metricType := parts[1] + metricID := parts[2] + + // Draw the row cells + pdf.CellFormat(detailColWidths[0], 10, sanitizeString(category.Name), "1", 0, "L", false, 0, "") + pdf.CellFormat(detailColWidths[1], 10, sanitizeString(metricType), "1", 0, "C", false, 0, "") + pdf.CellFormat(detailColWidths[2], 10, sanitizeString(metricID), "1", 0, "C", false, 0, "") + pdf.CellFormat(detailColWidths[3], 10, fmt.Sprintf("%d", metric.Score), "1", 0, "C", false, 0, "") + + // Status + statusText := "" + switch metric.Status { + case Green: + pdf.SetTextColor(0, 128, 0) // Dark green + statusText = "GREEN" + case Yellow: + pdf.SetTextColor(255, 165, 0) // Orange + statusText = "YELLOW" + case Red: + pdf.SetTextColor(255, 0, 0) // Red + statusText = "RED" + default: + pdf.SetTextColor(128, 128, 128) // Gray + statusText = "UNKNOWN" + } + pdf.CellFormat(detailColWidths[4], 10, statusText, "1", 1, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + } + } + } + + // Add page numbers + pdf.SetFooterFunc(func() { + pdf.SetY(-15) + pdf.SetFont("Arial", "I", 8) + pdf.CellFormat(0, 10, fmt.Sprintf("Page %d", pdf.PageNo()), "0", 0, "C", false, 0, "") + }) + + // Output the PDF as bytes + var buf bytes.Buffer + err := pdf.Output(&buf) + if err != nil { + return nil, fmt.Errorf("failed to generate PDF: %w", err) + } + + return buf.Bytes(), nil +} + +// formatCategoryReportAsPDF formats a category report as a PDF +func (r *ReportGenerator) formatCategoryReportAsPDF(score *CategoryScore) ([]byte, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.AddPage() + + // Get the weight for this category + weight, exists := r.scoreCalculator.metricsProcessor.leversConfig.Weights.Categories[score.ID] + if !exists { + // Use equal weights if not specified + totalCategories := len(r.scoreCalculator.metricsProcessor.GetAllCategories()) + if totalCategories > 0 { + weight = 1.0 / float64(totalCategories) + } else { + weight = 1.0 + } + } + + // Format weight as percentage + weightPercentage := int(weight * 100) + + // Set up fonts + pdf.SetFont("Arial", "B", 16) + + // Title + pdf.CellFormat(190, 10, fmt.Sprintf("%s REPORT (WEIGHT: %d%%)", strings.ToUpper(sanitizeString(score.Name)), weightPercentage), "", 1, "", false, 0, "") + pdf.Ln(15) + + // Summary + pdf.SetFont("Arial", "", 12) + pdf.CellFormat(40, 10, "KPI Score:", "", 0, "", false, 0, "") + pdf.CellFormat(20, 10, fmt.Sprintf("%d", score.KPIScore), "", 0, "", false, 0, "") + r.formatPDFStatus(pdf, score.KPIStatus) + pdf.Ln(10) + + pdf.CellFormat(40, 10, "KRI Score:", "", 0, "", false, 0, "") + pdf.CellFormat(20, 10, fmt.Sprintf("%d", score.KRIScore), "", 0, "", false, 0, "") + r.formatPDFStatus(pdf, score.KRIStatus) + pdf.Ln(10) + + pdf.CellFormat(40, 10, "Report Date:", "", 0, "", false, 0, "") + pdf.CellFormat(60, 10, time.Now().Format("2006-01-02 15:04:05"), "", 0, "", false, 0, "") + pdf.Ln(15) + + // Group metrics by type + var kpiMetrics []MetricScore + var kriMetrics []MetricScore + + for _, metric := range score.Metrics { + parts := strings.Split(metric.Reference, ".") + if len(parts) == 3 { + metricType := parts[1] + if metricType == "KPI" { + kpiMetrics = append(kpiMetrics, metric) + } else if metricType == "KRI" { + kriMetrics = append(kriMetrics, metric) + } + } + } + + // Metrics table + pdf.SetFont("Arial", "B", 12) + pdf.CellFormat(190, 10, "METRICS:", "", 1, "", false, 0, "") + pdf.Ln(10) + + // Table header + pdf.SetFillColor(200, 200, 200) + pdf.SetFont("Arial", "B", 10) + + // Define table dimensions + colWidths := []float64{30, 60, 30, 70} + + pdf.CellFormat(colWidths[0], 10, "Type", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[1], 10, "ID", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[2], 10, "Score", "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[3], 10, "Status", "1", 1, "C", true, 0, "") + + // Table rows for KPIs + pdf.SetFont("Arial", "", 10) + for _, metric := range kpiMetrics { + parts := strings.Split(metric.Reference, ".") + if len(parts) == 3 { + metricID := parts[2] + + // Draw the row cells + pdf.CellFormat(colWidths[0], 10, "KPI", "1", 0, "C", false, 0, "") + pdf.CellFormat(colWidths[1], 10, sanitizeString(metricID), "1", 0, "L", false, 0, "") + pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%d", metric.Score), "1", 0, "C", false, 0, "") + + // Status + statusText := "" + switch metric.Status { + case Green: + pdf.SetTextColor(0, 128, 0) // Dark green + statusText = "GREEN" + case Yellow: + pdf.SetTextColor(255, 165, 0) // Orange + statusText = "YELLOW" + case Red: + pdf.SetTextColor(255, 0, 0) // Red + statusText = "RED" + default: + pdf.SetTextColor(128, 128, 128) // Gray + statusText = "UNKNOWN" + } + pdf.CellFormat(colWidths[3], 10, statusText, "1", 1, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + } + } + + // Table rows for KRIs + for _, metric := range kriMetrics { + parts := strings.Split(metric.Reference, ".") + if len(parts) == 3 { + metricID := parts[2] + + // Draw the row cells + pdf.CellFormat(colWidths[0], 10, "KRI", "1", 0, "C", false, 0, "") + pdf.CellFormat(colWidths[1], 10, sanitizeString(metricID), "1", 0, "L", false, 0, "") + pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%d", metric.Score), "1", 0, "C", false, 0, "") + + // Status + statusText := "" + switch metric.Status { + case Green: + pdf.SetTextColor(0, 128, 0) // Dark green + statusText = "GREEN" + case Yellow: + pdf.SetTextColor(255, 165, 0) // Orange + statusText = "YELLOW" + case Red: + pdf.SetTextColor(255, 0, 0) // Red + statusText = "RED" + default: + pdf.SetTextColor(128, 128, 128) // Gray + statusText = "UNKNOWN" + } + pdf.CellFormat(colWidths[3], 10, statusText, "1", 1, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + } + } + + // Add page numbers + pdf.SetFooterFunc(func() { + pdf.SetY(-15) + pdf.SetFont("Arial", "I", 8) + pdf.CellFormat(0, 10, fmt.Sprintf("Page %d", pdf.PageNo()), "0", 0, "C", false, 0, "") + }) + + // Output the PDF as bytes + var buf bytes.Buffer + err := pdf.Output(&buf) + if err != nil { + return nil, fmt.Errorf("failed to generate PDF: %w", err) + } + + return buf.Bytes(), nil +} + +// formatPDFStatus formats a traffic light status for display in PDF +func (r *ReportGenerator) formatPDFStatus(pdf *gofpdf.Fpdf, status TrafficLightStatus) string { + switch status { + case Green: + pdf.SetTextColor(0, 128, 0) // Dark green + // Always use text labels for PDF to avoid encoding issues + pdf.CellFormat(30, 10, "GREEN", "", 0, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + return "" + case Yellow: + pdf.SetTextColor(255, 165, 0) // Orange + pdf.CellFormat(30, 10, "YELLOW", "", 0, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + return "" + case Red: + pdf.SetTextColor(255, 0, 0) // Red + pdf.CellFormat(30, 10, "RED", "", 0, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + return "" + default: + pdf.SetTextColor(128, 128, 128) // Gray + pdf.CellFormat(30, 10, "UNKNOWN", "", 0, "C", false, 0, "") + pdf.SetTextColor(0, 0, 0) // Reset to black + return "" + } +} diff --git a/report_test.go b/report_test.go index 47ef529..26341e8 100644 --- a/report_test.go +++ b/report_test.go @@ -126,11 +126,14 @@ func TestReportGenerator(t *testing.T) { generator := NewReportGenerator(calculator, TextLabels) // Test GenerateOverallReport with TextFormat - textReport, err := generator.GenerateOverallReport(TextFormat) + textReportOutput, err := generator.GenerateOverallReport(TextFormat) if err != nil { t.Fatalf("Failed to generate overall text report: %v", err) } + // Convert the report content to string + textReport := string(textReportOutput.Content) + // Check if the text report contains expected content expectedTextContent := []string{ "SECURITY POSTURE REPORT", @@ -150,14 +153,14 @@ func TestReportGenerator(t *testing.T) { } // Test GenerateOverallReport with JSONFormat - jsonReport, err := generator.GenerateOverallReport(JSONFormat) + jsonReportOutput, err := generator.GenerateOverallReport(JSONFormat) if err != nil { t.Fatalf("Failed to generate overall JSON report: %v", err) } // Parse the JSON report var jsonData map[string]interface{} - if err := json.Unmarshal([]byte(jsonReport), &jsonData); err != nil { + if err := json.Unmarshal(jsonReportOutput.Content, &jsonData); err != nil { t.Fatalf("Failed to parse JSON report: %v", err) } @@ -182,11 +185,14 @@ func TestReportGenerator(t *testing.T) { } // Test GenerateCategoryReport with TextFormat - categoryTextReport, err := generator.GenerateCategoryReport("test_cat", TextFormat) + categoryTextReportOutput, err := generator.GenerateCategoryReport("test_cat", TextFormat) if err != nil { t.Fatalf("Failed to generate category text report: %v", err) } + // Convert the report content to string + categoryTextReport := string(categoryTextReportOutput.Content) + // Check if the category text report contains expected content expectedCategoryTextContent := []string{ "TEST CATEGORY REPORT", @@ -203,14 +209,14 @@ func TestReportGenerator(t *testing.T) { } // Test GenerateCategoryReport with JSONFormat - categoryJsonReport, err := generator.GenerateCategoryReport("test_cat", JSONFormat) + categoryJsonReportOutput, err := generator.GenerateCategoryReport("test_cat", JSONFormat) if err != nil { t.Fatalf("Failed to generate category JSON report: %v", err) } // Parse the category JSON report var categoryJsonData map[string]interface{} - if err := json.Unmarshal([]byte(categoryJsonReport), &categoryJsonData); err != nil { + if err := json.Unmarshal(categoryJsonReportOutput.Content, &categoryJsonData); err != nil { t.Fatalf("Failed to parse category JSON report: %v", err) } @@ -277,3 +283,165 @@ func TestReportGenerator(t *testing.T) { t.Errorf("Expected emojiGenerator.formatStatus('unknown') to be '❓', got '%s'", emojiGenerator.formatStatus("unknown")) } } + +// TestPDFReport tests the PDF report generation +func TestPDFReport(t *testing.T) { + // Create test data + metricsConfig := &MetricsConfig{ + Categories: []Category{ + { + ID: "test_cat", + Name: "Test Category", + Description: "Test category description", + KPIs: []KPI{ + { + ID: "test_kpi", + Name: "Test KPI", + Description: "Test KPI description", + Unit: "count", + ScoringBands: []ScoringBand{ + {Max: FloatPtr(5), Score: 95}, + {Min: FloatPtr(5), Max: FloatPtr(10), Score: 85}, + {Min: FloatPtr(10), Max: FloatPtr(15), Score: 75}, + {Min: FloatPtr(15), Max: FloatPtr(20), Score: 65}, + {Min: FloatPtr(20), Score: 30}, + }, + }, + }, + KRIs: []KRI{ + { + ID: "test_kri", + Name: "Test KRI", + Description: "Test KRI description", + Unit: "count", + ScoringBands: []ScoringBand{ + {Max: FloatPtr(0), Score: 95}, + {Min: FloatPtr(0), Max: FloatPtr(2), Score: 85}, + {Min: FloatPtr(2), Max: FloatPtr(5), Score: 75}, + {Min: FloatPtr(5), Max: FloatPtr(10), Score: 65}, + {Min: FloatPtr(10), Score: 30}, + }, + }, + }, + }, + }, + } + + leversConfig := &LeversConfig{ + Global: Global{ + Thresholds: Thresholds{ + Green: ThresholdRange{ + Min: 80, + Max: 100, + }, + Yellow: ThresholdRange{ + Min: 60, + Max: 79, + }, + Red: ThresholdRange{ + Min: 0, + Max: 59, + }, + }, + KPIThresholds: Thresholds{ + Green: ThresholdRange{ + Min: 85, + Max: 100, + }, + Yellow: ThresholdRange{ + Min: 65, + Max: 84, + }, + Red: ThresholdRange{ + Min: 0, + Max: 64, + }, + }, + KRIThresholds: Thresholds{ + Green: ThresholdRange{ + Min: 75, + Max: 100, + }, + Yellow: ThresholdRange{ + Min: 55, + Max: 74, + }, + Red: ThresholdRange{ + Min: 0, + Max: 54, + }, + }, + }, + Weights: Weights{ + Categories: CategoryWeights{ + "test_cat": 1.0, + }, + }, + } + + metricsData := &MetricsData{ + Metrics: []Metric{ + { + Reference: "test_cat.KPI.test_kpi", + Value: 3, + Timestamp: time.Now(), + }, + { + Reference: "test_cat.KRI.test_kri", + Value: 4, + Timestamp: time.Now(), + }, + }, + } + + // Create a MetricsProcessor + processor := NewMetricsProcessor(metricsConfig, leversConfig, metricsData) + + // Create a ScoreCalculator with median scoring (default) + calculator := NewScoreCalculator(processor, MedianScoring) + + // Create a ReportGenerator with text labels for testing + generator := NewReportGenerator(calculator, TextLabels) + + // Test GenerateOverallReport with PDFFormat + pdfReport, err := generator.GenerateOverallReport(PDFFormat) + if err != nil { + t.Fatalf("Failed to generate overall PDF report: %v", err) + } + + // Check if the PDF report is not empty + if len(pdfReport.Content) == 0 { + t.Error("Expected PDF report content to be non-empty") + } + + // Check if the ContentType is set to "binary" + if pdfReport.ContentType != "binary" { + t.Errorf("Expected ContentType to be 'binary', got '%s'", pdfReport.ContentType) + } + + // Check if the content starts with the PDF header + if len(pdfReport.Content) < 4 || string(pdfReport.Content[:4]) != "%PDF" { + t.Error("Expected PDF content to start with '%PDF'") + } + + // Test GenerateCategoryReport with PDFFormat + categoryPDFReport, err := generator.GenerateCategoryReport("test_cat", PDFFormat) + if err != nil { + t.Fatalf("Failed to generate category PDF report: %v", err) + } + + // Check if the PDF report is not empty + if len(categoryPDFReport.Content) == 0 { + t.Error("Expected category PDF report content to be non-empty") + } + + // Check if the ContentType is set to "binary" + if categoryPDFReport.ContentType != "binary" { + t.Errorf("Expected ContentType to be 'binary', got '%s'", categoryPDFReport.ContentType) + } + + // Check if the content starts with the PDF header + if len(categoryPDFReport.Content) < 4 || string(categoryPDFReport.Content[:4]) != "%PDF" { + t.Error("Expected PDF content to start with '%PDF'") + } +}