diff --git a/internal/cmd/audit.go b/internal/cmd/audit.go index e5c3da40..40a6c3f0 100644 --- a/internal/cmd/audit.go +++ b/internal/cmd/audit.go @@ -22,6 +22,11 @@ const ( AuditModeFull AuditMode = 2 ) +const ( + statusPassed = "passed" + statusFailed = "failed" +) + // Enable audit mode enum // String is used both by fmt.Print and by Cobra in help text func (e *AuditMode) String() string { @@ -56,15 +61,48 @@ func (e *AuditMode) Type() string { type auditOpts struct { branchOptions verifierOptions + outputOptions auditDepth int endingCommit string auditMode AuditMode } +// AuditCommitResultJSON represents a single commit audit result in JSON format +type AuditCommitResultJSON struct { + Commit string `json:"commit"` + Status string `json:"status"` + VerifiedLevels []string `json:"verified_levels,omitempty"` + PrevCommitMatches *bool `json:"prev_commit_matches,omitempty"` + ProvControls interface{} `json:"prov_controls,omitempty"` + GhControls interface{} `json:"gh_controls,omitempty"` + PrevCommit string `json:"prev_commit,omitempty"` + GhPriorCommit string `json:"gh_prior_commit,omitempty"` + Link string `json:"link,omitempty"` + Error string `json:"error,omitempty"` +} + +// AuditResultJSON represents the full audit result in JSON format +type AuditResultJSON struct { + Owner string `json:"owner"` + Repository string `json:"repository"` + Branch string `json:"branch"` + LatestCommit string `json:"latest_commit"` + CommitResults []AuditCommitResultJSON `json:"commit_results"` + Summary *AuditSummary `json:"summary,omitempty"` +} + +// AuditSummary provides summary statistics for the audit +type AuditSummary struct { + TotalCommits int `json:"total_commits"` + PassedCommits int `json:"passed_commits"` + FailedCommits int `json:"failed_commits"` +} + func (ao *auditOpts) Validate() error { errs := []error{ ao.branchOptions.Validate(), ao.verifierOptions.Validate(), + ao.outputOptions.Validate(), } return errors.Join(errs...) } @@ -72,6 +110,7 @@ func (ao *auditOpts) Validate() error { func (ao *auditOpts) AddFlags(cmd *cobra.Command) { ao.branchOptions.AddFlags(cmd) ao.verifierOptions.AddFlags(cmd) + ao.outputOptions.AddFlags(cmd) cmd.PersistentFlags().IntVar(&ao.auditDepth, "depth", 0, "The max number of revisions to audit (depth <= audit all revisions).") cmd.PersistentFlags().StringVar(&ao.endingCommit, "ending-commit", "", "The commit to stop auditing at.") ao.auditMode = AuditModeBasic @@ -123,9 +162,9 @@ Future: func printResult(ghc *ghcontrol.GitHubConnection, ar *audit.AuditCommitResult, mode AuditMode) { good := ar.IsGood() - status := "passed" + status := statusPassed if !good { - status = "failed" + status = statusFailed } fmt.Printf("commit: %s - %v\n", ar.Commit, status) @@ -156,6 +195,41 @@ func printResult(ghc *ghcontrol.GitHubConnection, ar *audit.AuditCommitResult, m fmt.Printf("\tlink: https://github.com/%s/%s/commit/%s\n", ghc.Owner(), ghc.Repo(), ar.GhPriorCommit) } +func convertAuditResultToJSON(ghc *ghcontrol.GitHubConnection, ar *audit.AuditCommitResult, mode AuditMode) AuditCommitResultJSON { + good := ar.IsGood() + status := statusPassed + if !good { + status = statusFailed + } + + result := AuditCommitResultJSON{ + Commit: ar.Commit, + Status: status, + Link: fmt.Sprintf("https://github.com/%s/%s/commit/%s", ghc.Owner(), ghc.Repo(), ar.GhPriorCommit), + } + + // Only include details if mode is Full or status is failed + if mode == AuditModeFull || !good { + if ar.VsaPred != nil { + result.VerifiedLevels = ar.VsaPred.GetVerifiedLevels() + } + + if ar.ProvPred != nil { + result.ProvControls = ar.ProvPred.GetControls() + result.PrevCommit = ar.ProvPred.GetPrevCommit() + result.GhPriorCommit = ar.GhPriorCommit + matches := ar.ProvPred.GetPrevCommit() == ar.GhPriorCommit + result.PrevCommitMatches = &matches + } + + if ar.GhControlStatus != nil { + result.GhControls = ar.GhControlStatus.Controls + } + } + + return result +} + func doAudit(auditArgs *auditOpts) error { ghc := ghcontrol.NewGhConnection(auditArgs.owner, auditArgs.repository, ghcontrol.BranchToFullRef(auditArgs.branch)).WithAuthToken(githubToken) ctx := context.Background() @@ -169,27 +243,76 @@ func doAudit(auditArgs *auditOpts) error { return fmt.Errorf("could not get latest commit for %s", auditArgs.branch) } - fmt.Printf("Auditing branch %s starting from revision %s\n", auditArgs.branch, latestCommit) + // Initialize JSON result structure if needed + var jsonResult *AuditResultJSON + if auditArgs.outputFormatIsJSON() { + jsonResult = &AuditResultJSON{ + Owner: auditArgs.owner, + Repository: auditArgs.repository, + Branch: auditArgs.branch, + LatestCommit: latestCommit, + CommitResults: []AuditCommitResultJSON{}, + } + } else { + // Print header for text output + auditArgs.writeTextf("Auditing branch %s starting from revision %s\n", auditArgs.branch, latestCommit) + } + // Single loop for both JSON and text output count := 0 + passed := 0 + failed := 0 + for ar, err := range auditor.AuditBranch(ctx, auditArgs.branch) { if ar == nil { return err } - if err != nil { - fmt.Printf("\terror: %v\n", err) + + // Process result based on output format + if auditArgs.outputFormatIsJSON() { + commitResult := convertAuditResultToJSON(ghc, ar, auditArgs.auditMode) + if err != nil { + commitResult.Error = err.Error() + } + if commitResult.Status == statusPassed { + passed++ + } else { + failed++ + } + jsonResult.CommitResults = append(jsonResult.CommitResults, commitResult) + } else { + // Text output + if err != nil { + auditArgs.writeTextf("\terror: %v\n", err) + } + printResult(ghc, ar, auditArgs.auditMode) } - printResult(ghc, ar, auditArgs.auditMode) + + // Check for early termination conditions if auditArgs.endingCommit != "" && auditArgs.endingCommit == ar.Commit { - fmt.Printf("Found ending commit %s\n", auditArgs.endingCommit) - return nil + if !auditArgs.outputFormatIsJSON() { + auditArgs.writeTextf("Found ending commit %s\n", auditArgs.endingCommit) + } + break } if auditArgs.auditDepth > 0 && count >= auditArgs.auditDepth { - fmt.Printf("Reached depth limit %d\n", auditArgs.auditDepth) - return nil + if !auditArgs.outputFormatIsJSON() { + auditArgs.writeTextf("Reached depth limit %d\n", auditArgs.auditDepth) + } + break } count++ } + // Write JSON output if needed + if auditArgs.outputFormatIsJSON() { + jsonResult.Summary = &AuditSummary{ + TotalCommits: len(jsonResult.CommitResults), + PassedCommits: passed, + FailedCommits: failed, + } + return auditArgs.writeJSON(jsonResult) + } + return nil } diff --git a/internal/cmd/audit_test.go b/internal/cmd/audit_test.go new file mode 100644 index 00000000..d0c9a81c --- /dev/null +++ b/internal/cmd/audit_test.go @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: Copyright 2025 The SLSA Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "reflect" + "testing" + + vpb "github.com/in-toto/attestation/go/predicates/vsa/v1" + + "github.com/slsa-framework/source-tool/pkg/audit" + "github.com/slsa-framework/source-tool/pkg/ghcontrol" + "github.com/slsa-framework/source-tool/pkg/provenance" + "github.com/slsa-framework/source-tool/pkg/slsa" +) + +// assertJSONEqual compares two JSON values semantically (ignoring field order and formatting) +func assertJSONEqual(t *testing.T, got, want interface{}) { + t.Helper() + + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("failed to marshal got: %v", err) + } + + wantJSON, err := json.Marshal(want) + if err != nil { + t.Fatalf("failed to marshal want: %v", err) + } + + var gotData, wantData interface{} + if err := json.Unmarshal(gotJSON, &gotData); err != nil { + t.Fatalf("failed to unmarshal got JSON: %v", err) + } + if err := json.Unmarshal(wantJSON, &wantData); err != nil { + t.Fatalf("failed to unmarshal want JSON: %v", err) + } + + if !reflect.DeepEqual(gotData, wantData) { + t.Errorf("JSON mismatch:\ngot: %s\nwant: %s", string(gotJSON), string(wantJSON)) + } +} + +func TestAuditResultJSON_JSONMarshaling(t *testing.T) { + tests := []struct { + name string + result AuditResultJSON + want AuditResultJSON + }{ + { + name: "complete audit result", + result: AuditResultJSON{ + Owner: "test-owner", + Repository: "test-repo", + Branch: "main", + LatestCommit: "abc123", + CommitResults: []AuditCommitResultJSON{ + { + Commit: "abc123", + Status: "passed", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + Link: "https://github.com/test-owner/test-repo/commit/abc123", + }, + }, + Summary: &AuditSummary{ + TotalCommits: 1, + PassedCommits: 1, + FailedCommits: 0, + }, + }, + want: AuditResultJSON{ + Owner: "test-owner", + Repository: "test-repo", + Branch: "main", + LatestCommit: "abc123", + CommitResults: []AuditCommitResultJSON{ + { + Commit: "abc123", + Status: "passed", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + Link: "https://github.com/test-owner/test-repo/commit/abc123", + }, + }, + Summary: &AuditSummary{ + TotalCommits: 1, + PassedCommits: 1, + FailedCommits: 0, + }, + }, + }, + { + name: "audit with failed commit", + result: AuditResultJSON{ + Owner: "test-owner", + Repository: "test-repo", + Branch: "main", + LatestCommit: "def456", + CommitResults: []AuditCommitResultJSON{ + { + Commit: "def456", + Status: "failed", + Link: "https://github.com/test-owner/test-repo/commit/def456", + }, + }, + Summary: &AuditSummary{ + TotalCommits: 1, + PassedCommits: 0, + FailedCommits: 1, + }, + }, + want: AuditResultJSON{ + Owner: "test-owner", + Repository: "test-repo", + Branch: "main", + LatestCommit: "def456", + CommitResults: []AuditCommitResultJSON{ + { + Commit: "def456", + Status: "failed", + Link: "https://github.com/test-owner/test-repo/commit/def456", + }, + }, + Summary: &AuditSummary{ + TotalCommits: 1, + PassedCommits: 0, + FailedCommits: 1, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use semantic JSON comparison instead of string comparison + assertJSONEqual(t, tt.result, tt.want) + }) + } +} + +func TestConvertAuditResultToJSON(t *testing.T) { + ghc := ghcontrol.NewGhConnection("test-owner", "test-repo", "refs/heads/main") + + tests := []struct { + name string + result *audit.AuditCommitResult + mode AuditMode + want AuditCommitResultJSON + }{ + { + name: "passed audit in basic mode", + result: &audit.AuditCommitResult{ + Commit: "abc123", + VsaPred: &vpb.VerificationSummary{ + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + }, + ProvPred: &provenance.SourceProvenancePred{ + PrevCommit: "def456", + }, + GhPriorCommit: "def456", + }, + mode: AuditModeBasic, + want: AuditCommitResultJSON{ + Commit: "abc123", + Status: "passed", + Link: "https://github.com/test-owner/test-repo/commit/def456", + }, + }, + { + name: "passed audit in full mode", + result: &audit.AuditCommitResult{ + Commit: "abc123", + VsaPred: &vpb.VerificationSummary{ + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + }, + ProvPred: &provenance.SourceProvenancePred{ + PrevCommit: "def456", + Controls: []*provenance.Control{{Name: "test_control"}}, + }, + GhPriorCommit: "def456", + GhControlStatus: &ghcontrol.GhControlStatus{ + Controls: slsa.Controls{}, + }, + }, + mode: AuditModeFull, + want: func() AuditCommitResultJSON { + matches := true + return AuditCommitResultJSON{ + Commit: "abc123", + Status: "passed", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + PrevCommitMatches: &matches, + ProvControls: []*provenance.Control{{Name: "test_control"}}, + GhControls: slsa.Controls{}, + PrevCommit: "def456", + GhPriorCommit: "def456", + Link: "https://github.com/test-owner/test-repo/commit/def456", + } + }(), + }, + { + name: "failed audit shows details even in basic mode", + result: &audit.AuditCommitResult{ + Commit: "abc123", + VsaPred: nil, + ProvPred: nil, + GhPriorCommit: "def456", + }, + mode: AuditModeBasic, + want: AuditCommitResultJSON{ + Commit: "abc123", + Status: "failed", + Link: "https://github.com/test-owner/test-repo/commit/def456", + }, + }, + { + name: "failed audit with mismatched commits", + result: &audit.AuditCommitResult{ + Commit: "abc123", + VsaPred: &vpb.VerificationSummary{ + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + }, + ProvPred: &provenance.SourceProvenancePred{ + PrevCommit: "wrong123", + Controls: []*provenance.Control{{Name: "test_control"}}, + }, + GhPriorCommit: "def456", + }, + mode: AuditModeFull, + want: func() AuditCommitResultJSON { + matches := false + return AuditCommitResultJSON{ + Commit: "abc123", + Status: "failed", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + PrevCommitMatches: &matches, + ProvControls: []*provenance.Control{{Name: "test_control"}}, + PrevCommit: "wrong123", + GhPriorCommit: "def456", + Link: "https://github.com/test-owner/test-repo/commit/def456", + } + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertAuditResultToJSON(ghc, tt.result, tt.mode) + + if got.Commit != tt.want.Commit { + t.Errorf("Commit = %v, want %v", got.Commit, tt.want.Commit) + } + if got.Status != tt.want.Status { + t.Errorf("Status = %v, want %v", got.Status, tt.want.Status) + } + if got.Link != tt.want.Link { + t.Errorf("Link = %v, want %v", got.Link, tt.want.Link) + } + + // Check verified levels + if len(got.VerifiedLevels) != len(tt.want.VerifiedLevels) { + t.Errorf("VerifiedLevels length = %v, want %v", len(got.VerifiedLevels), len(tt.want.VerifiedLevels)) + } + + // Check PrevCommitMatches pointer + if (got.PrevCommitMatches == nil) != (tt.want.PrevCommitMatches == nil) { + t.Errorf("PrevCommitMatches nil mismatch: got nil=%v, want nil=%v", + got.PrevCommitMatches == nil, tt.want.PrevCommitMatches == nil) + } else if got.PrevCommitMatches != nil && *got.PrevCommitMatches != *tt.want.PrevCommitMatches { + t.Errorf("PrevCommitMatches = %v, want %v", *got.PrevCommitMatches, *tt.want.PrevCommitMatches) + } + }) + } +} + +func TestAuditMode_String(t *testing.T) { + tests := []struct { + name string + mode AuditMode + want string + }{ + { + name: "basic mode", + mode: AuditModeBasic, + want: "basic", + }, + { + name: "full mode", + mode: AuditModeFull, + want: "full", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.mode.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAuditMode_Set(t *testing.T) { + tests := []struct { + name string + value string + want AuditMode + wantErr bool + }{ + { + name: "set to basic", + value: "basic", + want: AuditModeBasic, + wantErr: false, + }, + { + name: "set to full", + value: "full", + want: AuditModeFull, + wantErr: false, + }, + { + name: "invalid value", + value: "invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mode AuditMode + err := mode.Set(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && mode != tt.want { + t.Errorf("Set() got = %v, want %v", mode, tt.want) + } + }) + } +} diff --git a/internal/cmd/output.go b/internal/cmd/output.go new file mode 100644 index 00000000..bb27ba7d --- /dev/null +++ b/internal/cmd/output.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright 2025 The SLSA Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +const ( + OutputFormatText = "text" + OutputFormatJSON = "json" +) + +// outputOptions provides common output formatting options +type outputOptions struct { + format string +} + +// AddFlags adds output-related flags to the command +func (oo *outputOptions) AddFlags(cmd *cobra.Command) { + oo.format = OutputFormatText + cmd.PersistentFlags().StringVar(&oo.format, "format", OutputFormatText, "Output format: 'text' (default) or 'json'") +} + +// Validate checks that the output format is valid +func (oo *outputOptions) Validate() error { + if oo.format != OutputFormatText && oo.format != OutputFormatJSON { + return fmt.Errorf("output format must be 'text' or 'json', got: %s", oo.format) + } + return nil +} + +// getWriter returns the writer to use for output (currently always os.Stdout) +func (oo *outputOptions) getWriter() io.Writer { + return os.Stdout +} + +func (oo *outputOptions) outputFormatIsJSON() bool { + return oo.format == OutputFormatJSON +} + +func (oo *outputOptions) writeJSON(v interface{}) error { + encoder := json.NewEncoder(oo.getWriter()) + encoder.SetIndent("", " ") + return encoder.Encode(v) +} + +func (oo *outputOptions) writeTextf(format string, a ...interface{}) { + //nolint:errcheck // writeTextf is a convenience method that intentionally ignores errors + fmt.Fprintf(oo.getWriter(), format, a...) +} + +// writeResult writes the result in the appropriate format (JSON or text) +// For text output, it uses the String() method if the value implements fmt.Stringer +func (oo *outputOptions) writeResult(v interface{}) error { + if oo.outputFormatIsJSON() { + return oo.writeJSON(v) + } + + // For text output, use String() method if available + if stringer, ok := v.(fmt.Stringer); ok { + //nolint:errcheck // writeResult is a convenience method that intentionally ignores errors + fmt.Fprint(oo.getWriter(), stringer.String()) + return nil + } + + // Fallback: format with %v + //nolint:errcheck // writeResult is a convenience method that intentionally ignores errors + fmt.Fprintf(oo.getWriter(), "%v\n", v) + return nil +} diff --git a/internal/cmd/verifycommit.go b/internal/cmd/verifycommit.go index 155d1878..b95c40df 100644 --- a/internal/cmd/verifycommit.go +++ b/internal/cmd/verifycommit.go @@ -17,13 +17,35 @@ import ( type verifyCommitOptions struct { commitOptions verifierOptions + outputOptions tag string } +// VerifyCommitResult represents the result of a commit verification +type VerifyCommitResult struct { + Success bool `json:"success"` + Commit string `json:"commit"` + Ref string `json:"ref"` + RefType string `json:"ref_type"` // "branch" or "tag" + Owner string `json:"owner"` + Repository string `json:"repository"` + VerifiedLevels []string `json:"verified_levels,omitempty"` + Message string `json:"message,omitempty"` +} + +// String implements fmt.Stringer for text output +func (v VerifyCommitResult) String() string { + if !v.Success { + return fmt.Sprintf("FAILED: %s\n", v.Message) + } + return fmt.Sprintf("SUCCESS: commit %s on %s verified with %v\n", v.Commit, v.Ref, v.VerifiedLevels) +} + func (vco *verifyCommitOptions) Validate() error { errs := []error{ vco.commitOptions.Validate(), vco.verifierOptions.Validate(), + vco.outputOptions.Validate(), } return errors.Join(errs...) } @@ -31,6 +53,7 @@ func (vco *verifyCommitOptions) Validate() error { func (vco *verifyCommitOptions) AddFlags(cmd *cobra.Command) { vco.commitOptions.AddFlags(cmd) vco.verifierOptions.AddFlags(cmd) + vco.outputOptions.AddFlags(cmd) cmd.PersistentFlags().StringVar( &vco.tag, "tag", "", "The tag within the repository", ) @@ -74,11 +97,17 @@ func addVerifyCommit(cmd *cobra.Command) { func doVerifyCommit(opts *verifyCommitOptions) error { var ref string + var refType string + var refName string switch { case opts.branch != "": ref = ghcontrol.BranchToFullRef(opts.branch) + refType = "branch" + refName = opts.branch case opts.tag != "": ref = ghcontrol.TagToFullRef(opts.tag) + refType = "tag" + refName = opts.tag default: return fmt.Errorf("must specify either branch or tag") } @@ -90,14 +119,24 @@ func doVerifyCommit(opts *verifyCommitOptions) error { if err != nil { return err } + + result := VerifyCommitResult{ + Success: vsaPred != nil, + Commit: opts.commit, + Ref: refName, + RefType: refType, + Owner: opts.owner, + Repository: opts.repository, + } + if vsaPred == nil { - fmt.Printf( - "FAILED: no VSA matching commit '%s' on branch '%s' found in github.com/%s/%s\n", - opts.commit, opts.branch, opts.owner, opts.repository, + result.Message = fmt.Sprintf( + "no VSA matching commit '%s' on %s '%s' found in github.com/%s/%s", + opts.commit, refType, refName, opts.owner, opts.repository, ) - return nil + return opts.writeResult(result) } - fmt.Printf("SUCCESS: commit %s on %s verified with %v\n", opts.commit, opts.branch, vsaPred.GetVerifiedLevels()) - return nil + result.VerifiedLevels = vsaPred.GetVerifiedLevels() + return opts.writeResult(result) } diff --git a/internal/cmd/verifycommit_test.go b/internal/cmd/verifycommit_test.go new file mode 100644 index 00000000..bae0cdc0 --- /dev/null +++ b/internal/cmd/verifycommit_test.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright 2025 The SLSA Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "testing" +) + +func TestVerifyCommitResult_JSONMarshaling(t *testing.T) { + tests := []struct { + name string + result VerifyCommitResult + want VerifyCommitResult + }{ + { + name: "successful verification", + result: VerifyCommitResult{ + Success: true, + Commit: "abc123", + Ref: "main", + RefType: "branch", + Owner: "test-owner", + Repository: "test-repo", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + }, + want: VerifyCommitResult{ + Success: true, + Commit: "abc123", + Ref: "main", + RefType: "branch", + Owner: "test-owner", + Repository: "test-repo", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + }, + }, + { + name: "failed verification", + result: VerifyCommitResult{ + Success: false, + Commit: "def456", + Ref: "develop", + RefType: "branch", + Owner: "test-owner", + Repository: "test-repo", + Message: "no VSA matching commit 'def456' on branch 'develop' found in github.com/test-owner/test-repo", + }, + want: VerifyCommitResult{ + Success: false, + Commit: "def456", + Ref: "develop", + RefType: "branch", + Owner: "test-owner", + Repository: "test-repo", + Message: "no VSA matching commit 'def456' on branch 'develop' found in github.com/test-owner/test-repo", + }, + }, + { + name: "tag verification", + result: VerifyCommitResult{ + Success: true, + Commit: "ghi789", + Ref: "v1.0.0", + RefType: "tag", + Owner: "test-owner", + Repository: "test-repo", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_2", "SLSA_SOURCE_LEVEL_3"}, + }, + want: VerifyCommitResult{ + Success: true, + Commit: "ghi789", + Ref: "v1.0.0", + RefType: "tag", + Owner: "test-owner", + Repository: "test-repo", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_2", "SLSA_SOURCE_LEVEL_3"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use semantic JSON comparison instead of string comparison + assertJSONEqual(t, tt.result, tt.want) + }) + } +} + +func TestOutputOptions_WriteJSON(t *testing.T) { + result := VerifyCommitResult{ + Success: true, + Commit: "abc123", + Ref: "main", + RefType: "branch", + Owner: "test-owner", + Repository: "test-repo", + VerifiedLevels: []string{"SLSA_SOURCE_LEVEL_3"}, + } + + opts := outputOptions{ + format: OutputFormatJSON, + } + + // Note: This test now writes to os.Stdout via getWriter() + // In a real scenario, we would capture stdout, but for this simple test + // we just verify it doesn't error + if err := opts.writeJSON(result); err != nil { + t.Fatalf("writeJSON failed: %v", err) + } +} + +func TestOutputOptions_OutputFormatIsJSON(t *testing.T) { + tests := []struct { + name string + format string + want bool + }{ + { + name: "JSON format", + format: OutputFormatJSON, + want: true, + }, + { + name: "text format", + format: OutputFormatText, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := outputOptions{format: tt.format} + if got := opts.outputFormatIsJSON(); got != tt.want { + t.Errorf("outputFormatIsJSON() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOutputOptions_Validate(t *testing.T) { + tests := []struct { + name string + format string + wantErr bool + }{ + { + name: "valid text format", + format: OutputFormatText, + wantErr: false, + }, + { + name: "valid JSON format", + format: OutputFormatJSON, + wantErr: false, + }, + { + name: "invalid format", + format: "invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := outputOptions{format: tt.format} + err := opts.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}