diff --git a/cmd/audit_logs_export.go b/cmd/audit_logs_export.go new file mode 100644 index 0000000..93ff64d --- /dev/null +++ b/cmd/audit_logs_export.go @@ -0,0 +1,654 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "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 ( + auditLogExportStatusActive = "active" + auditLogExportStatusPaused = "paused" +) + +type auditLogExportListPageInfo struct { + HasMore bool + NextOffset int +} + +type AuditLogsExportService interface { + 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) (*kernel.AuditLogExportDestinationTestResult, error) +} + +type auditLogsExportClient struct { + svc *kernel.AuditLogExportDestinationService +} + +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, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + var httpRes *http.Response + page, err := s.svc.List(ctx, query, option.WithResponseInto(&httpRes)) + if err != nil { + return nil, auditLogExportListPageInfo{}, err + } + + info, err := parseAuditLogExportListPagination(httpRes) + if err != nil { + return nil, auditLogExportListPageInfo{}, err + } + if page == nil { + return nil, info, nil + } + 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) +} + +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.svc.Delete(ctx, id) +} + +func (s *auditLogsExportClient) Test(ctx context.Context, id string) (*kernel.AuditLogExportDestinationTestResult, error) { + return s.svc.Test(ctx, id) +} + +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 := 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.CreateAuditLogExportDestinationRequest.KmsKeyID = kernel.String(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") + } + + 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} + } + + if in.Output == "json" { + data, err := marshalAuditLogExportListJSON(destinations, pageInfo.NextOffset) + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + 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, + string(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 +} + +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 +} + +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 := 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 = kernel.String("") + } + 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, kernel.AuditLogExportDestinationUpdateParams{UpdateAuditLogExportDestinationRequest: 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, kernel.AuditLogExportDestinationUpdateParams{ + UpdateAuditLogExportDestinationRequest: kernel.UpdateAuditLogExportDestinationRequestParam{ + Status: kernel.UpdateAuditLogExportDestinationRequestStatus(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.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) + } + + 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 *kernel.AuditLogExportDestination) { + rows := pterm.TableData{ + {"Property", "Value"}, + {"ID", d.ID}, + {"Type", string(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", 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)}, + {"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.IsZero() { + return "-" + } + return util.FormatLocal(t) +} + +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) +} + +func truncateAuditLogExportError(s string) string { + const maxLen = 60 + runes := []rune(s) + if len(runes) <= maxLen { + return util.OrDash(s) + } + return string(runes[:maxLen-3]) + "..." +} + +func getAuditLogsExportHandler(cmd *cobra.Command) AuditLogsExportCmd { + client := getKernelClient(cmd) + return AuditLogsExportCmd{export: &auditLogsExportClient{svc: &client.AuditLogs.ExportDestinations}} +} + +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..258564c --- /dev/null +++ b/cmd/audit_logs_export_test.go @@ -0,0 +1,764 @@ +package cmd + +import ( + "context" + "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" +) + +type FakeAuditLogsExportService struct { + 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) (*kernel.AuditLogExportDestinationTestResult, 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, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + if f.ListFunc != nil { + return f.ListFunc(ctx, query) + } + return nil, auditLogExportListPageInfo{}, errors.New("List not implemented") +} + +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 kernel.AuditLogExportDestinationUpdateParams) (*kernel.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) (*kernel.AuditLogExportDestinationTestResult, 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) kernel.AuditLogExportDestination { + var d kernel.AuditLogExportDestination + if err := json.Unmarshal([]byte(raw), &d); err != nil { + panic(err) + } + return d +} + +func sampleAuditLogExportDestination() kernel.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 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, + 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 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 + }, + } + 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 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 + }, + } + 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 kernel.AuditLogExportDestinationNewParams) (*kernel.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, 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} + + 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, 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} + + 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, 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 []kernel.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 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{ + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + return []kernel.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 TestAuditLogsExportListJSONEmptyPrintsEnvelope(t *testing.T) { + fake := &FakeAuditLogsExportService{ + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + return []kernel.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.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 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 + 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) { + 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) (*kernel.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) (*kernel.AuditLogExportDestination, error) { + return &kernel.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) (*kernel.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 kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + req := body.UpdateAuditLogExportDestinationRequest + assert.Equal(t, "dest_123", id) + 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 + }, + } + 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 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 + }, + } + 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(kernel.UpdateAuditLogExportDestinationRequestParam{KmsKeyID: kernel.String("")}) + require.NoError(t, err) + assert.JSONEq(t, `{"kms_key_id":""}`, string(raw)) + + raw, err = json.Marshal(kernel.UpdateAuditLogExportDestinationRequestParam{}) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(raw)) + + 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 kernel.AuditLogExportDestinationUpdateParams) (*kernel.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 kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + req := body.UpdateAuditLogExportDestinationRequest + assert.Equal(t, "dest_123", id) + 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 + }, + } + 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 kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + req := body.UpdateAuditLogExportDestinationRequest + assert.Equal(t, kernel.UpdateAuditLogExportDestinationRequestStatusActive, req.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) (*kernel.AuditLogExportDestinationTestResult, error) { + assert.Equal(t, "dest_123", id) + result := auditLogExportTestResultFromJSON(`{"success":true,"stage":"complete"}`) + return &result, 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) (*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} + + 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) (*kernel.AuditLogExportDestinationTestResult, error) { + result := auditLogExportTestResultFromJSON(`{"success":false,"stage":"put_object","error":{"code":"put_object_failed","message":"NoSuchBucket"}}`) + return &result, 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 kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return nil, boom + }, + ListFunc: func(ctx context.Context, query kernel.AuditLogExportDestinationListParams) ([]kernel.AuditLogExportDestination, auditLogExportListPageInfo, error) { + return nil, auditLogExportListPageInfo{}, boom + }, + GetFunc: func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { + return nil, boom + }, + 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) (*kernel.AuditLogExportDestinationTestResult, 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") +} 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=