diff --git a/README.md b/README.md index 2dfbef5..f0762d6 100644 --- a/README.md +++ b/README.md @@ -215,13 +215,32 @@ flashduty field list [flags] # List custom field definitions Supports `--name`. -### `statuspage` - Status Page Management (4 commands) +### `statuspage` - Status Page Management (5 command groups) ```bash flashduty statuspage list [--id ] # List status pages flashduty statuspage changes --page-id --type # List active changes flashduty statuspage create-incident --page-id --title # Create status incident flashduty statuspage create-timeline --page-id <id> --change <id> --message <msg> # Add timeline update +flashduty statuspage migrate structure --from atlassian --source-page-id <id> --api-key <key> # Start structure/history migration +flashduty statuspage migrate email-subscribers --from atlassian --source-page-id <id> --target-page-id <id> --api-key <key> # Start email subscriber migration +flashduty statuspage migrate status --job-id <id> # Check migration job status +flashduty statuspage migrate cancel --job-id <id> # Cancel a running migration job +``` + +Migration jobs are asynchronous. After starting `structure` or `email-subscribers`, use: + +```bash +flashduty statuspage migrate status --job-id <job_id> +``` + +Typical flow: + +```bash +flashduty statuspage migrate structure --from atlassian --source-page-id page_123 --api-key $ATLASSIAN_STATUSPAGE_API_KEY +flashduty statuspage migrate status --job-id <structure_job_id> +flashduty statuspage migrate email-subscribers --from atlassian --source-page-id page_123 --target-page-id <target_page_id> --api-key $ATLASSIAN_STATUSPAGE_API_KEY +flashduty statuspage migrate status --job-id <subscriber_job_id> ``` ### `template` - Notification Template Management (4 commands) diff --git a/internal/cli/root.go b/internal/cli/root.go index c05b265..17410b8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -133,18 +133,11 @@ func newClient() (flashdutyClient, error) { // defaultNewClient creates a real Flashduty SDK client from resolved config + flag overrides. func defaultNewClient() (flashdutyClient, error) { - cfg, err := config.Load() + cfg, err := loadResolvedConfig() if err != nil { return nil, err } - if flagAppKey != "" { - cfg.AppKey = flagAppKey - } - if flagBaseURL != "" { - cfg.BaseURL = flagBaseURL - } - if cfg.AppKey == "" { return nil, fmt.Errorf("no app key configured. Run 'flashduty login' or set FLASHDUTY_APP_KEY") } @@ -160,6 +153,22 @@ func defaultNewClient() (flashdutyClient, error) { return flashduty.NewClient(cfg.AppKey, opts...) } +func loadResolvedConfig() (*config.Config, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + + if flagAppKey != "" { + cfg.AppKey = flagAppKey + } + if flagBaseURL != "" { + cfg.BaseURL = flagBaseURL + } + + return cfg, nil +} + // newPrinter creates a Printer based on global flags. func newPrinter(w io.Writer) output.Printer { if w == nil { diff --git a/internal/cli/status_page.go b/internal/cli/status_page.go index 943f337..5f61435 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -20,6 +20,7 @@ func newStatusPageCmd() *cobra.Command { cmd.AddCommand(newStatusPageChangesCmd()) cmd.AddCommand(newStatusPageCreateIncidentCmd()) cmd.AddCommand(newStatusPageCreateTimelineCmd()) + cmd.AddCommand(newStatusPageMigrateCmd()) return cmd } diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go new file mode 100644 index 0000000..f3fcf9d --- /dev/null +++ b/internal/cli/status_page_migrate.go @@ -0,0 +1,590 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const migrationSourceAtlassian = "atlassian" +const migrationErrorBodyLimit = 4096 + +var migrationSensitiveBodyKeys = map[string]struct{}{ + "apikey": {}, + "xapikey": {}, + "accesskey": {}, + "password": {}, + "passwd": {}, + "pwd": {}, + "token": {}, + "accesstoken": {}, + "refreshtoken": {}, + "idtoken": {}, + "sessiontoken": {}, + "authtoken": {}, + "oauthtoken": {}, + "bearertoken": {}, + "authorization": {}, + "auth": {}, + "secret": {}, + "clientsecret": {}, + "secretkey": {}, + "privatekey": {}, + "signingkey": {}, + "credential": {}, + "credentials": {}, +} + +type statusPageMigrationService interface { + StartStructure(ctx context.Context, sourceAPIKey, sourcePageID string) (*migrationStartResult, error) + StartEmailSubscribers(ctx context.Context, sourceAPIKey, sourcePageID string, targetPageID int64) (*migrationStartResult, error) + GetStatus(ctx context.Context, jobID string) (*migrationJob, error) + Cancel(ctx context.Context, jobID string) error +} + +var newStatusPageMigrationService = func() (statusPageMigrationService, error) { + return newStatusPageMigrationAPI() +} + +type statusPageMigrationAPI struct { + httpClient *http.Client + baseURL string + appKey string + userAgent string +} + +type migrationStartResult struct { + JobID string `json:"job_id"` +} + +type migrationProgress struct { + TotalSteps int `json:"total_steps"` + CompletedSteps int `json:"completed_steps"` + ComponentsImported int `json:"components_imported"` + SectionsImported int `json:"sections_imported"` + IncidentsImported int `json:"incidents_imported"` + MaintenancesImported int `json:"maintenances_imported"` + SubscribersImported int `json:"subscribers_imported"` + SubscribersSkipped int `json:"subscribers_skipped"` + TemplatesImported int `json:"templates_imported"` + Warnings []string `json:"warnings,omitempty"` +} + +type migrationJob struct { + JobID string `json:"job_id"` + SourcePageID string `json:"source_page_id"` + TargetPageID int64 `json:"target_page_id"` + Phase string `json:"phase"` + Status string `json:"status"` + Progress migrationProgress `json:"progress"` + Error string `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type migrationEnvelope[T any] struct { + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + Data *T `json:"data,omitempty"` +} + +func newStatusPageMigrationAPI() (*statusPageMigrationAPI, error) { + cfg, err := loadResolvedConfig() + if err != nil { + return nil, err + } + if cfg.AppKey == "" { + return nil, fmt.Errorf("no app key configured. Run 'flashduty login' or set FLASHDUTY_APP_KEY") + } + + return &statusPageMigrationAPI{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + appKey: cfg.AppKey, + userAgent: "flashduty-cli/" + versionStr, + }, nil +} + +func (a *statusPageMigrationAPI) StartStructure(ctx context.Context, sourceAPIKey, sourcePageID string) (*migrationStartResult, error) { + return a.postStart(ctx, "/status-page/migrate-structure", map[string]any{ + "api_key": sourceAPIKey, + "source_page_id": sourcePageID, + }) +} + +func (a *statusPageMigrationAPI) StartEmailSubscribers(ctx context.Context, sourceAPIKey, sourcePageID string, targetPageID int64) (*migrationStartResult, error) { + return a.postStart(ctx, "/status-page/migrate-email-subscribers", map[string]any{ + "api_key": sourceAPIKey, + "source_page_id": sourcePageID, + "target_page_id": targetPageID, + }) +} + +func (a *statusPageMigrationAPI) GetStatus(ctx context.Context, jobID string) (*migrationJob, error) { + query := url.Values{} + query.Set("job_id", jobID) + + var result migrationEnvelope[migrationJob] + if err := a.do(ctx, http.MethodGet, "/status-page/migration/status", query, nil, &result); err != nil { + return nil, err + } + if result.Data == nil { + return nil, fmt.Errorf("migration status response missing data") + } + return result.Data, nil +} + +func (a *statusPageMigrationAPI) Cancel(ctx context.Context, jobID string) error { + var result migrationEnvelope[map[string]any] + return a.do(ctx, http.MethodPost, "/status-page/migration/cancel", nil, map[string]any{ + "job_id": jobID, + }, &result) +} + +func (a *statusPageMigrationAPI) postStart(ctx context.Context, path string, body map[string]any) (*migrationStartResult, error) { + var result migrationEnvelope[migrationStartResult] + if err := a.do(ctx, http.MethodPost, path, nil, body, &result); err != nil { + return nil, err + } + if result.Data == nil { + return nil, fmt.Errorf("migration start response missing data") + } + return result.Data, nil +} + +func (a *statusPageMigrationAPI) do(ctx context.Context, method, path string, query url.Values, body any, out any) error { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + fullURL, err := url.Parse(a.baseURL + path) + if err != nil { + return fmt.Errorf("parse request URL: %w", err) + } + + values := fullURL.Query() + values.Set("app_key", a.appKey) + for key, items := range query { + for _, item := range items { + values.Add(key, item) + } + } + fullURL.RawQuery = values.Encode() + + req, err := http.NewRequestWithContext(ctx, method, fullURL.String(), bodyReader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if a.userAgent != "" { + req.Header.Set("User-Agent", a.userAgent) + } + + resp, err := a.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %s", redactAppKey(err.Error(), a.appKey)) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, migrationErrorBodyLimit)) + if readErr != nil { + return fmt.Errorf("API client error (HTTP %d)", resp.StatusCode) + } + return fmt.Errorf("API client error (HTTP %d): %s", resp.StatusCode, sanitizeMigrationBody(string(bodyBytes))) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + + switch envelope := out.(type) { + case *migrationEnvelope[migrationStartResult]: + if envelope.Error != nil { + return fmt.Errorf("%s: %s", envelope.Error.Code, envelope.Error.Message) + } + case *migrationEnvelope[migrationJob]: + if envelope.Error != nil { + return fmt.Errorf("%s: %s", envelope.Error.Code, envelope.Error.Message) + } + case *migrationEnvelope[map[string]any]: + if envelope.Error != nil { + return fmt.Errorf("%s: %s", envelope.Error.Code, envelope.Error.Message) + } + } + + return nil +} + +func redactAppKey(message, appKey string) string { + if appKey == "" { + return message + } + + redacted := strings.ReplaceAll(message, appKey, "[redacted]") + redacted = strings.ReplaceAll(redacted, url.QueryEscape(appKey), "[redacted]") + return redacted +} + +func sanitizeMigrationBody(body string) string { + if body == "" { + return body + } + + var v any + if err := json.Unmarshal([]byte(body), &v); err != nil { + return body + } + + sanitized, redacted := sanitizeMigrationJSONValue(v) + if !redacted { + return body + } + + out, err := json.Marshal(sanitized) + if err != nil { + return body + } + return string(out) +} + +func sanitizeMigrationJSONValue(v any) (any, bool) { + switch value := v.(type) { + case map[string]any: + sanitized := make(map[string]any, len(value)) + redacted := false + for key, item := range value { + if isMigrationSensitiveBodyKey(key) { + sanitized[key] = "[REDACTED]" + redacted = true + continue + } + + sanitizedItem, itemRedacted := sanitizeMigrationJSONValue(item) + sanitized[key] = sanitizedItem + redacted = redacted || itemRedacted + } + return sanitized, redacted + case []any: + sanitized := make([]any, len(value)) + redacted := false + for i, item := range value { + sanitizedItem, itemRedacted := sanitizeMigrationJSONValue(item) + sanitized[i] = sanitizedItem + redacted = redacted || itemRedacted + } + return sanitized, redacted + default: + return v, false + } +} + +func isMigrationSensitiveBodyKey(key string) bool { + _, ok := migrationSensitiveBodyKeys[normalizeMigrationSensitiveBodyKey(key)] + return ok +} + +func normalizeMigrationSensitiveBodyKey(key string) string { + var b strings.Builder + b.Grow(len(key)) + for _, r := range strings.ToLower(strings.TrimSpace(key)) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +func newStatusPageMigrateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Manage status page migration jobs", + } + cmd.AddCommand(newStatusPageMigrateStructureCmd()) + cmd.AddCommand(newStatusPageMigrateEmailSubscribersCmd()) + cmd.AddCommand(newStatusPageMigrateStatusCmd()) + cmd.AddCommand(newStatusPageMigrateCancelCmd()) + return cmd +} + +func newStatusPageMigrateStructureCmd() *cobra.Command { + var source string + var sourcePageID string + var sourceAPIKey string + + cmd := &cobra.Command{ + Use: "structure", + Short: "Start structure and history migration", + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateMigrationSource(source); err != nil { + return err + } + + service, err := newStatusPageMigrationService() + if err != nil { + return err + } + + result, err := service.StartStructure(cmdContext(cmd), sourceAPIKey, sourcePageID) + if err != nil { + return err + } + + return printMigrationStart(cmd, "structure", source, sourcePageID, 0, result) + }, + } + + cmd.Flags().StringVar(&source, "from", "", "Migration source provider (required)") + cmd.Flags().StringVar(&sourcePageID, "source-page-id", "", "Source page ID in the provider (required)") + cmd.Flags().StringVar(&sourceAPIKey, "api-key", "", "Source provider API key (required)") + _ = cmd.MarkFlagRequired("from") + _ = cmd.MarkFlagRequired("source-page-id") + _ = cmd.MarkFlagRequired("api-key") + + return cmd +} + +func newStatusPageMigrateEmailSubscribersCmd() *cobra.Command { + var source string + var sourcePageID string + var sourceAPIKey string + var targetPageID int64 + + cmd := &cobra.Command{ + Use: "email-subscribers", + Short: "Start email subscriber migration", + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateMigrationSource(source); err != nil { + return err + } + + service, err := newStatusPageMigrationService() + if err != nil { + return err + } + + result, err := service.StartEmailSubscribers(cmdContext(cmd), sourceAPIKey, sourcePageID, targetPageID) + if err != nil { + return err + } + + return printMigrationStart(cmd, "email-subscribers", source, sourcePageID, targetPageID, result) + }, + } + + cmd.Flags().StringVar(&source, "from", "", "Migration source provider (required)") + cmd.Flags().StringVar(&sourcePageID, "source-page-id", "", "Source page ID in the provider (required)") + cmd.Flags().StringVar(&sourceAPIKey, "api-key", "", "Source provider API key (required)") + cmd.Flags().Int64Var(&targetPageID, "target-page-id", 0, "Target Flashduty status page ID (required)") + _ = cmd.MarkFlagRequired("from") + _ = cmd.MarkFlagRequired("source-page-id") + _ = cmd.MarkFlagRequired("api-key") + _ = cmd.MarkFlagRequired("target-page-id") + + return cmd +} + +func newStatusPageMigrateStatusCmd() *cobra.Command { + var jobID string + + cmd := &cobra.Command{ + Use: "status", + Short: "Show migration job status", + RunE: func(cmd *cobra.Command, args []string) error { + service, err := newStatusPageMigrationService() + if err != nil { + return err + } + + job, err := service.GetStatus(cmdContext(cmd), jobID) + if err != nil { + return err + } + + return printMigrationStatus(cmd, job) + }, + } + + cmd.Flags().StringVar(&jobID, "job-id", "", "Migration job ID (required)") + _ = cmd.MarkFlagRequired("job-id") + + return cmd +} + +func newStatusPageMigrateCancelCmd() *cobra.Command { + var jobID string + + cmd := &cobra.Command{ + Use: "cancel", + Short: "Cancel a running migration job", + RunE: func(cmd *cobra.Command, args []string) error { + service, err := newStatusPageMigrationService() + if err != nil { + return err + } + + if err := service.Cancel(cmdContext(cmd), jobID); err != nil { + return err + } + + if flagJSON { + return newPrinter(cmd.OutOrStdout()).Print(map[string]any{ + "job_id": jobID, + "status": "cancel_requested", + "command": "flashduty statuspage migrate status --job-id " + jobID, + }, nil) + } + + out := cmd.OutOrStdout() + if _, err := fmt.Fprintln(out, "Cancellation requested."); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Job ID: %s\n\n", jobID); err != nil { + return err + } + if _, err := fmt.Fprintln(out, "Check progress with:"); err != nil { + return err + } + _, err = fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", jobID) + return err + }, + } + + cmd.Flags().StringVar(&jobID, "job-id", "", "Migration job ID (required)") + _ = cmd.MarkFlagRequired("job-id") + + return cmd +} + +func validateMigrationSource(source string) error { + if source != migrationSourceAtlassian { + return fmt.Errorf("unsupported migration source: %q (supported: %s)", source, migrationSourceAtlassian) + } + return nil +} + +func printMigrationStart(cmd *cobra.Command, migrationType, source, sourcePageID string, targetPageID int64, result *migrationStartResult) error { + if flagJSON { + payload := map[string]any{ + "type": migrationType, + "source": source, + "source_page_id": sourcePageID, + "job_id": result.JobID, + } + if targetPageID > 0 { + payload["target_page_id"] = targetPageID + } + payload["next_command"] = "flashduty statuspage migrate status --job-id " + result.JobID + return newPrinter(cmd.OutOrStdout()).Print(payload, nil) + } + + out := cmd.OutOrStdout() + if _, err := fmt.Fprintln(out, "Migration started."); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Type: %s\n", migrationType); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Source: %s\n", source); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Source page: %s\n", sourcePageID); err != nil { + return err + } + if targetPageID > 0 { + if _, err := fmt.Fprintf(out, "Target page ID: %d\n", targetPageID); err != nil { + return err + } + } + if _, err := fmt.Fprintf(out, "Job ID: %s\n\n", result.JobID); err != nil { + return err + } + if _, err := fmt.Fprintln(out, "Check progress with:"); err != nil { + return err + } + _, err := fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", result.JobID) + return err +} + +func printMigrationStatus(cmd *cobra.Command, job *migrationJob) error { + if flagJSON { + return newPrinter(cmd.OutOrStdout()).Print(job, nil) + } + + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "Job ID: %s\n", job.JobID); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Source page: %s\n", job.SourcePageID); err != nil { + return err + } + if job.TargetPageID > 0 { + if _, err := fmt.Fprintf(out, "Target page ID: %d\n", job.TargetPageID); err != nil { + return err + } + } + if _, err := fmt.Fprintf(out, "Phase: %s\n", job.Phase); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Status: %s\n", job.Status); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Progress: %d/%d\n", job.Progress.CompletedSteps, job.Progress.TotalSteps); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Sections imported: %d\n", job.Progress.SectionsImported); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Components imported: %d\n", job.Progress.ComponentsImported); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Incidents imported: %d\n", job.Progress.IncidentsImported); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Maintenances imported: %d\n", job.Progress.MaintenancesImported); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Subscribers imported: %d\n", job.Progress.SubscribersImported); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Subscribers skipped: %d\n", job.Progress.SubscribersSkipped); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Templates imported: %d\n", job.Progress.TemplatesImported); err != nil { + return err + } + if job.Error != "" { + if _, err := fmt.Fprintf(out, "Error: %s\n", job.Error); err != nil { + return err + } + } + if len(job.Progress.Warnings) > 0 { + if _, err := fmt.Fprintln(out, "Warnings:"); err != nil { + return err + } + for _, warning := range job.Progress.Warnings { + if _, err := fmt.Fprintf(out, "- %s\n", warning); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go new file mode 100644 index 0000000..9e68c99 --- /dev/null +++ b/internal/cli/status_page_migrate_test.go @@ -0,0 +1,387 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func jsonResponse(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestStatusPageMigrationAPIStartStructure(t *testing.T) { + t.Parallel() + + var gotMethod string + var gotPath string + var gotAppKey string + var gotBody map[string]any + + api := &statusPageMigrationAPI{ + httpClient: &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAppKey = r.URL.Query().Get("app_key") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + return jsonResponse(http.StatusOK, `{"data":{"job_id":"job-1"}}`), nil + }), + }, + baseURL: "https://status.example.com", + appKey: "fd-app-key", + userAgent: "flashduty-cli/test", + } + + out, err := api.StartStructure(context.Background(), "atlassian-key", "page_123") + if err != nil { + t.Fatalf("StartStructure() error = %v", err) + } + + if gotMethod != http.MethodPost { + t.Fatalf("method = %s, want POST", gotMethod) + } + if gotPath != "/status-page/migrate-structure" { + t.Fatalf("path = %s", gotPath) + } + if gotAppKey != "fd-app-key" { + t.Fatalf("app_key = %s", gotAppKey) + } + if gotBody["api_key"] != "atlassian-key" || gotBody["source_page_id"] != "page_123" { + t.Fatalf("unexpected body: %#v", gotBody) + } + if out.JobID != "job-1" { + t.Fatalf("job_id = %s", out.JobID) + } +} + +func TestStatusPageMigrationAPIGetStatus(t *testing.T) { + t.Parallel() + + var gotMethod string + var gotPath string + var gotJobID string + + api := &statusPageMigrationAPI{ + httpClient: &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + gotMethod = r.Method + gotPath = r.URL.Path + gotJobID = r.URL.Query().Get("job_id") + return jsonResponse(http.StatusOK, `{"data":{"job_id":"job-2","source_page_id":"src-1","target_page_id":1024,"phase":"history","status":"running","progress":{"total_steps":5,"completed_steps":3}}}`), nil + }), + }, + baseURL: "https://status.example.com", + appKey: "fd-app-key", + userAgent: "flashduty-cli/test", + } + + out, err := api.GetStatus(context.Background(), "job-2") + if err != nil { + t.Fatalf("GetStatus() error = %v", err) + } + + if gotMethod != http.MethodGet { + t.Fatalf("method = %s, want GET", gotMethod) + } + if gotPath != "/status-page/migration/status" { + t.Fatalf("path = %s", gotPath) + } + if gotJobID != "job-2" { + t.Fatalf("job_id query = %s", gotJobID) + } + if out.JobID != "job-2" || out.TargetPageID != 1024 { + t.Fatalf("unexpected job: %#v", out) + } +} + +func TestStatusPageMigrationAPIRedactsAppKeyFromTransportError(t *testing.T) { + t.Parallel() + + api := &statusPageMigrationAPI{ + httpClient: &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("transport failed for %s", req.URL.String()) + }), + }, + baseURL: "https://status.example.com", + appKey: "secret-app-key", + userAgent: "flashduty-cli/test", + } + + _, err := api.GetStatus(context.Background(), "job-4") + if err == nil { + t.Fatal("GetStatus() error = nil, want transport error") + } + if strings.Contains(err.Error(), "secret-app-key") { + t.Fatalf("transport error leaked app key: %v", err) + } +} + +func TestStatusPageMigrationAPICapsErrorBodyReads(t *testing.T) { + t.Parallel() + + largeBody := strings.Repeat("0123456789", 2000) + + api := &statusPageMigrationAPI{ + httpClient: &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway, largeBody), nil + }), + }, + baseURL: "https://status.example.com", + appKey: "fd-app-key", + userAgent: "flashduty-cli/test", + } + + _, err := api.GetStatus(context.Background(), "job-5") + if err == nil { + t.Fatal("GetStatus() error = nil, want HTTP error") + } + if got := len(err.Error()); got > 5000 { + t.Fatalf("HTTP error too large: got %d chars, want <= 5000", got) + } +} + +func TestStatusPageMigrationAPISanitizesErrorBodyFields(t *testing.T) { + t.Parallel() + + api := &statusPageMigrationAPI{ + httpClient: &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway, `{"error":{"message":"upstream failed","details":{"ApiKey":"response-secret","nested":[{"ACCESS_TOKEN":"response-token"}]}}}`), nil + }), + }, + baseURL: "https://status.example.com", + appKey: "fd-app-key", + userAgent: "flashduty-cli/test", + } + + _, err := api.GetStatus(context.Background(), "job-6") + if err == nil { + t.Fatal("GetStatus() error = nil, want HTTP error") + } + if strings.Contains(err.Error(), "response-secret") || strings.Contains(err.Error(), "response-token") { + t.Fatalf("HTTP error leaked secret body fields: %v", err) + } + if !strings.Contains(err.Error(), "[REDACTED]") { + t.Fatalf("HTTP error missing redaction marker: %v", err) + } +} + +func TestStatusPageMigrationAPICancel(t *testing.T) { + t.Parallel() + + var gotMethod string + var gotPath string + var gotBody map[string]any + + api := &statusPageMigrationAPI{ + httpClient: &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + gotMethod = r.Method + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + return jsonResponse(http.StatusOK, `{"data":{}}`), nil + }), + }, + baseURL: "https://status.example.com", + appKey: "fd-app-key", + userAgent: "flashduty-cli/test", + } + + if err := api.Cancel(context.Background(), "job-3"); err != nil { + t.Fatalf("Cancel() error = %v", err) + } + + if gotMethod != http.MethodPost { + t.Fatalf("method = %s, want POST", gotMethod) + } + if gotPath != "/status-page/migration/cancel" { + t.Fatalf("path = %s", gotPath) + } + if gotBody["job_id"] != "job-3" { + t.Fatalf("unexpected body: %#v", gotBody) + } +} + +type stubMigrationService struct { + startStructure func(ctx context.Context, sourceAPIKey, sourcePageID string) (*migrationStartResult, error) + startEmailSubscribers func(ctx context.Context, sourceAPIKey, sourcePageID string, targetPageID int64) (*migrationStartResult, error) + getStatus func(ctx context.Context, jobID string) (*migrationJob, error) + cancel func(ctx context.Context, jobID string) error +} + +func (s stubMigrationService) StartStructure(ctx context.Context, sourceAPIKey, sourcePageID string) (*migrationStartResult, error) { + return s.startStructure(ctx, sourceAPIKey, sourcePageID) +} + +func (s stubMigrationService) StartEmailSubscribers(ctx context.Context, sourceAPIKey, sourcePageID string, targetPageID int64) (*migrationStartResult, error) { + return s.startEmailSubscribers(ctx, sourceAPIKey, sourcePageID, targetPageID) +} + +func (s stubMigrationService) GetStatus(ctx context.Context, jobID string) (*migrationJob, error) { + return s.getStatus(ctx, jobID) +} + +func (s stubMigrationService) Cancel(ctx context.Context, jobID string) error { + return s.cancel(ctx, jobID) +} + +func TestStatusPageMigrateStructureCommandPrintsStatusHint(t *testing.T) { + original := newStatusPageMigrationService + t.Cleanup(func() { newStatusPageMigrationService = original }) + newStatusPageMigrationService = func() (statusPageMigrationService, error) { + return stubMigrationService{ + startStructure: func(ctx context.Context, apiKey, pageID string) (*migrationStartResult, error) { + return &migrationStartResult{JobID: "job-123"}, nil + }, + }, nil + } + + cmd := newStatusPageMigrateStructureCmd() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--from", "atlassian", "--source-page-id", "src-1", "--api-key", "key-1"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Job ID: job-123") { + t.Fatalf("missing job id in output: %s", out) + } + if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-123") { + t.Fatalf("missing status hint in output: %s", out) + } +} + +func TestStatusPageMigrateEmailSubscribersCommandPrintsStatusHint(t *testing.T) { + original := newStatusPageMigrationService + t.Cleanup(func() { newStatusPageMigrationService = original }) + newStatusPageMigrationService = func() (statusPageMigrationService, error) { + return stubMigrationService{ + startEmailSubscribers: func(ctx context.Context, apiKey, pageID string, targetPageID int64) (*migrationStartResult, error) { + if targetPageID != 2048 { + t.Fatalf("target_page_id = %d, want 2048", targetPageID) + } + return &migrationStartResult{JobID: "job-456"}, nil + }, + }, nil + } + + cmd := newStatusPageMigrateEmailSubscribersCmd() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--from", "atlassian", "--source-page-id", "src-1", "--target-page-id", "2048", "--api-key", "key-1"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Target page ID: 2048") { + t.Fatalf("missing target page id in output: %s", out) + } + if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-456") { + t.Fatalf("missing status hint in output: %s", out) + } +} + +func TestStatusPageMigrateStatusCommandPrintsJobDetails(t *testing.T) { + original := newStatusPageMigrationService + t.Cleanup(func() { newStatusPageMigrationService = original }) + newStatusPageMigrationService = func() (statusPageMigrationService, error) { + return stubMigrationService{ + getStatus: func(ctx context.Context, jobID string) (*migrationJob, error) { + return &migrationJob{ + JobID: jobID, + SourcePageID: "src-1", + TargetPageID: 1024, + Phase: "history", + Status: "completed", + Progress: migrationProgress{ + TotalSteps: 5, + CompletedSteps: 5, + SectionsImported: 2, + ComponentsImported: 4, + IncidentsImported: 3, + MaintenancesImported: 1, + TemplatesImported: 2, + Warnings: []string{"incident skipped"}, + }, + }, nil + }, + }, nil + } + + cmd := newStatusPageMigrateStatusCmd() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--job-id", "job-123"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Target page ID: 1024") || !strings.Contains(out, "Warnings:") { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestStatusPageMigrateCancelCommandPrintsStatusHint(t *testing.T) { + original := newStatusPageMigrationService + t.Cleanup(func() { newStatusPageMigrationService = original }) + newStatusPageMigrationService = func() (statusPageMigrationService, error) { + return stubMigrationService{ + cancel: func(ctx context.Context, jobID string) error { + if jobID != "job-789" { + t.Fatalf("jobID = %s, want job-789", jobID) + } + return nil + }, + }, nil + } + + cmd := newStatusPageMigrateCancelCmd() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--job-id", "job-789"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Cancellation requested.") { + t.Fatalf("missing cancel message in output: %s", out) + } + if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-789") { + t.Fatalf("missing status hint in output: %s", out) + } +}