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 1/4] 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") +} From 72d6e63b23f1fa1e28ebc3e679538ceb03c3024b Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:21:41 +0000 Subject: [PATCH 2/4] Use generated SDK service for audit-logs export commands --- cmd/audit_logs_export.go | 243 ++++++++++++---------------------- cmd/audit_logs_export_test.go | 190 +++++++++++++------------- go.mod | 2 +- go.sum | 4 +- 4 files changed, 185 insertions(+), 254 deletions(-) diff --git a/cmd/audit_logs_export.go b/cmd/audit_logs_export.go index 4c33f9f..cd9350a 100644 --- a/cmd/audit_logs_export.go +++ b/cmd/audit_logs_export.go @@ -2,11 +2,9 @@ package cmd import ( "context" - "encoding/json" "errors" "fmt" "net/http" - "net/url" "strconv" "strings" "time" @@ -18,130 +16,40 @@ import ( "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) + Create(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) + List(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) + Get(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) + Update(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) Delete(ctx context.Context, id string) error - Test(ctx context.Context, id string) (*auditLogExportTestResult, error) + Test(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) } type auditLogsExportClient struct { - client *kernel.Client + svc *kernel.AuditLogExportDestinationService } -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) Create(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return s.svc.New(ctx, body) } -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 - } +func (s *auditLogsExportClient) List(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { var httpRes *http.Response - destinations := make([]auditLogExportDestination, 0) - if err := s.client.Get(ctx, path, nil, &destinations, option.WithResponseInto(&httpRes)); err != nil { + page, err := s.svc.List(ctx, query, option.WithResponseInto(&httpRes)) + if err != nil { return nil, auditLogExportListPageInfo{}, err } + info := auditLogExportListPageInfo{} if httpRes != nil { info.HasMore = strings.EqualFold(httpRes.Header.Get("X-Has-More"), "true") @@ -151,35 +59,26 @@ func (s *auditLogsExportClient) List(ctx context.Context, limit, offset int) ([] } } } - return destinations, info, nil + if page == nil { + return nil, info, nil + } + return page.Items, 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) Get(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { + return s.svc.Get(ctx, id) } -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) Update(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + return s.svc.Update(ctx, id, body) } func (s *auditLogsExportClient) Delete(ctx context.Context, id string) error { - return s.client.Delete(ctx, auditLogsExportBasePath+"/"+url.PathEscape(id), nil, nil) + return s.svc.Delete(ctx, id) } -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 +func (s *auditLogsExportClient) Test(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) { + return s.svc.Test(ctx, id) } type AuditLogsExportCmd struct { @@ -200,16 +99,18 @@ func (c AuditLogsExportCmd) Create(ctx context.Context, in AuditLogsExportCreate return err } - req := createAuditLogExportDestinationRequest{ - Type: "s3", - Format: "jsonl.gz", - Region: in.Region, - Bucket: in.Bucket, - Prefix: in.Prefix, - RoleARN: in.RoleARN, + req := kernel.AuditLogExportDestinationNewParams{ + CreateAuditLogExportDestinationRequest: kernel.CreateAuditLogExportDestinationRequestParam{ + Type: kernel.CreateAuditLogExportDestinationRequestTypeS3, + Format: kernel.CreateAuditLogExportDestinationRequestFormatJSONLGz, + Region: in.Region, + Bucket: in.Bucket, + Prefix: in.Prefix, + RoleArn: in.RoleARN, + }, } if in.KMSKeyID != "" { - req.KMSKeyID = &in.KMSKeyID + req.CreateAuditLogExportDestinationRequest.KmsKeyID = kernel.String(in.KMSKeyID) } dest, err := c.export.Create(ctx, req) @@ -223,7 +124,7 @@ func (c AuditLogsExportCmd) Create(ctx context.Context, in AuditLogsExportCreate 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) + 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 } @@ -244,7 +145,14 @@ func (c AuditLogsExportCmd) List(ctx context.Context, in AuditLogsExportListInpu return fmt.Errorf("--offset must be non-negative") } - destinations, pageInfo, err := c.export.List(ctx, in.Limit, in.Offset) + query := kernel.AuditLogExportDestinationListParams{} + if in.Limit > 0 { + query.Limit = kernel.Opt(int64(in.Limit)) + } + if in.Offset > 0 { + query.Offset = kernel.Opt(int64(in.Offset)) + } + destinations, pageInfo, err := c.export.List(ctx, query) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -265,7 +173,7 @@ func (c AuditLogsExportCmd) List(ctx context.Context, in AuditLogsExportListInpu d.Bucket, util.OrDash(d.Prefix), d.Region, - d.Status, + string(d.Status), formatAuditLogExportTime(d.LastSuccessAt), strconv.FormatInt(d.ConsecutiveFailures, 10), truncateAuditLogExportError(d.LastError), @@ -321,21 +229,30 @@ func (c AuditLogsExportCmd) Update(ctx context.Context, in AuditLogsExportUpdate 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, + req := kernel.UpdateAuditLogExportDestinationRequestParam{} + if in.Region != nil { + req.Region = kernel.String(*in.Region) + } + if in.Bucket != nil { + req.Bucket = kernel.String(*in.Bucket) + } + if in.Prefix != nil { + req.Prefix = kernel.String(*in.Prefix) + } + if in.RoleARN != nil { + req.RoleArn = kernel.String(*in.RoleARN) + } + if in.KMSKeyID != nil { + req.KmsKeyID = kernel.String(*in.KMSKeyID) } if in.ClearKMSKey { - req.KMSKeyID = new(string) + req.KmsKeyID = kernel.String("") } - if req.Region == nil && req.Bucket == nil && req.Prefix == nil && req.RoleARN == nil && req.KMSKeyID == nil { + if !req.Region.Valid() && !req.Bucket.Valid() && !req.Prefix.Valid() && !req.RoleArn.Valid() && !req.KmsKeyID.Valid() { 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) + dest, err := c.export.Update(ctx, in.ID, kernel.AuditLogExportDestinationUpdateParams{UpdateAuditLogExportDestinationRequest: req}) if err != nil { return cleanedUpAuditLogExportUpdateError(err) } @@ -363,7 +280,11 @@ func (c AuditLogsExportCmd) SetStatus(ctx context.Context, in AuditLogsExportSta return fmt.Errorf("invalid status %q", in.Status) } - dest, err := c.export.Update(ctx, in.ID, updateAuditLogExportDestinationRequest{Status: &in.Status}) + dest, err := c.export.Update(ctx, in.ID, kernel.AuditLogExportDestinationUpdateParams{ + UpdateAuditLogExportDestinationRequest: kernel.UpdateAuditLogExportDestinationRequestParam{ + Status: kernel.UpdateAuditLogExportDestinationRequestStatus(in.Status), + }, + }) if err != nil { return cleanedUpAuditLogExportUpdateError(err) } @@ -417,7 +338,7 @@ func (c AuditLogsExportCmd) Test(ctx context.Context, in AuditLogsExportTestInpu } } else if res.Success { pterm.Success.Printf("Test passed (stage: %s)\n", res.Stage) - } else if res.Error != nil { + } else if res.Error.Code != "" || res.Error.Message != "" { 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) @@ -437,20 +358,20 @@ func cleanedUpAuditLogExportUpdateError(err error) error { return util.CleanedUpSdkError{Err: err} } -func printAuditLogExportDestinationDetail(d *auditLogExportDestination) { +func printAuditLogExportDestinationDetail(d *kernel.AuditLogExportDestination) { rows := pterm.TableData{ {"Property", "Value"}, {"ID", d.ID}, - {"Type", d.Type}, + {"Type", string(d.Type)}, {"Region", d.Region}, {"Bucket", d.Bucket}, {"Prefix", util.OrDash(d.Prefix)}, - {"Role ARN", d.RoleARN}, - {"Kernel Role ARN", d.KernelRoleARN}, + {"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}, + {"KMS Key ID", util.OrDash(d.KmsKeyID)}, + {"Format", string(d.Format)}, + {"Status", string(d.Status)}, {"Last Exported Cursor", util.OrDash(d.LastExportedCursor)}, {"Last Success", formatAuditLogExportLastSuccess(d.LastSuccessAt)}, {"Last Error", util.OrDash(d.LastError)}, @@ -463,19 +384,19 @@ func printAuditLogExportDestinationDetail(d *auditLogExportDestination) { PrintTableNoPad(rows, true) } -func formatAuditLogExportTime(t *time.Time) string { - if t == nil { +func formatAuditLogExportTime(t time.Time) string { + if t.IsZero() { return "-" } - return util.FormatLocal(*t) + return util.FormatLocal(t) } -func formatAuditLogExportLastSuccess(t *time.Time) string { - if t == nil { +func formatAuditLogExportLastSuccess(t time.Time) string { + if t.IsZero() { return "-" } - lag := max(time.Since(*t).Round(time.Second), 0) - return fmt.Sprintf("%s (%s ago)", util.FormatLocal(*t), lag) + lag := max(time.Since(t).Round(time.Second), 0) + return fmt.Sprintf("%s (%s ago)", util.FormatLocal(t), lag) } func truncateAuditLogExportError(s string) string { @@ -488,7 +409,7 @@ func truncateAuditLogExportError(s string) string { func getAuditLogsExportHandler(cmd *cobra.Command) AuditLogsExportCmd { client := getKernelClient(cmd) - return AuditLogsExportCmd{export: &auditLogsExportClient{client: &client}} + return AuditLogsExportCmd{export: &auditLogsExportClient{svc: &client.AuditLogs.ExportDestinations}} } func runAuditLogsExportCreate(cmd *cobra.Command, args []string) error { diff --git a/cmd/audit_logs_export_test.go b/cmd/audit_logs_export_test.go index cb7ae0c..78c8133 100644 --- a/cmd/audit_logs_export_test.go +++ b/cmd/audit_logs_export_test.go @@ -14,36 +14,36 @@ import ( ) 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) + CreateFunc func(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) + ListFunc func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) + GetFunc func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) + UpdateFunc func(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) DeleteFunc func(ctx context.Context, id string) error - TestFunc func(ctx context.Context, id string) (*auditLogExportTestResult, error) + TestFunc func(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) } -func (f *FakeAuditLogsExportService) Create(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { +func (f *FakeAuditLogsExportService) Create(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.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) { +func (f *FakeAuditLogsExportService) List(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { if f.ListFunc != nil { - return f.ListFunc(ctx, limit, offset) + return f.ListFunc(ctx, query) } return nil, auditLogExportListPageInfo{}, errors.New("List not implemented") } -func (f *FakeAuditLogsExportService) Get(ctx context.Context, id string) (*auditLogExportDestination, error) { +func (f *FakeAuditLogsExportService) Get(ctx context.Context, id string) (*kernel.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) { +func (f *FakeAuditLogsExportService) Update(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { if f.UpdateFunc != nil { return f.UpdateFunc(ctx, id, body) } @@ -57,7 +57,7 @@ func (f *FakeAuditLogsExportService) Delete(ctx context.Context, id string) erro return errors.New("Delete not implemented") } -func (f *FakeAuditLogsExportService) Test(ctx context.Context, id string) (*auditLogExportTestResult, error) { +func (f *FakeAuditLogsExportService) Test(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) { if f.TestFunc != nil { return f.TestFunc(ctx, id) } @@ -68,15 +68,15 @@ func stringPtr(s string) *string { return &s } -func auditLogExportDestinationFromJSON(raw string) auditLogExportDestination { - var d auditLogExportDestination +func auditLogExportDestinationFromJSON(raw string) kernel.AuditLogExportDestination { + var d kernel.AuditLogExportDestination if err := json.Unmarshal([]byte(raw), &d); err != nil { panic(err) } return d } -func sampleAuditLogExportDestination() auditLogExportDestination { +func sampleAuditLogExportDestination() kernel.AuditLogExportDestination { return auditLogExportDestinationFromJSON(`{ "id": "dest_123", "type": "s3", @@ -100,6 +100,14 @@ func sampleAuditLogExportDestination() auditLogExportDestination { }`) } +func auditLogExportTestResultFromJSON(raw string) kernel.AuditLogExportDestinationTestResult { + var result kernel.AuditLogExportDestinationTestResult + if err := json.Unmarshal([]byte(raw), &result); err != nil { + panic(err) + } + return result +} + func auditLogExportAPIError(status int) *kernel.Error { return &kernel.Error{ StatusCode: status, @@ -111,14 +119,15 @@ func auditLogExportAPIError(status int) *kernel.Error { 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) + CreateFunc: func(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + req := body.CreateAuditLogExportDestinationRequest + assert.Equal(t, kernel.CreateAuditLogExportDestinationRequestTypeS3, req.Type) + assert.Equal(t, kernel.CreateAuditLogExportDestinationRequestFormatJSONLGz, req.Format) + assert.Equal(t, "us-east-1", req.Region) + assert.Equal(t, "acme-audit-logs", req.Bucket) + assert.Equal(t, "kernel/audit", req.Prefix) + assert.Equal(t, "arn:aws:iam::123456789012:role/audit-export", req.RoleArn) + assert.False(t, req.KmsKeyID.Valid()) dest := sampleAuditLogExportDestination() return &dest, nil }, @@ -145,9 +154,10 @@ func TestAuditLogsExportCreateBuildsRequestAndPrintsOnboarding(t *testing.T) { 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) + CreateFunc: func(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + req := body.CreateAuditLogExportDestinationRequest + require.True(t, req.KmsKeyID.Valid()) + assert.Equal(t, "arn:aws:kms:us-east-1:123456789012:key/abc-def", req.KmsKeyID.Value) dest := sampleAuditLogExportDestination() return &dest, nil }, @@ -166,7 +176,7 @@ func TestAuditLogsExportCreateIncludesKMSKeyWhenSet(t *testing.T) { func TestAuditLogsExportCreateJSONPrintsObject(t *testing.T) { fake := &FakeAuditLogsExportService{ - CreateFunc: func(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + CreateFunc: func(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { dest := sampleAuditLogExportDestination() return &dest, nil }, @@ -193,10 +203,11 @@ func TestAuditLogsExportCreateJSONPrintsObject(t *testing.T) { 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 + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + require.True(t, query.Limit.Valid()) + assert.Equal(t, int64(20), query.Limit.Value) + assert.False(t, query.Offset.Valid()) + return []kernel.AuditLogExportDestination{sampleAuditLogExportDestination()}, auditLogExportListPageInfo{HasMore: true, NextOffset: 20}, nil }, } c := AuditLogsExportCmd{export: fake} @@ -217,10 +228,12 @@ func TestAuditLogsExportListRendersTableAndPaginationHint(t *testing.T) { 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 + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + require.True(t, query.Limit.Valid()) + assert.Equal(t, int64(50), query.Limit.Value) + require.True(t, query.Offset.Valid()) + assert.Equal(t, int64(40), query.Offset.Value) + return []kernel.AuditLogExportDestination{sampleAuditLogExportDestination()}, auditLogExportListPageInfo{}, nil }, } c := AuditLogsExportCmd{export: fake} @@ -232,10 +245,10 @@ func TestAuditLogsExportListPassesLimitAndOffset(t *testing.T) { func TestAuditLogsExportListTruncatesLongLastError(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeAuditLogsExportService{ - ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.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 + return []kernel.AuditLogExportDestination{dest}, auditLogExportListPageInfo{}, nil }, } c := AuditLogsExportCmd{export: fake} @@ -251,8 +264,8 @@ func TestAuditLogsExportListTruncatesLongLastError(t *testing.T) { 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 + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + return []kernel.AuditLogExportDestination{}, auditLogExportListPageInfo{}, nil }, } c := AuditLogsExportCmd{export: fake} @@ -264,8 +277,8 @@ func TestAuditLogsExportListPrintsEmptyMessage(t *testing.T) { func TestAuditLogsExportListJSONEmptyPrintsEmptyArray(t *testing.T) { fake := &FakeAuditLogsExportService{ - ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { - return []auditLogExportDestination{}, auditLogExportListPageInfo{}, nil + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + return []kernel.AuditLogExportDestination{}, auditLogExportListPageInfo{}, nil }, } c := AuditLogsExportCmd{export: fake} @@ -297,7 +310,7 @@ func TestAuditLogsExportListRejectsInvalidLimitAndOffset(t *testing.T) { func TestAuditLogsExportGetRendersDeliveryStatus(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeAuditLogsExportService{ - GetFunc: func(ctx context.Context, id string) (*auditLogExportDestination, error) { + GetFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { assert.Equal(t, "dest_123", id) dest := sampleAuditLogExportDestination() return &dest, nil @@ -322,8 +335,8 @@ func TestAuditLogsExportGetRendersDeliveryStatus(t *testing.T) { 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 + GetFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { + return &kernel.AuditLogExportDestination{ID: id, Type: "s3", Status: "paused"}, nil }, } c := AuditLogsExportCmd{export: fake} @@ -339,7 +352,7 @@ func TestAuditLogsExportGetRendersDashWhenNeverDelivered(t *testing.T) { func TestAuditLogsExportGetJSONPrintsObject(t *testing.T) { fake := &FakeAuditLogsExportService{ - GetFunc: func(ctx context.Context, id string) (*auditLogExportDestination, error) { + GetFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { dest := sampleAuditLogExportDestination() return &dest, nil }, @@ -360,16 +373,17 @@ func TestAuditLogsExportGetJSONPrintsObject(t *testing.T) { func TestAuditLogsExportUpdateBuildsPartialRequest(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeAuditLogsExportService{ - UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + UpdateFunc: func(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + req := body.UpdateAuditLogExportDestinationRequest 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) + require.True(t, req.Bucket.Valid()) + assert.Equal(t, "new-bucket", req.Bucket.Value) + require.True(t, req.Prefix.Valid()) + assert.Equal(t, "new/prefix", req.Prefix.Value) + assert.False(t, req.Region.Valid()) + assert.False(t, req.RoleArn.Valid()) + assert.False(t, req.KmsKeyID.Valid()) + assert.Empty(t, req.Status) dest := sampleAuditLogExportDestination() return &dest, nil }, @@ -388,9 +402,10 @@ func TestAuditLogsExportUpdateBuildsPartialRequest(t *testing.T) { 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) + UpdateFunc: func(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + req := body.UpdateAuditLogExportDestinationRequest + require.True(t, req.KmsKeyID.Valid()) + assert.Equal(t, "", req.KmsKeyID.Value) dest := sampleAuditLogExportDestination() return &dest, nil }, @@ -423,22 +438,22 @@ func TestAuditLogsExportUpdateRequiresAChange(t *testing.T) { } func TestAuditLogsExportUpdateRequestSerialization(t *testing.T) { - raw, err := json.Marshal(updateAuditLogExportDestinationRequest{KMSKeyID: stringPtr("")}) + raw, err := json.Marshal(kernel.UpdateAuditLogExportDestinationRequestParam{KmsKeyID: kernel.String("")}) require.NoError(t, err) assert.JSONEq(t, `{"kms_key_id":""}`, string(raw)) - raw, err = json.Marshal(updateAuditLogExportDestinationRequest{}) + raw, err = json.Marshal(kernel.UpdateAuditLogExportDestinationRequestParam{}) require.NoError(t, err) assert.JSONEq(t, `{}`, string(raw)) - raw, err = json.Marshal(updateAuditLogExportDestinationRequest{Status: stringPtr("paused")}) + raw, err = json.Marshal(kernel.UpdateAuditLogExportDestinationRequestParam{Status: kernel.UpdateAuditLogExportDestinationRequestStatusPaused}) 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) { + UpdateFunc: func(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { return nil, auditLogExportAPIError(http.StatusConflict) }, } @@ -452,15 +467,15 @@ func TestAuditLogsExportUpdateHintsOnConflict(t *testing.T) { func TestAuditLogsExportPauseSendsStatusPaused(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeAuditLogsExportService{ - UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + UpdateFunc: func(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + req := body.UpdateAuditLogExportDestinationRequest 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) + assert.Equal(t, kernel.UpdateAuditLogExportDestinationRequestStatusPaused, req.Status) + assert.False(t, req.Region.Valid()) + assert.False(t, req.Bucket.Valid()) + assert.False(t, req.Prefix.Valid()) + assert.False(t, req.RoleArn.Valid()) + assert.False(t, req.KmsKeyID.Valid()) dest := sampleAuditLogExportDestination() dest.Status = "paused" return &dest, nil @@ -479,9 +494,9 @@ func TestAuditLogsExportPauseSendsStatusPaused(t *testing.T) { 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) + UpdateFunc: func(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + req := body.UpdateAuditLogExportDestinationRequest + assert.Equal(t, kernel.UpdateAuditLogExportDestinationRequestStatusActive, req.Status) dest := sampleAuditLogExportDestination() return &dest, nil }, @@ -522,9 +537,10 @@ func TestAuditLogsExportDeletePrintsSuccess(t *testing.T) { func TestAuditLogsExportTestPassesPrintsSuccess(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeAuditLogsExportService{ - TestFunc: func(ctx context.Context, id string) (*auditLogExportTestResult, error) { + TestFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) { assert.Equal(t, "dest_123", id) - return &auditLogExportTestResult{Success: true, Stage: "complete"}, nil + result := auditLogExportTestResultFromJSON(`{"success":true,"stage":"complete"}`) + return &result, nil }, } c := AuditLogsExportCmd{export: fake} @@ -537,12 +553,9 @@ func TestAuditLogsExportTestPassesPrintsSuccess(t *testing.T) { 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 + TestFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) { + result := auditLogExportTestResultFromJSON(`{"success":false,"stage":"assume_role","error":{"code":"assume_role_failed","message":"AccessDenied: not authorized to perform sts:AssumeRole"}}`) + return &result, nil }, } c := AuditLogsExportCmd{export: fake} @@ -559,12 +572,9 @@ func TestAuditLogsExportTestFailurePrintsDetailsAndReturnsError(t *testing.T) { 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 + TestFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) { + result := auditLogExportTestResultFromJSON(`{"success":false,"stage":"put_object","error":{"code":"put_object_failed","message":"NoSuchBucket"}}`) + return &result, nil }, } c := AuditLogsExportCmd{export: fake} @@ -601,22 +611,22 @@ func TestAuditLogsExportRejectsInvalidJSONOutput(t *testing.T) { func TestAuditLogsExportPropagatesAPIErrors(t *testing.T) { boom := errors.New("boom") c := AuditLogsExportCmd{export: &FakeAuditLogsExportService{ - CreateFunc: func(ctx context.Context, body createAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + CreateFunc: func(ctx context.Context, body kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { return nil, boom }, - ListFunc: func(ctx context.Context, limit, offset int) ([]auditLogExportDestination, auditLogExportListPageInfo, error) { + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { return nil, auditLogExportListPageInfo{}, boom }, - GetFunc: func(ctx context.Context, id string) (*auditLogExportDestination, error) { + GetFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { return nil, boom }, - UpdateFunc: func(ctx context.Context, id string, body updateAuditLogExportDestinationRequest) (*auditLogExportDestination, error) { + UpdateFunc: func(ctx context.Context, id string, body kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { return nil, boom }, DeleteFunc: func(ctx context.Context, id string) error { return boom }, - TestFunc: func(ctx context.Context, id string) (*auditLogExportTestResult, error) { + TestFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) { return nil, boom }, }} diff --git a/go.mod b/go.mod index 54ec0a7..017cfda 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.85.0 + github.com/kernel/kernel-go-sdk v0.87.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index a872a5b..da853f3 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.85.0 h1:rACZOx5dcjO4rasFmMoh2GS14w4yRBbYXxb92eGvD2E= -github.com/kernel/kernel-go-sdk v0.85.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.87.0 h1:Z5qRzvK9vSZbiOryVQ7XFT4WWh9W2Lcy+e5Brvk1TkY= +github.com/kernel/kernel-go-sdk v0.87.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 4267a19ada0f59bda90639a2fa85fc696f8a67b8 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:09:33 +0000 Subject: [PATCH 3/4] Harden audit export pagination --- cmd/audit_logs_export.go | 62 +++++++++++++++++++++---- cmd/audit_logs_export_test.go | 87 ++++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/cmd/audit_logs_export.go b/cmd/audit_logs_export.go index cd9350a..498a5b5 100644 --- a/cmd/audit_logs_export.go +++ b/cmd/audit_logs_export.go @@ -2,11 +2,11 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "net/http" "strconv" - "strings" "time" "github.com/kernel/cli/pkg/util" @@ -50,14 +50,9 @@ func (s *auditLogsExportClient) List(ctx context.Context, query kernel.AuditLogE 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 - } - } + info, err := parseAuditLogExportListPagination(httpRes) + if err != nil { + return nil, auditLogExportListPageInfo{}, err } if page == nil { return nil, info, nil @@ -65,6 +60,32 @@ func (s *auditLogsExportClient) List(ctx context.Context, query kernel.AuditLogE return page.Items, info, nil } +func parseAuditLogExportListPagination(response *http.Response) (auditLogExportListPageInfo, error) { + if response == nil { + return auditLogExportListPageInfo{}, fmt.Errorf("audit log export list response is missing pagination headers") + } + + hasMoreValue := response.Header.Get("X-Has-More") + hasMore, err := strconv.ParseBool(hasMoreValue) + if err != nil { + return auditLogExportListPageInfo{}, fmt.Errorf("invalid X-Has-More header %q", hasMoreValue) + } + + nextOffsetValue := response.Header.Get("X-Next-Offset") + nextOffset, err := strconv.Atoi(nextOffsetValue) + if err != nil || nextOffset < 0 { + return auditLogExportListPageInfo{}, fmt.Errorf("invalid X-Next-Offset header %q", nextOffsetValue) + } + if hasMore && nextOffset == 0 { + return auditLogExportListPageInfo{}, fmt.Errorf("X-Has-More is true but X-Next-Offset is not positive") + } + if !hasMore && nextOffset != 0 { + return auditLogExportListPageInfo{}, fmt.Errorf("X-Has-More is false but X-Next-Offset is %d", nextOffset) + } + + return auditLogExportListPageInfo{HasMore: hasMore, NextOffset: nextOffset}, nil +} + func (s *auditLogsExportClient) Get(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { return s.svc.Get(ctx, id) } @@ -158,7 +179,12 @@ func (c AuditLogsExportCmd) List(ctx context.Context, in AuditLogsExportListInpu } if in.Output == "json" { - return util.PrintPrettyJSONSlice(destinations) + data, err := marshalAuditLogExportListJSON(destinations, pageInfo.NextOffset) + if err != nil { + return err + } + fmt.Println(string(data)) + return nil } if len(destinations) == 0 { @@ -187,6 +213,22 @@ func (c AuditLogsExportCmd) List(ctx context.Context, in AuditLogsExportListInpu return nil } +func marshalAuditLogExportListJSON(destinations []kernel.AuditLogExportDestination, nextOffset int) ([]byte, error) { + items := make([]json.RawMessage, 0, len(destinations)) + for _, destination := range destinations { + raw := destination.RawJSON() + if raw == "" { + raw = "{}" + } + items = append(items, json.RawMessage(raw)) + } + payload := struct { + Destinations []json.RawMessage `json:"destinations"` + NextOffset int `json:"next_offset,omitempty"` + }{Destinations: items, NextOffset: nextOffset} + return json.MarshalIndent(payload, "", " ") +} + type AuditLogsExportGetInput struct { ID string Output string diff --git a/cmd/audit_logs_export_test.go b/cmd/audit_logs_export_test.go index 78c8133..527d153 100644 --- a/cmd/audit_logs_export_test.go +++ b/cmd/audit_logs_export_test.go @@ -275,7 +275,7 @@ func TestAuditLogsExportListPrintsEmptyMessage(t *testing.T) { assert.Contains(t, buf.String(), "No audit log export destinations found") } -func TestAuditLogsExportListJSONEmptyPrintsEmptyArray(t *testing.T) { +func TestAuditLogsExportListJSONEmptyPrintsEnvelope(t *testing.T) { fake := &FakeAuditLogsExportService{ ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { return []kernel.AuditLogExportDestination{}, auditLogExportListPageInfo{}, nil @@ -288,7 +288,90 @@ func TestAuditLogsExportListJSONEmptyPrintsEmptyArray(t *testing.T) { err = c.List(context.Background(), AuditLogsExportListInput{Limit: 20, Output: "json"}) }) require.NoError(t, err) - assert.Contains(t, out, "[]") + assert.JSONEq(t, `{"destinations":[]}`, out) +} + +func TestAuditLogsExportListJSONIncludesNextOffset(t *testing.T) { + destination := sampleAuditLogExportDestination() + fake := &FakeAuditLogsExportService{ + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + return []kernel.AuditLogExportDestination{destination}, auditLogExportListPageInfo{HasMore: true, NextOffset: 20}, 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) + + var payload struct { + Destinations []json.RawMessage `json:"destinations"` + NextOffset int `json:"next_offset"` + } + require.NoError(t, json.Unmarshal([]byte(out), &payload)) + require.Len(t, payload.Destinations, 1) + assert.JSONEq(t, destination.RawJSON(), string(payload.Destinations[0])) + assert.Equal(t, 20, payload.NextOffset) +} + +func TestParseAuditLogExportListPagination(t *testing.T) { + tests := []struct { + name string + response *http.Response + want auditLogExportListPageInfo + wantErr string + }{ + { + name: "more results", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"120"}}}, + want: auditLogExportListPageInfo{HasMore: true, NextOffset: 120}, + }, + { + name: "terminal page", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"0"}}}, + want: auditLogExportListPageInfo{}, + }, + {name: "missing response", wantErr: "missing pagination headers"}, + { + name: "missing has more", + response: &http.Response{Header: http.Header{"X-Next-Offset": []string{"120"}}}, + wantErr: "invalid X-Has-More", + }, + { + name: "has more with missing offset", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}}}, + wantErr: "invalid X-Next-Offset", + }, + { + name: "has more with malformed offset", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"next"}}}, + wantErr: "invalid X-Next-Offset", + }, + { + name: "has more with terminal offset", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"true"}, "X-Next-Offset": []string{"0"}}}, + wantErr: "X-Next-Offset is not positive", + }, + { + name: "terminal page with offset", + response: &http.Response{Header: http.Header{"X-Has-More": []string{"false"}, "X-Next-Offset": []string{"120"}}}, + wantErr: "X-Has-More is false", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAuditLogExportListPagination(tt.response) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } } func TestAuditLogsExportListRejectsInvalidLimitAndOffset(t *testing.T) { From 09876615cdef3ddc9502901028db878d8ff87812 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:46:15 +0000 Subject: [PATCH 4/4] Preserve UTF-8 in audit export errors --- cmd/audit_logs_export.go | 5 +++-- cmd/audit_logs_export_test.go | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cmd/audit_logs_export.go b/cmd/audit_logs_export.go index 498a5b5..93ff64d 100644 --- a/cmd/audit_logs_export.go +++ b/cmd/audit_logs_export.go @@ -443,10 +443,11 @@ func formatAuditLogExportLastSuccess(t time.Time) string { func truncateAuditLogExportError(s string) string { const maxLen = 60 - if len(s) <= maxLen { + runes := []rune(s) + if len(runes) <= maxLen { return util.OrDash(s) } - return s[:maxLen-3] + "..." + return string(runes[:maxLen-3]) + "..." } func getAuditLogsExportHandler(cmd *cobra.Command) AuditLogsExportCmd { diff --git a/cmd/audit_logs_export_test.go b/cmd/audit_logs_export_test.go index 527d153..258564c 100644 --- a/cmd/audit_logs_export_test.go +++ b/cmd/audit_logs_export_test.go @@ -5,10 +5,14 @@ import ( "encoding/json" "errors" "net/http" + "net/http/httptest" "net/url" + "strings" "testing" + "unicode/utf8" "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -261,6 +265,15 @@ func TestAuditLogsExportListTruncatesLongLastError(t *testing.T) { assert.NotContains(t, out, "must be truncated") } +func TestTruncateAuditLogExportErrorPreservesUTF8(t *testing.T) { + input := strings.Repeat("a", 56) + "界" + strings.Repeat("b", 10) + got := truncateAuditLogExportError(input) + + assert.True(t, utf8.ValidString(got)) + assert.Equal(t, strings.Repeat("a", 56)+"界...", got) + assert.Len(t, []rune(got), 60) +} + func TestAuditLogsExportListPrintsEmptyMessage(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeAuditLogsExportService{ @@ -316,6 +329,32 @@ func TestAuditLogsExportListJSONIncludesNextOffset(t *testing.T) { assert.Equal(t, 20, payload.NextOffset) } +func TestAuditLogsExportClientListCapturesPaginationHeaders(t *testing.T) { + destination := sampleAuditLogExportDestination() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/audit-logs/export/destinations", r.URL.Path) + assert.Equal(t, "2", r.URL.Query().Get("limit")) + assert.Equal(t, "40", r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Has-More", "true") + w.Header().Set("X-Next-Offset", "42") + _, _ = w.Write([]byte("[" + destination.RawJSON() + "]")) + })) + defer server.Close() + + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + export := auditLogsExportClient{svc: &client.AuditLogs.ExportDestinations} + items, pageInfo, err := export.List(context.Background(), kernel.AuditLogExportDestinationListParams{ + Limit: kernel.Opt(int64(2)), + Offset: kernel.Opt(int64(40)), + }) + + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, destination.ID, items[0].ID) + assert.Equal(t, auditLogExportListPageInfo{HasMore: true, NextOffset: 42}, pageInfo) +} + func TestParseAuditLogExportListPagination(t *testing.T) { tests := []struct { name string