From f2cf648f6da75233eb735206e35774fbb61efc29 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:18:15 +0000 Subject: [PATCH] Add audit-logs export commands for S3 export destinations --- cmd/audit_logs_export.go | 690 ++++++++++++++++++++++++++++++++++ cmd/audit_logs_export_test.go | 632 +++++++++++++++++++++++++++++++ 2 files changed, 1322 insertions(+) create mode 100644 cmd/audit_logs_export.go create mode 100644 cmd/audit_logs_export_test.go diff --git a/cmd/audit_logs_export.go b/cmd/audit_logs_export.go new file mode 100644 index 0000000..4c33f9f --- /dev/null +++ b/cmd/audit_logs_export.go @@ -0,0 +1,690 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +const auditLogsExportBasePath = "audit-logs/export/destinations" + +const ( + auditLogExportStatusActive = "active" + auditLogExportStatusPaused = "paused" +) + +// The SDK has no generated types for the audit log export destination +// endpoints, so the CLI defines its own and calls them through the raw +// request methods on kernel.Client. + +type auditLogExportDestination struct { + ID string `json:"id"` + Type string `json:"type"` + Region string `json:"region"` + Bucket string `json:"bucket"` + Prefix string `json:"prefix"` + RoleARN string `json:"role_arn"` + ExternalID string `json:"external_id"` + KernelRoleARN string `json:"kernel_role_arn"` + KMSKeyID string `json:"kms_key_id,omitempty"` + Format string `json:"format"` + Status string `json:"status"` + LastExportedCursor string `json:"last_exported_cursor,omitempty"` + LastSuccessAt *time.Time `json:"last_success_at,omitempty"` + LastError string `json:"last_error,omitempty"` + LastErrorAt *time.Time `json:"last_error_at,omitempty"` + ConsecutiveFailures int64 `json:"consecutive_failures"` + NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (d auditLogExportDestination) RawJSON() string { + raw, err := json.Marshal(d) + if err != nil { + return "" + } + return string(raw) +} + +type createAuditLogExportDestinationRequest struct { + Type string `json:"type"` + Region string `json:"region"` + Bucket string `json:"bucket"` + Prefix string `json:"prefix"` + RoleARN string `json:"role_arn"` + KMSKeyID *string `json:"kms_key_id,omitempty"` + Format string `json:"format"` +} + +// updateAuditLogExportDestinationRequest is a partial update: nil fields are +// omitted, and a KMSKeyID pointing at "" clears the configured key. +type updateAuditLogExportDestinationRequest struct { + Region *string `json:"region,omitempty"` + Bucket *string `json:"bucket,omitempty"` + Prefix *string `json:"prefix,omitempty"` + RoleARN *string `json:"role_arn,omitempty"` + KMSKeyID *string `json:"kms_key_id,omitempty"` + Status *string `json:"status,omitempty"` +} + +type auditLogExportTestResultError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type auditLogExportTestResult struct { + Success bool `json:"success"` + Stage string `json:"stage"` + Error *auditLogExportTestResultError `json:"error,omitempty"` +} + +func (r auditLogExportTestResult) RawJSON() string { + raw, err := json.Marshal(r) + if err != nil { + return "" + } + return string(raw) +} + +type auditLogExportListPageInfo struct { + HasMore bool + NextOffset int +} + +type AuditLogsExportService interface { + Create(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) + List(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) + Get(ctx context.Context, id string) (*auditLogExportDestination, error) + Update(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) + Delete(ctx context.Context, id string) error + Test(ctx context.Context, id string) (*auditLogExportTestResult, error) +} + +type auditLogsExportClient struct { + client *kernel.Client +} + +func (s *auditLogsExportClient) Create(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + var res auditLogExportDestination + if err := s.client.Post(ctx, auditLogsExportBasePath, body, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (s *auditLogsExportClient) List(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + query := url.Values{} + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + path := auditLogsExportBasePath + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + var httpRes *http.Response + destinations := make([]auditLogExportDestination, 0) + if err := s.client.Get(ctx, path, nil, &destinations, option.WithResponseInto(&httpRes)); err != nil { + return nil, auditLogExportListPageInfo{}, err + } + info := auditLogExportListPageInfo{} + if httpRes != nil { + info.HasMore = strings.EqualFold(httpRes.Header.Get("X-Has-More"), "true") + if v := httpRes.Header.Get("X-Next-Offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + info.NextOffset = n + } + } + } + return destinations, info, nil +} + +func (s *auditLogsExportClient) Get(ctx context.Context, id string) (*auditLogExportDestination, error) { + var res auditLogExportDestination + if err := s.client.Get(ctx, auditLogsExportBasePath+"/"+url.PathEscape(id), nil, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (s *auditLogsExportClient) Update(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + var res auditLogExportDestination + if err := s.client.Patch(ctx, auditLogsExportBasePath+"/"+url.PathEscape(id), body, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (s *auditLogsExportClient) Delete(ctx context.Context, id string) error { + return s.client.Delete(ctx, auditLogsExportBasePath+"/"+url.PathEscape(id), nil, nil) +} + +func (s *auditLogsExportClient) Test(ctx context.Context, id string) (*auditLogExportTestResult, error) { + var res auditLogExportTestResult + if err := s.client.Post(ctx, auditLogsExportBasePath+"/"+url.PathEscape(id)+"/test", nil, &res); err != nil { + return nil, err + } + return &res, nil +} + +type AuditLogsExportCmd struct { + export AuditLogsExportService +} + +type AuditLogsExportCreateInput struct { + Region string + Bucket string + Prefix string + RoleARN string + KMSKeyID string + Output string +} + +func (c AuditLogsExportCmd) Create(ctx context.Context, in AuditLogsExportCreateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + req := createAuditLogExportDestinationRequest{ + Type: "s3", + Format: "jsonl.gz", + Region: in.Region, + Bucket: in.Bucket, + Prefix: in.Prefix, + RoleARN: in.RoleARN, + } + if in.KMSKeyID != "" { + req.KMSKeyID = &in.KMSKeyID + } + + dest, err := c.export.Create(ctx, req) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(dest) + } + + pterm.Success.Printf("Created audit log export destination %s (paused)\n", dest.ID) + printAuditLogExportDestinationDetail(dest) + pterm.Info.Printf("To activate this destination:\n 1. Update the trust policy of %s to allow %s as a principal, requiring sts:ExternalId = %s\n 2. Run: kernel audit-logs export test %s\n 3. Activate: kernel audit-logs export resume %s\n", dest.RoleARN, dest.KernelRoleARN, dest.ExternalID, dest.ID, dest.ID) + return nil +} + +type AuditLogsExportListInput struct { + Limit int + Offset int + Output string +} + +func (c AuditLogsExportCmd) List(ctx context.Context, in AuditLogsExportListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if in.Limit < 1 || in.Limit > 100 { + return fmt.Errorf("--limit must be between 1 and 100") + } + if in.Offset < 0 { + return fmt.Errorf("--offset must be non-negative") + } + + destinations, pageInfo, err := c.export.List(ctx, in.Limit, in.Offset) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSONSlice(destinations) + } + + if len(destinations) == 0 { + pterm.Info.Println("No audit log export destinations found") + return nil + } + + table := pterm.TableData{{"ID", "Bucket", "Prefix", "Region", "Status", "Last Success", "Failures", "Last Error"}} + for _, d := range destinations { + table = append(table, []string{ + d.ID, + d.Bucket, + util.OrDash(d.Prefix), + d.Region, + d.Status, + formatAuditLogExportTime(d.LastSuccessAt), + strconv.FormatInt(d.ConsecutiveFailures, 10), + truncateAuditLogExportError(d.LastError), + }) + } + PrintTableNoPad(table, true) + + if pageInfo.HasMore { + pterm.Info.Printf("More destinations available; re-run with --offset %d\n", pageInfo.NextOffset) + } + return nil +} + +type AuditLogsExportGetInput struct { + ID string + Output string +} + +func (c AuditLogsExportCmd) Get(ctx context.Context, in AuditLogsExportGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + dest, err := c.export.Get(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(dest) + } + + printAuditLogExportDestinationDetail(dest) + return nil +} + +type AuditLogsExportUpdateInput struct { + ID string + Region *string + Bucket *string + Prefix *string + RoleARN *string + KMSKeyID *string + ClearKMSKey bool + Output string +} + +func (c AuditLogsExportCmd) Update(ctx context.Context, in AuditLogsExportUpdateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if in.KMSKeyID != nil && in.ClearKMSKey { + return fmt.Errorf("cannot specify both --kms-key-id and --clear-kms-key") + } + + req := updateAuditLogExportDestinationRequest{ + Region: in.Region, + Bucket: in.Bucket, + Prefix: in.Prefix, + RoleARN: in.RoleARN, + KMSKeyID: in.KMSKeyID, + } + if in.ClearKMSKey { + req.KMSKeyID = new(string) + } + if req.Region == nil && req.Bucket == nil && req.Prefix == nil && req.RoleARN == nil && req.KMSKeyID == nil { + return fmt.Errorf("nothing to update: pass at least one of --region, --bucket, --prefix, --role-arn, --kms-key-id, or --clear-kms-key") + } + + dest, err := c.export.Update(ctx, in.ID, req) + if err != nil { + return cleanedUpAuditLogExportUpdateError(err) + } + + if in.Output == "json" { + return util.PrintPrettyJSON(dest) + } + + pterm.Success.Printf("Updated audit log export destination %s\n", dest.ID) + printAuditLogExportDestinationDetail(dest) + return nil +} + +type AuditLogsExportStatusInput struct { + ID string + Status string + Output string +} + +func (c AuditLogsExportCmd) SetStatus(ctx context.Context, in AuditLogsExportStatusInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if in.Status != auditLogExportStatusActive && in.Status != auditLogExportStatusPaused { + return fmt.Errorf("invalid status %q", in.Status) + } + + dest, err := c.export.Update(ctx, in.ID, updateAuditLogExportDestinationRequest{Status: &in.Status}) + if err != nil { + return cleanedUpAuditLogExportUpdateError(err) + } + + if in.Output == "json" { + return util.PrintPrettyJSON(dest) + } + + if in.Status == auditLogExportStatusActive { + pterm.Success.Printf("Resumed audit log export destination %s\n", dest.ID) + } else { + pterm.Success.Printf("Paused audit log export destination %s\n", dest.ID) + } + printAuditLogExportDestinationDetail(dest) + if in.Status == auditLogExportStatusPaused { + pterm.Info.Println("An S3 upload already in progress may still complete; its rows can appear again after the destination is resumed.") + } + return nil +} + +type AuditLogsExportDeleteInput struct { + ID string +} + +func (c AuditLogsExportCmd) Delete(ctx context.Context, in AuditLogsExportDeleteInput) error { + if err := c.export.Delete(ctx, in.ID); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted audit log export destination %s\n", in.ID) + return nil +} + +type AuditLogsExportTestInput struct { + ID string + Output string +} + +func (c AuditLogsExportCmd) Test(ctx context.Context, in AuditLogsExportTestInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + res, err := c.export.Test(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if err := util.PrintPrettyJSON(res); err != nil { + return err + } + } else if res.Success { + pterm.Success.Printf("Test passed (stage: %s)\n", res.Stage) + } else if res.Error != nil { + pterm.Error.Printf("Test failed at stage %s: %s: %s\n", res.Stage, res.Error.Code, res.Error.Message) + } else { + pterm.Error.Printf("Test failed at stage %s\n", res.Stage) + } + + if !res.Success { + return fmt.Errorf("audit log export destination test failed at stage %s", res.Stage) + } + return nil +} + +func cleanedUpAuditLogExportUpdateError(err error) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { + return fmt.Errorf("%w (destination changed concurrently; re-run against fresh state)", util.CleanedUpSdkError{Err: err}) + } + return util.CleanedUpSdkError{Err: err} +} + +func printAuditLogExportDestinationDetail(d *auditLogExportDestination) { + rows := pterm.TableData{ + {"Property", "Value"}, + {"ID", d.ID}, + {"Type", d.Type}, + {"Region", d.Region}, + {"Bucket", d.Bucket}, + {"Prefix", util.OrDash(d.Prefix)}, + {"Role ARN", d.RoleARN}, + {"Kernel Role ARN", d.KernelRoleARN}, + {"External ID", d.ExternalID}, + {"KMS Key ID", util.OrDash(d.KMSKeyID)}, + {"Format", d.Format}, + {"Status", d.Status}, + {"Last Exported Cursor", util.OrDash(d.LastExportedCursor)}, + {"Last Success", formatAuditLogExportLastSuccess(d.LastSuccessAt)}, + {"Last Error", util.OrDash(d.LastError)}, + {"Last Error At", formatAuditLogExportTime(d.LastErrorAt)}, + {"Consecutive Failures", strconv.FormatInt(d.ConsecutiveFailures, 10)}, + {"Next Attempt", formatAuditLogExportTime(d.NextAttemptAt)}, + {"Created At", util.FormatLocal(d.CreatedAt)}, + {"Updated At", util.FormatLocal(d.UpdatedAt)}, + } + PrintTableNoPad(rows, true) +} + +func formatAuditLogExportTime(t *time.Time) string { + if t == nil { + return "-" + } + return util.FormatLocal(*t) +} + +func formatAuditLogExportLastSuccess(t *time.Time) string { + if t == nil { + return "-" + } + lag := max(time.Since(*t).Round(time.Second), 0) + return fmt.Sprintf("%s (%s ago)", util.FormatLocal(*t), lag) +} + +func truncateAuditLogExportError(s string) string { + const maxLen = 60 + if len(s) <= maxLen { + return util.OrDash(s) + } + return s[:maxLen-3] + "..." +} + +func getAuditLogsExportHandler(cmd *cobra.Command) AuditLogsExportCmd { + client := getKernelClient(cmd) + return AuditLogsExportCmd{export: &auditLogsExportClient{client: &client}} +} + +func runAuditLogsExportCreate(cmd *cobra.Command, args []string) error { + region, _ := cmd.Flags().GetString("region") + bucket, _ := cmd.Flags().GetString("bucket") + prefix, _ := cmd.Flags().GetString("prefix") + roleARN, _ := cmd.Flags().GetString("role-arn") + kmsKeyID, _ := cmd.Flags().GetString("kms-key-id") + output, _ := cmd.Flags().GetString("output") + c := getAuditLogsExportHandler(cmd) + return c.Create(cmd.Context(), AuditLogsExportCreateInput{ + Region: region, + Bucket: bucket, + Prefix: prefix, + RoleARN: roleARN, + KMSKeyID: kmsKeyID, + Output: output, + }) +} + +func runAuditLogsExportList(cmd *cobra.Command, args []string) error { + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + output, _ := cmd.Flags().GetString("output") + c := getAuditLogsExportHandler(cmd) + return c.List(cmd.Context(), AuditLogsExportListInput{Limit: limit, Offset: offset, Output: output}) +} + +func runAuditLogsExportGet(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + c := getAuditLogsExportHandler(cmd) + return c.Get(cmd.Context(), AuditLogsExportGetInput{ID: args[0], Output: output}) +} + +func runAuditLogsExportUpdate(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + clearKMSKey, _ := cmd.Flags().GetBool("clear-kms-key") + in := AuditLogsExportUpdateInput{ID: args[0], ClearKMSKey: clearKMSKey, Output: output} + if cmd.Flags().Changed("region") { + region, _ := cmd.Flags().GetString("region") + in.Region = ®ion + } + if cmd.Flags().Changed("bucket") { + bucket, _ := cmd.Flags().GetString("bucket") + in.Bucket = &bucket + } + if cmd.Flags().Changed("prefix") { + prefix, _ := cmd.Flags().GetString("prefix") + in.Prefix = &prefix + } + if cmd.Flags().Changed("role-arn") { + roleARN, _ := cmd.Flags().GetString("role-arn") + in.RoleARN = &roleARN + } + if cmd.Flags().Changed("kms-key-id") { + kmsKeyID, _ := cmd.Flags().GetString("kms-key-id") + in.KMSKeyID = &kmsKeyID + } + c := getAuditLogsExportHandler(cmd) + return c.Update(cmd.Context(), in) +} + +func runAuditLogsExportPause(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + c := getAuditLogsExportHandler(cmd) + return c.SetStatus(cmd.Context(), AuditLogsExportStatusInput{ID: args[0], Status: auditLogExportStatusPaused, Output: output}) +} + +func runAuditLogsExportResume(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + c := getAuditLogsExportHandler(cmd) + return c.SetStatus(cmd.Context(), AuditLogsExportStatusInput{ID: args[0], Status: auditLogExportStatusActive, Output: output}) +} + +func runAuditLogsExportDelete(cmd *cobra.Command, args []string) error { + c := getAuditLogsExportHandler(cmd) + return c.Delete(cmd.Context(), AuditLogsExportDeleteInput{ID: args[0]}) +} + +func runAuditLogsExportTest(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + c := getAuditLogsExportHandler(cmd) + return c.Test(cmd.Context(), AuditLogsExportTestInput{ID: args[0], Output: output}) +} + +var auditLogsExportCmd = &cobra.Command{ + Use: "export", + Aliases: []string{"exports", "export-destinations"}, + Short: "Manage audit log export destinations", + Long: "Manage S3 destinations that receive a continuous export of your organization's audit logs.\n\n" + + "Objects are written as /destination_id=/org_id=/date=/hour=/-.jsonl.gz. " + + "Delivery is at-least-once; consumers must deduplicate on event_id.", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var auditLogsExportCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an S3 audit log export destination", + Long: "Create an S3 audit log export destination. The destination is created paused; test it and then activate it with 'kernel audit-logs export resume '.", + Args: cobra.NoArgs, + RunE: runAuditLogsExportCreate, +} + +var auditLogsExportListCmd = &cobra.Command{ + Use: "list", + Short: "List audit log export destinations", + Args: cobra.NoArgs, + RunE: runAuditLogsExportList, +} + +var auditLogsExportGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get details of an audit log export destination", + Args: cobra.ExactArgs(1), + RunE: runAuditLogsExportGet, +} + +var auditLogsExportUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an audit log export destination", + Args: cobra.ExactArgs(1), + RunE: runAuditLogsExportUpdate, +} + +var auditLogsExportPauseCmd = &cobra.Command{ + Use: "pause ", + Short: "Pause an audit log export destination", + Long: "Pause an audit log export destination. Pausing prevents new delivery attempts; an S3 upload already in progress may still complete.", + Args: cobra.ExactArgs(1), + RunE: runAuditLogsExportPause, +} + +var auditLogsExportResumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume an audit log export destination", + Long: "Resume an audit log export destination. Delivery starts from the time of the resume; events recorded while paused are not exported.", + Args: cobra.ExactArgs(1), + RunE: runAuditLogsExportResume, +} + +var auditLogsExportDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an audit log export destination", + Args: cobra.ExactArgs(1), + RunE: runAuditLogsExportDelete, +} + +var auditLogsExportTestCmd = &cobra.Command{ + Use: "test ", + Short: "Test an audit log export destination", + Long: "Test an audit log export destination by assuming its role and writing a test object. Exits non-zero when the test fails.", + Args: cobra.ExactArgs(1), + RunE: runAuditLogsExportTest, +} + +func init() { + addJSONOutputFlag(auditLogsExportCreateCmd) + auditLogsExportCreateCmd.Flags().String("region", "", "AWS region of the destination bucket (required)") + _ = auditLogsExportCreateCmd.MarkFlagRequired("region") + auditLogsExportCreateCmd.Flags().String("bucket", "", "Destination S3 bucket name (required)") + _ = auditLogsExportCreateCmd.MarkFlagRequired("bucket") + auditLogsExportCreateCmd.Flags().String("prefix", "", "Key prefix for exported objects; may be empty (required)") + _ = auditLogsExportCreateCmd.MarkFlagRequired("prefix") + auditLogsExportCreateCmd.Flags().String("role-arn", "", "IAM role ARN Kernel assumes to deliver logs (required)") + _ = auditLogsExportCreateCmd.MarkFlagRequired("role-arn") + auditLogsExportCreateCmd.Flags().String("kms-key-id", "", "KMS key ID, alias, or ARN for server-side encryption") + + addJSONOutputFlag(auditLogsExportListCmd) + auditLogsExportListCmd.Flags().Int("limit", 20, "Maximum number of destinations to return (1-100)") + auditLogsExportListCmd.Flags().Int("offset", 0, "Number of destinations to skip (for pagination)") + + addJSONOutputFlag(auditLogsExportGetCmd) + + addJSONOutputFlag(auditLogsExportUpdateCmd) + auditLogsExportUpdateCmd.Flags().String("region", "", "Update the AWS region of the destination bucket") + auditLogsExportUpdateCmd.Flags().String("bucket", "", "Update the destination S3 bucket name") + auditLogsExportUpdateCmd.Flags().String("prefix", "", "Update the key prefix for exported objects") + auditLogsExportUpdateCmd.Flags().String("role-arn", "", "Update the IAM role ARN Kernel assumes to deliver logs") + auditLogsExportUpdateCmd.Flags().String("kms-key-id", "", "Update the KMS key ID, alias, or ARN for server-side encryption") + auditLogsExportUpdateCmd.Flags().Bool("clear-kms-key", false, "Remove the configured KMS key") + auditLogsExportUpdateCmd.MarkFlagsMutuallyExclusive("kms-key-id", "clear-kms-key") + + addJSONOutputFlag(auditLogsExportPauseCmd) + addJSONOutputFlag(auditLogsExportResumeCmd) + addJSONOutputFlag(auditLogsExportTestCmd) + + auditLogsExportCmd.AddCommand(auditLogsExportCreateCmd) + auditLogsExportCmd.AddCommand(auditLogsExportListCmd) + auditLogsExportCmd.AddCommand(auditLogsExportGetCmd) + auditLogsExportCmd.AddCommand(auditLogsExportUpdateCmd) + auditLogsExportCmd.AddCommand(auditLogsExportPauseCmd) + auditLogsExportCmd.AddCommand(auditLogsExportResumeCmd) + auditLogsExportCmd.AddCommand(auditLogsExportDeleteCmd) + auditLogsExportCmd.AddCommand(auditLogsExportTestCmd) + + auditLogsCmd.AddCommand(auditLogsExportCmd) +} diff --git a/cmd/audit_logs_export_test.go b/cmd/audit_logs_export_test.go new file mode 100644 index 0000000..cb7ae0c --- /dev/null +++ b/cmd/audit_logs_export_test.go @@ -0,0 +1,632 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type FakeAuditLogsExportService struct { + CreateFunc func(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) + ListFunc func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) + GetFunc func(ctx context.Context, id string) (*auditLogExportDestination, error) + UpdateFunc func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) + DeleteFunc func(ctx context.Context, id string) error + TestFunc func(ctx context.Context, id string) (*auditLogExportTestResult, error) +} + +func (f *FakeAuditLogsExportService) Create(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + if f.CreateFunc != nil { + return f.CreateFunc(ctx, body) + } + return nil, errors.New("Create not implemented") +} + +func (f *FakeAuditLogsExportService) List(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, limit, offset) + } + return nil, auditLogExportListPageInfo{}, errors.New("List not implemented") +} + +func (f *FakeAuditLogsExportService) Get(ctx context.Context, id string) (*auditLogExportDestination, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, id) + } + return nil, errors.New("Get not implemented") +} + +func (f *FakeAuditLogsExportService) Update(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + if f.UpdateFunc != nil { + return f.UpdateFunc(ctx, id, body) + } + return nil, errors.New("Update not implemented") +} + +func (f *FakeAuditLogsExportService) Delete(ctx context.Context, id string) error { + if f.DeleteFunc != nil { + return f.DeleteFunc(ctx, id) + } + return errors.New("Delete not implemented") +} + +func (f *FakeAuditLogsExportService) Test(ctx context.Context, id string) (*auditLogExportTestResult, error) { + if f.TestFunc != nil { + return f.TestFunc(ctx, id) + } + return nil, errors.New("Test not implemented") +} + +func stringPtr(s string) *string { + return &s +} + +func auditLogExportDestinationFromJSON(raw string) auditLogExportDestination { + var d auditLogExportDestination + if err := json.Unmarshal([]byte(raw), &d); err != nil { + panic(err) + } + return d +} + +func sampleAuditLogExportDestination() auditLogExportDestination { + return auditLogExportDestinationFromJSON(`{ + "id": "dest_123", + "type": "s3", + "region": "us-east-1", + "bucket": "acme-audit-logs", + "prefix": "kernel/audit", + "role_arn": "arn:aws:iam::123456789012:role/audit-export", + "external_id": "ext_abc123", + "kernel_role_arn": "arn:aws:iam::210987654321:role/kernel-exporter", + "kms_key_id": "arn:aws:kms:us-east-1:123456789012:key/abc-def", + "format": "jsonl.gz", + "status": "active", + "last_exported_cursor": "cursor_v1_abc", + "last_success_at": "2026-07-01T12:00:00Z", + "last_error": "AccessDenied: not authorized to perform s3:PutObject", + "last_error_at": "2026-07-01T11:00:00Z", + "consecutive_failures": 3, + "next_attempt_at": "2026-07-01T12:05:00Z", + "created_at": "2026-06-30T00:00:00Z", + "updated_at": "2026-07-01T00:00:00Z" + }`) +} + +func auditLogExportAPIError(status int) *kernel.Error { + return &kernel.Error{ + StatusCode: status, + Request: &http.Request{Method: http.MethodPatch, URL: &url.URL{Path: "/audit-logs/export/destinations/dest_123"}}, + Response: &http.Response{StatusCode: status}, + } +} + +func TestAuditLogsExportCreateBuildsRequestAndPrintsOnboarding(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + CreateFunc: func(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + assert.Equal(t, "s3", body.Type) + assert.Equal(t, "jsonl.gz", body.Format) + assert.Equal(t, "us-east-1", body.Region) + assert.Equal(t, "acme-audit-logs", body.Bucket) + assert.Equal(t, "kernel/audit", body.Prefix) + assert.Equal(t, "arn:aws:iam::123456789012:role/audit-export", body.RoleARN) + assert.Nil(t, body.KMSKeyID) + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Create(context.Background(), AuditLogsExportCreateInput{ + Region: "us-east-1", + Bucket: "acme-audit-logs", + Prefix: "kernel/audit", + RoleARN: "arn:aws:iam::123456789012:role/audit-export", + }) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "Created audit log export destination dest_123") + assert.Contains(t, out, "paused") + assert.Contains(t, out, "ext_abc123") + assert.Contains(t, out, "arn:aws:iam::210987654321:role/kernel-exporter") + assert.Contains(t, out, "kernel audit-logs export test dest_123") + assert.Contains(t, out, "kernel audit-logs export resume dest_123") +} + +func TestAuditLogsExportCreateIncludesKMSKeyWhenSet(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + CreateFunc: func(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + require.NotNil(t, body.KMSKeyID) + assert.Equal(t, "arn:aws:kms:us-east-1:123456789012:key/abc-def", *body.KMSKeyID) + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Create(context.Background(), AuditLogsExportCreateInput{ + Region: "us-east-1", + Bucket: "acme-audit-logs", + Prefix: "kernel/audit", + RoleARN: "arn:aws:iam::123456789012:role/audit-export", + KMSKeyID: "arn:aws:kms:us-east-1:123456789012:key/abc-def", + }) + require.NoError(t, err) +} + +func TestAuditLogsExportCreateJSONPrintsObject(t *testing.T) { + fake := &FakeAuditLogsExportService{ + CreateFunc: func(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + var err error + out := captureStdout(t, func() { + err = c.Create(context.Background(), AuditLogsExportCreateInput{ + Region: "us-east-1", + Bucket: "acme-audit-logs", + Prefix: "kernel/audit", + RoleARN: "arn:aws:iam::123456789012:role/audit-export", + Output: "json", + }) + }) + require.NoError(t, err) + + assert.Contains(t, out, `"id": "dest_123"`) + assert.Contains(t, out, `"kernel_role_arn": "arn:aws:iam::210987654321:role/kernel-exporter"`) + assert.NotContains(t, out, "Created audit log export destination") +} + +func TestAuditLogsExportListRendersTableAndPaginationHint(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + assert.Equal(t, 20, limit) + assert.Equal(t, 0, offset) + return []auditLogExportDestination{sampleAuditLogExportDestination()}, auditLogExportListPageInfo{HasMore: true, NextOffset: 20}, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.List(context.Background(), AuditLogsExportListInput{Limit: 20}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "dest_123") + assert.Contains(t, out, "acme-audit-logs") + assert.Contains(t, out, "kernel/audit") + assert.Contains(t, out, "us-east-1") + assert.Contains(t, out, "active") + assert.Contains(t, out, "AccessDenied") + assert.Contains(t, out, "--offset 20") +} + +func TestAuditLogsExportListPassesLimitAndOffset(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + assert.Equal(t, 50, limit) + assert.Equal(t, 40, offset) + return []auditLogExportDestination{sampleAuditLogExportDestination()}, auditLogExportListPageInfo{}, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.List(context.Background(), AuditLogsExportListInput{Limit: 50, Offset: 40}) + require.NoError(t, err) +} + +func TestAuditLogsExportListTruncatesLongLastError(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + dest := sampleAuditLogExportDestination() + dest.LastError = "AccessDenied: this is a very long error message that exceeds sixty characters and must be truncated" + return []auditLogExportDestination{dest}, auditLogExportListPageInfo{}, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.List(context.Background(), AuditLogsExportListInput{Limit: 20}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "...") + assert.NotContains(t, out, "must be truncated") +} + +func TestAuditLogsExportListPrintsEmptyMessage(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + return []auditLogExportDestination{}, auditLogExportListPageInfo{}, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.List(context.Background(), AuditLogsExportListInput{Limit: 20}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "No audit log export destinations found") +} + +func TestAuditLogsExportListJSONEmptyPrintsEmptyArray(t *testing.T) { + fake := &FakeAuditLogsExportService{ + ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + return []auditLogExportDestination{}, auditLogExportListPageInfo{}, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + var err error + out := captureStdout(t, func() { + err = c.List(context.Background(), AuditLogsExportListInput{Limit: 20, Output: "json"}) + }) + require.NoError(t, err) + assert.Contains(t, out, "[]") +} + +func TestAuditLogsExportListRejectsInvalidLimitAndOffset(t *testing.T) { + c := AuditLogsExportCmd{export: &FakeAuditLogsExportService{}} + + err := c.List(context.Background(), AuditLogsExportListInput{Limit: 0}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--limit") + + err = c.List(context.Background(), AuditLogsExportListInput{Limit: 101}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--limit") + + err = c.List(context.Background(), AuditLogsExportListInput{Limit: 20, Offset: -1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--offset") +} + +func TestAuditLogsExportGetRendersDeliveryStatus(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + GetFunc: func(ctx context.Context, id string) (*auditLogExportDestination, error) { + assert.Equal(t, "dest_123", id) + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Get(context.Background(), AuditLogsExportGetInput{ID: "dest_123"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "dest_123") + assert.Contains(t, out, "active") + assert.Contains(t, out, "cursor_v1_abc") + assert.Contains(t, out, "ago)") + assert.Contains(t, out, "AccessDenied: not authorized to perform s3:PutObject") + assert.Contains(t, out, "3") + assert.Contains(t, out, "2026-07-01") + assert.Contains(t, out, "arn:aws:kms:us-east-1:123456789012:key/abc-def") +} + +func TestAuditLogsExportGetRendersDashWhenNeverDelivered(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + GetFunc: func(ctx context.Context, id string) (*auditLogExportDestination, error) { + return &auditLogExportDestination{ID: id, Type: "s3", Status: "paused"}, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Get(context.Background(), AuditLogsExportGetInput{ID: "dest_123"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "Last Success") + assert.NotContains(t, out, "ago)") + assert.NotContains(t, out, "0001-01-01") +} + +func TestAuditLogsExportGetJSONPrintsObject(t *testing.T) { + fake := &FakeAuditLogsExportService{ + GetFunc: func(ctx context.Context, id string) (*auditLogExportDestination, error) { + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + var err error + out := captureStdout(t, func() { + err = c.Get(context.Background(), AuditLogsExportGetInput{ID: "dest_123", Output: "json"}) + }) + require.NoError(t, err) + + assert.Contains(t, out, `"id": "dest_123"`) + assert.Contains(t, out, `"consecutive_failures": 3`) + assert.NotContains(t, out, "Property") +} + +func TestAuditLogsExportUpdateBuildsPartialRequest(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + assert.Equal(t, "dest_123", id) + require.NotNil(t, body.Bucket) + assert.Equal(t, "new-bucket", *body.Bucket) + require.NotNil(t, body.Prefix) + assert.Equal(t, "new/prefix", *body.Prefix) + assert.Nil(t, body.Region) + assert.Nil(t, body.RoleARN) + assert.Nil(t, body.KMSKeyID) + assert.Nil(t, body.Status) + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Update(context.Background(), AuditLogsExportUpdateInput{ + ID: "dest_123", + Bucket: stringPtr("new-bucket"), + Prefix: stringPtr("new/prefix"), + }) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Updated audit log export destination dest_123") +} + +func TestAuditLogsExportUpdateClearKMSKeySendsEmptyString(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + require.NotNil(t, body.KMSKeyID) + assert.Equal(t, "", *body.KMSKeyID) + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Update(context.Background(), AuditLogsExportUpdateInput{ID: "dest_123", ClearKMSKey: true}) + require.NoError(t, err) +} + +func TestAuditLogsExportUpdateRejectsKMSKeyAndClear(t *testing.T) { + c := AuditLogsExportCmd{export: &FakeAuditLogsExportService{}} + + err := c.Update(context.Background(), AuditLogsExportUpdateInput{ + ID: "dest_123", + KMSKeyID: stringPtr("arn:aws:kms:us-east-1:123456789012:key/abc-def"), + ClearKMSKey: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--kms-key-id") + assert.Contains(t, err.Error(), "--clear-kms-key") +} + +func TestAuditLogsExportUpdateRequiresAChange(t *testing.T) { + c := AuditLogsExportCmd{export: &FakeAuditLogsExportService{}} + + err := c.Update(context.Background(), AuditLogsExportUpdateInput{ID: "dest_123"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nothing to update") +} + +func TestAuditLogsExportUpdateRequestSerialization(t *testing.T) { + raw, err := json.Marshal(updateAuditLogExportDestinationRequest{KMSKeyID: stringPtr("")}) + require.NoError(t, err) + assert.JSONEq(t, `{"kms_key_id":""}`, string(raw)) + + raw, err = json.Marshal(updateAuditLogExportDestinationRequest{}) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(raw)) + + raw, err = json.Marshal(updateAuditLogExportDestinationRequest{Status: stringPtr("paused")}) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"paused"}`, string(raw)) +} + +func TestAuditLogsExportUpdateHintsOnConflict(t *testing.T) { + fake := &FakeAuditLogsExportService{ + UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + return nil, auditLogExportAPIError(http.StatusConflict) + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Update(context.Background(), AuditLogsExportUpdateInput{ID: "dest_123", Bucket: stringPtr("new-bucket")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "concurrently") +} + +func TestAuditLogsExportPauseSendsStatusPaused(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + assert.Equal(t, "dest_123", id) + require.NotNil(t, body.Status) + assert.Equal(t, "paused", *body.Status) + assert.Nil(t, body.Region) + assert.Nil(t, body.Bucket) + assert.Nil(t, body.Prefix) + assert.Nil(t, body.RoleARN) + assert.Nil(t, body.KMSKeyID) + dest := sampleAuditLogExportDestination() + dest.Status = "paused" + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.SetStatus(context.Background(), AuditLogsExportStatusInput{ID: "dest_123", Status: "paused"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "Paused audit log export destination dest_123") + assert.Contains(t, out, "in progress") +} + +func TestAuditLogsExportResumeSendsStatusActive(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + require.NotNil(t, body.Status) + assert.Equal(t, "active", *body.Status) + dest := sampleAuditLogExportDestination() + return &dest, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.SetStatus(context.Background(), AuditLogsExportStatusInput{ID: "dest_123", Status: "active"}) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "Resumed audit log export destination dest_123") + assert.NotContains(t, out, "in progress") +} + +func TestAuditLogsExportSetStatusRejectsInvalidStatus(t *testing.T) { + c := AuditLogsExportCmd{export: &FakeAuditLogsExportService{}} + + err := c.SetStatus(context.Background(), AuditLogsExportStatusInput{ID: "dest_123", Status: "stopped"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid status") +} + +func TestAuditLogsExportDeletePrintsSuccess(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + DeleteFunc: func(ctx context.Context, id string) error { + assert.Equal(t, "dest_123", id) + return nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Delete(context.Background(), AuditLogsExportDeleteInput{ID: "dest_123"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Deleted audit log export destination dest_123") +} + +func TestAuditLogsExportTestPassesPrintsSuccess(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + TestFunc: func(ctx context.Context, id string) (*auditLogExportTestResult, error) { + assert.Equal(t, "dest_123", id) + return &auditLogExportTestResult{Success: true, Stage: "complete"}, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Test(context.Background(), AuditLogsExportTestInput{ID: "dest_123"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Test passed (stage: complete)") +} + +func TestAuditLogsExportTestFailurePrintsDetailsAndReturnsError(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuditLogsExportService{ + TestFunc: func(ctx context.Context, id string) (*auditLogExportTestResult, error) { + return &auditLogExportTestResult{ + Success: false, + Stage: "assume_role", + Error: &auditLogExportTestResultError{Code: "assume_role_failed", Message: "AccessDenied: not authorized to perform sts:AssumeRole"}, + }, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + err := c.Test(context.Background(), AuditLogsExportTestInput{ID: "dest_123"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "assume_role") + + out := buf.String() + assert.Contains(t, out, "assume_role") + assert.Contains(t, out, "assume_role_failed") + assert.Contains(t, out, "AccessDenied") +} + +func TestAuditLogsExportTestJSONFailurePrintsResultAndReturnsError(t *testing.T) { + fake := &FakeAuditLogsExportService{ + TestFunc: func(ctx context.Context, id string) (*auditLogExportTestResult, error) { + return &auditLogExportTestResult{ + Success: false, + Stage: "put_object", + Error: &auditLogExportTestResultError{Code: "put_object_failed", Message: "NoSuchBucket"}, + }, nil + }, + } + c := AuditLogsExportCmd{export: fake} + + var err error + out := captureStdout(t, func() { + err = c.Test(context.Background(), AuditLogsExportTestInput{ID: "dest_123", Output: "json"}) + }) + require.Error(t, err) + + assert.Contains(t, out, `"success": false`) + assert.Contains(t, out, `"stage": "put_object"`) + assert.Contains(t, out, `"code": "put_object_failed"`) +} + +func TestAuditLogsExportRejectsInvalidJSONOutput(t *testing.T) { + c := AuditLogsExportCmd{export: &FakeAuditLogsExportService{}} + ctx := context.Background() + + errs := []error{ + c.Create(ctx, AuditLogsExportCreateInput{Output: "yaml"}), + c.List(ctx, AuditLogsExportListInput{Limit: 20, Output: "yaml"}), + c.Get(ctx, AuditLogsExportGetInput{ID: "dest_123", Output: "yaml"}), + c.Update(ctx, AuditLogsExportUpdateInput{ID: "dest_123", Bucket: stringPtr("b"), Output: "yaml"}), + c.SetStatus(ctx, AuditLogsExportStatusInput{ID: "dest_123", Status: "paused", Output: "yaml"}), + c.Test(ctx, AuditLogsExportTestInput{ID: "dest_123", Output: "yaml"}), + } + for _, err := range errs { + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") + } +} + +func TestAuditLogsExportPropagatesAPIErrors(t *testing.T) { + boom := errors.New("boom") + c := AuditLogsExportCmd{export: &FakeAuditLogsExportService{ + CreateFunc: func(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + return nil, boom + }, + ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + return nil, auditLogExportListPageInfo{}, boom + }, + GetFunc: func(ctx context.Context, id string) (*auditLogExportDestination, error) { + return nil, boom + }, + UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + return nil, boom + }, + DeleteFunc: func(ctx context.Context, id string) error { + return boom + }, + TestFunc: func(ctx context.Context, id string) (*auditLogExportTestResult, error) { + return nil, boom + }, + }} + ctx := context.Background() + + assert.ErrorContains(t, c.Create(ctx, AuditLogsExportCreateInput{}), "boom") + assert.ErrorContains(t, c.List(ctx, AuditLogsExportListInput{Limit: 20}), "boom") + assert.ErrorContains(t, c.Get(ctx, AuditLogsExportGetInput{ID: "dest_123"}), "boom") + assert.ErrorContains(t, c.Update(ctx, AuditLogsExportUpdateInput{ID: "dest_123", Bucket: stringPtr("b")}), "boom") + assert.ErrorContains(t, c.SetStatus(ctx, AuditLogsExportStatusInput{ID: "dest_123", Status: "paused"}), "boom") + assert.ErrorContains(t, c.Delete(ctx, AuditLogsExportDeleteInput{ID: "dest_123"}), "boom") + assert.ErrorContains(t, c.Test(ctx, AuditLogsExportTestInput{ID: "dest_123"}), "boom") +}