diff --git a/go.mod b/go.mod index 33c6909..3853d5f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/flashduty-sdk v0.6.0 + github.com/flashcatcloud/flashduty-sdk v0.7.0 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index ced92aa..4138bd9 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/flashduty-sdk v0.6.0 h1:2dzJJ5s7Wgq7KWbsnbGMSo0+yN8yuNMVc50M+dtF8rU= -github.com/flashcatcloud/flashduty-sdk v0.6.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.7.0 h1:yPW8ghyHB60/34fz5sBITXhMWtbsm2mxYVFORgs+jpE= +github.com/flashcatcloud/flashduty-sdk v0.7.0/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 60461db..1e672e0 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -191,6 +191,22 @@ func (m *mockClient) SearchAuditLogs(context.Context, *flashduty.SearchAuditLogs return nil, fmt.Errorf("mockClient: SearchAuditLogs not implemented") } +func (m *mockClient) StartStatusPageMigration(context.Context, *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + return nil, fmt.Errorf("mockClient: StartStatusPageMigration not implemented") +} + +func (m *mockClient) StartStatusPageEmailSubscriberMigration(context.Context, *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + return nil, fmt.Errorf("mockClient: StartStatusPageEmailSubscriberMigration not implemented") +} + +func (m *mockClient) GetStatusPageMigrationStatus(context.Context, string) (*flashduty.StatusPageMigrationJob, error) { + return nil, fmt.Errorf("mockClient: GetStatusPageMigrationStatus not implemented") +} + +func (m *mockClient) CancelStatusPageMigration(context.Context, string) error { + return fmt.Errorf("mockClient: CancelStatusPageMigration not implemented") +} + // saveAndResetGlobals saves the current state of all global vars that commands // mutate, resets them to safe defaults, and returns a restore function for // t.Cleanup. diff --git a/internal/cli/root.go b/internal/cli/root.go index 17410b8..d851be9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -68,6 +68,12 @@ type flashdutyClient interface { QueryInsightIncidentList(ctx context.Context, input *flashduty.QueryInsightIncidentListInput) (*flashduty.QueryInsightIncidentListOutput, error) QueryNotificationTrend(ctx context.Context, input *flashduty.QueryNotificationTrendInput) (*flashduty.QueryNotificationTrendOutput, error) SearchAuditLogs(ctx context.Context, input *flashduty.SearchAuditLogsInput) (*flashduty.SearchAuditLogsOutput, error) + + // === PHASE 4: Status Page Migration === + StartStatusPageMigration(ctx context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) + StartStatusPageEmailSubscriberMigration(ctx context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) + GetStatusPageMigrationStatus(ctx context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) + CancelStatusPageMigration(ctx context.Context, jobID string) error } // newClientFn creates a flashdutyClient. Override in tests to inject a mock. diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index f3fcf9d..4dec034 100644 --- a/internal/cli/status_page_migrate.go +++ b/internal/cli/status_page_migrate.go @@ -1,317 +1,13 @@ package cli import ( - "bytes" - "context" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" + flashduty "github.com/flashcatcloud/flashduty-sdk" "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{ @@ -337,18 +33,17 @@ func newStatusPageMigrateStructureCmd() *cobra.Command { 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) + return runCommand(cmd, args, func(ctx *RunContext) error { + result, err := ctx.Client.StartStatusPageMigration(cmdContext(ctx.Cmd), &flashduty.StartStatusPageMigrationInput{ + SourceAPIKey: sourceAPIKey, + SourcePageID: sourcePageID, + }) + if err != nil { + return err + } + + return printMigrationStart(ctx, "structure", source, sourcePageID, 0, result) + }) }, } @@ -375,18 +70,18 @@ func newStatusPageMigrateEmailSubscribersCmd() *cobra.Command { 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) + return runCommand(cmd, args, func(ctx *RunContext) error { + result, err := ctx.Client.StartStatusPageEmailSubscriberMigration(cmdContext(ctx.Cmd), &flashduty.StartStatusPageEmailSubscriberMigrationInput{ + SourceAPIKey: sourceAPIKey, + SourcePageID: sourcePageID, + TargetPageID: targetPageID, + }) + if err != nil { + return err + } + + return printMigrationStart(ctx, "email-subscribers", source, sourcePageID, targetPageID, result) + }) }, } @@ -409,17 +104,14 @@ func newStatusPageMigrateStatusCmd() *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) + return runCommand(cmd, args, func(ctx *RunContext) error { + job, err := ctx.Client.GetStatusPageMigrationStatus(cmdContext(ctx.Cmd), jobID) + if err != nil { + return err + } + + return printMigrationStatus(ctx, job) + }) }, } @@ -436,35 +128,34 @@ func newStatusPageMigrateCancelCmd() *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 runCommand(cmd, args, func(ctx *RunContext) error { + if err := ctx.Client.CancelStatusPageMigration(cmdContext(ctx.Cmd), jobID); err != nil { + return err + } + + if ctx.JSON { + statusCmd := "flashduty statuspage migrate status --job-id " + jobID + return ctx.Printer.Print(map[string]any{ + "job_id": jobID, + "status": "cancel_requested", + "command": statusCmd, + "next_command": statusCmd, + }, nil) + } + + out := ctx.Writer + 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 - } - - 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 + }) }, } @@ -481,8 +172,8 @@ func validateMigrationSource(source string) error { return nil } -func printMigrationStart(cmd *cobra.Command, migrationType, source, sourcePageID string, targetPageID int64, result *migrationStartResult) error { - if flagJSON { +func printMigrationStart(ctx *RunContext, migrationType, source, sourcePageID string, targetPageID int64, result *flashduty.StartStatusPageMigrationOutput) error { + if ctx.JSON { payload := map[string]any{ "type": migrationType, "source": source, @@ -493,10 +184,10 @@ func printMigrationStart(cmd *cobra.Command, migrationType, source, sourcePageID payload["target_page_id"] = targetPageID } payload["next_command"] = "flashduty statuspage migrate status --job-id " + result.JobID - return newPrinter(cmd.OutOrStdout()).Print(payload, nil) + return ctx.Printer.Print(payload, nil) } - out := cmd.OutOrStdout() + out := ctx.Writer if _, err := fmt.Fprintln(out, "Migration started."); err != nil { return err } @@ -524,12 +215,12 @@ func printMigrationStart(cmd *cobra.Command, migrationType, source, sourcePageID return err } -func printMigrationStatus(cmd *cobra.Command, job *migrationJob) error { - if flagJSON { - return newPrinter(cmd.OutOrStdout()).Print(job, nil) +func printMigrationStatus(ctx *RunContext, job *flashduty.StatusPageMigrationJob) error { + if ctx.JSON { + return ctx.Printer.Print(job, nil) } - out := cmd.OutOrStdout() + out := ctx.Writer if _, err := fmt.Fprintf(out, "Job ID: %s\n", job.JobID); err != nil { return err } diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go index 9e68c99..661ef30 100644 --- a/internal/cli/status_page_migrate_test.go +++ b/internal/cli/status_page_migrate_test.go @@ -1,387 +1,414 @@ package cli import ( - "bytes" "context" "encoding/json" "fmt" - "io" - "net/http" "strings" "testing" + + flashduty "github.com/flashcatcloud/flashduty-sdk" ) -type roundTripperFunc func(*http.Request) (*http.Response, error) +type mockStatusPageMigrate struct { + mockClient -func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) + startStructure func(ctx context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) + startEmailSubscribers func(ctx context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) + getStatus func(ctx context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) + cancel func(ctx context.Context, jobID string) error } -func jsonResponse(statusCode int, body string) *http.Response { - return &http.Response{ - StatusCode: statusCode, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), +func (m *mockStatusPageMigrate) StartStatusPageMigration(ctx context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + if m.startStructure == nil { + return m.mockClient.StartStatusPageMigration(ctx, input) } + return m.startStructure(ctx, input) } -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", +func (m *mockStatusPageMigrate) StartStatusPageEmailSubscriberMigration(ctx context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + if m.startEmailSubscribers == nil { + return m.mockClient.StartStatusPageEmailSubscriberMigration(ctx, input) } + return m.startEmailSubscribers(ctx, input) +} - out, err := api.StartStructure(context.Background(), "atlassian-key", "page_123") - if err != nil { - t.Fatalf("StartStructure() error = %v", err) +func (m *mockStatusPageMigrate) GetStatusPageMigrationStatus(ctx context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) { + if m.getStatus == nil { + return m.mockClient.GetStatusPageMigrationStatus(ctx, jobID) } + return m.getStatus(ctx, jobID) +} - 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 (m *mockStatusPageMigrate) CancelStatusPageMigration(ctx context.Context, jobID string) error { + if m.cancel == nil { + return m.mockClient.CancelStatusPageMigration(ctx, jobID) } + return m.cancel(ctx, jobID) } -func TestStatusPageMigrationAPIGetStatus(t *testing.T) { - t.Parallel() - - var gotMethod string - var gotPath string - var gotJobID string +func TestCommandStatusPageMigrateStructureSendsSDKInput(t *testing.T) { + saveAndResetGlobals(t) - 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 - }), + var gotInput *flashduty.StartStatusPageMigrationInput + mock := &mockStatusPageMigrate{ + startStructure: func(_ context.Context, input *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + gotInput = input + return &flashduty.StartStatusPageMigrationOutput{JobID: "job-1"}, nil }, - baseURL: "https://status.example.com", - appKey: "fd-app-key", - userAgent: "flashduty-cli/test", } + newClientFn = func() (flashdutyClient, error) { return mock, nil } - out, err := api.GetStatus(context.Background(), "job-2") + out, err := execCommand("statuspage", "migrate", "structure", + "--from", "atlassian", + "--source-page-id", "src-1", + "--api-key", "atlassian-secret", + ) if err != nil { - t.Fatalf("GetStatus() error = %v", err) + t.Fatalf("execCommand: %v", err) } - if gotMethod != http.MethodGet { - t.Fatalf("method = %s, want GET", gotMethod) + if gotInput == nil { + t.Fatal("expected input to be captured") } - if gotPath != "/status-page/migration/status" { - t.Fatalf("path = %s", gotPath) + if gotInput.SourceAPIKey != "atlassian-secret" { + t.Errorf("SourceAPIKey = %q, want atlassian-secret", gotInput.SourceAPIKey) } - if gotJobID != "job-2" { - t.Fatalf("job_id query = %s", gotJobID) + if gotInput.SourcePageID != "src-1" { + t.Errorf("SourcePageID = %q, want src-1", gotInput.SourcePageID) } - if out.JobID != "job-2" || out.TargetPageID != 1024 { - t.Fatalf("unexpected job: %#v", out) + if !strings.Contains(out, "Job ID: job-1") { + t.Errorf("missing job id in output:\n%s", out) + } + if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-1") { + t.Errorf("missing status hint in output:\n%s", out) } } -func TestStatusPageMigrationAPIRedactsAppKeyFromTransportError(t *testing.T) { - t.Parallel() +func TestCommandStatusPageMigrateStructureRejectsUnsupportedSource(t *testing.T) { + saveAndResetGlobals(t) - 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()) - }), + called := false + mock := &mockStatusPageMigrate{ + startStructure: func(context.Context, *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + called = true + return nil, nil }, - baseURL: "https://status.example.com", - appKey: "secret-app-key", - userAgent: "flashduty-cli/test", } + newClientFn = func() (flashdutyClient, error) { return mock, nil } - _, err := api.GetStatus(context.Background(), "job-4") + _, err := execCommand("statuspage", "migrate", "structure", + "--from", "pagerduty", + "--source-page-id", "src-1", + "--api-key", "x", + ) if err == nil { - t.Fatal("GetStatus() error = nil, want transport error") + t.Fatal("expected error for unsupported source") + } + if !strings.Contains(err.Error(), "unsupported migration source") { + t.Errorf("unexpected error: %v", err) } - if strings.Contains(err.Error(), "secret-app-key") { - t.Fatalf("transport error leaked app key: %v", err) + if !strings.Contains(err.Error(), "atlassian") { + t.Errorf("error should mention supported source 'atlassian': %v", err) + } + if called { + t.Error("SDK should not have been called for unsupported source") } } -func TestStatusPageMigrationAPICapsErrorBodyReads(t *testing.T) { - t.Parallel() - - largeBody := strings.Repeat("0123456789", 2000) +// TestCommandStatusPageMigrateStructureValidatesBeforeClient locks ordering: +// an invalid --from must surface its validation error before any +// client-build / auth work — matching PR #1 behavior. +func TestCommandStatusPageMigrateStructureValidatesBeforeClient(t *testing.T) { + saveAndResetGlobals(t) - 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", + clientBuilt := false + newClientFn = func() (flashdutyClient, error) { + clientBuilt = true + return nil, fmt.Errorf("should not have been called") } - _, err := api.GetStatus(context.Background(), "job-5") + _, err := execCommand("statuspage", "migrate", "structure", + "--from", "pagerduty", + "--source-page-id", "src-1", + "--api-key", "x", + ) if err == nil { - t.Fatal("GetStatus() error = nil, want HTTP error") + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), "unsupported migration source") { + t.Errorf("got %v; want validation error about source", err) } - if got := len(err.Error()); got > 5000 { - t.Fatalf("HTTP error too large: got %d chars, want <= 5000", got) + if clientBuilt { + t.Error("newClientFn must not run when --from is invalid") } } -func TestStatusPageMigrationAPISanitizesErrorBodyFields(t *testing.T) { - t.Parallel() +// TestCommandStatusPageMigrateEmailSubscribersValidatesBeforeClient: same +// ordering guarantee for the subscribers variant. +func TestCommandStatusPageMigrateEmailSubscribersValidatesBeforeClient(t *testing.T) { + saveAndResetGlobals(t) - 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", + clientBuilt := false + newClientFn = func() (flashdutyClient, error) { + clientBuilt = true + return nil, fmt.Errorf("should not have been called") } - _, err := api.GetStatus(context.Background(), "job-6") + _, err := execCommand("statuspage", "migrate", "email-subscribers", + "--from", "pagerduty", + "--source-page-id", "src-1", + "--target-page-id", "1", + "--api-key", "x", + ) if err == nil { - t.Fatal("GetStatus() error = nil, want HTTP error") + t.Fatal("expected validation 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(), "unsupported migration source") { + t.Errorf("got %v; want validation error about source", err) } - if !strings.Contains(err.Error(), "[REDACTED]") { - t.Fatalf("HTTP error missing redaction marker: %v", err) + if clientBuilt { + t.Error("newClientFn must not run when --from is invalid") } } -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 - }), +func TestCommandStatusPageMigrateStructureJSON(t *testing.T) { + saveAndResetGlobals(t) + + mock := &mockStatusPageMigrate{ + startStructure: func(_ context.Context, _ *flashduty.StartStatusPageMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + return &flashduty.StartStatusPageMigrationOutput{JobID: "job-1"}, nil }, - baseURL: "https://status.example.com", - appKey: "fd-app-key", - userAgent: "flashduty-cli/test", } + newClientFn = func() (flashdutyClient, error) { return mock, nil } - if err := api.Cancel(context.Background(), "job-3"); err != nil { - t.Fatalf("Cancel() error = %v", err) + out, err := execCommand("--json", "statuspage", "migrate", "structure", + "--from", "atlassian", + "--source-page-id", "src-1", + "--api-key", "x", + ) + if err != nil { + t.Fatalf("execCommand: %v", err) } - if gotMethod != http.MethodPost { - t.Fatalf("method = %s, want POST", gotMethod) + var payload map[string]any + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if payload["type"] != "structure" { + t.Errorf("type = %v, want structure", payload["type"]) + } + if payload["source"] != "atlassian" { + t.Errorf("source = %v, want atlassian", payload["source"]) } - if gotPath != "/status-page/migration/cancel" { - t.Fatalf("path = %s", gotPath) + if payload["source_page_id"] != "src-1" { + t.Errorf("source_page_id = %v, want src-1", payload["source_page_id"]) } - if gotBody["job_id"] != "job-3" { - t.Fatalf("unexpected body: %#v", gotBody) + if payload["job_id"] != "job-1" { + t.Errorf("job_id = %v, want job-1", payload["job_id"]) + } + if next, _ := payload["next_command"].(string); !strings.Contains(next, "job-1") { + t.Errorf("next_command missing job id: %v", payload["next_command"]) } } -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 TestCommandStatusPageMigrateEmailSubscribersSendsSDKInput(t *testing.T) { + saveAndResetGlobals(t) -func (s stubMigrationService) StartStructure(ctx context.Context, sourceAPIKey, sourcePageID string) (*migrationStartResult, error) { - return s.startStructure(ctx, sourceAPIKey, sourcePageID) -} + var gotInput *flashduty.StartStatusPageEmailSubscriberMigrationInput + mock := &mockStatusPageMigrate{ + startEmailSubscribers: func(_ context.Context, input *flashduty.StartStatusPageEmailSubscriberMigrationInput) (*flashduty.StartStatusPageMigrationOutput, error) { + gotInput = input + return &flashduty.StartStatusPageMigrationOutput{JobID: "sub-1"}, nil + }, + } + newClientFn = func() (flashdutyClient, error) { return mock, nil } -func (s stubMigrationService) StartEmailSubscribers(ctx context.Context, sourceAPIKey, sourcePageID string, targetPageID int64) (*migrationStartResult, error) { - return s.startEmailSubscribers(ctx, sourceAPIKey, sourcePageID, targetPageID) -} + out, err := execCommand("statuspage", "migrate", "email-subscribers", + "--from", "atlassian", + "--source-page-id", "src-1", + "--target-page-id", "2048", + "--api-key", "atlassian-secret", + ) + if err != nil { + t.Fatalf("execCommand: %v", err) + } -func (s stubMigrationService) GetStatus(ctx context.Context, jobID string) (*migrationJob, error) { - return s.getStatus(ctx, jobID) + if gotInput == nil { + t.Fatal("expected input to be captured") + } + if gotInput.TargetPageID != 2048 { + t.Errorf("TargetPageID = %d, want 2048", gotInput.TargetPageID) + } + if !strings.Contains(out, "Target page ID: 2048") { + t.Errorf("missing target page id line in output:\n%s", out) + } + if !strings.Contains(out, "Job ID: sub-1") { + t.Errorf("missing job id in output:\n%s", out) + } } -func (s stubMigrationService) Cancel(ctx context.Context, jobID string) error { - return s.cancel(ctx, jobID) -} +func TestCommandStatusPageMigrateStatusRendersJobFields(t *testing.T) { + saveAndResetGlobals(t) -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 + var gotJobID string + mock := &mockStatusPageMigrate{ + getStatus: func(_ context.Context, jobID string) (*flashduty.StatusPageMigrationJob, error) { + gotJobID = jobID + return &flashduty.StatusPageMigrationJob{ + JobID: "job-9", + SourcePageID: "src-9", + TargetPageID: 1024, + Phase: "history", + Status: "running", + Progress: flashduty.StatusPageMigrationProgress{ + TotalSteps: 5, + CompletedSteps: 3, + ComponentsImported: 2, + SectionsImported: 1, + IncidentsImported: 4, + MaintenancesImported: 1, + SubscribersImported: 0, + SubscribersSkipped: 0, + TemplatesImported: 2, + Warnings: []string{"missing field X"}, + }, + }, nil + }, } + newClientFn = func() (flashdutyClient, error) { return mock, nil } + + out, err := execCommand("statuspage", "migrate", "status", "--job-id", "job-9") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + if gotJobID != "job-9" { + t.Errorf("jobID passed to SDK = %q, want job-9", gotJobID) + } + for _, want := range []string{ + "Job ID: job-9", + "Source page: src-9", + "Target page ID: 1024", + "Phase: history", + "Status: running", + "Progress: 3/5", + "Incidents imported: 4", + "Templates imported: 2", + "Warnings:", + "- missing field X", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in output:\n%s", want, out) + } + } +} - 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"}) +func TestCommandStatusPageMigrateStatusJSON(t *testing.T) { + saveAndResetGlobals(t) - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute() error = %v", err) + mock := &mockStatusPageMigrate{ + getStatus: func(_ context.Context, _ string) (*flashduty.StatusPageMigrationJob, error) { + return &flashduty.StatusPageMigrationJob{ + JobID: "job-j", + Phase: "completed", + Status: "completed", + }, nil + }, } + newClientFn = func() (flashdutyClient, error) { return mock, nil } - out := buf.String() - if !strings.Contains(out, "Job ID: job-123") { - t.Fatalf("missing job id in output: %s", out) + out, err := execCommand("--json", "statuspage", "migrate", "status", "--job-id", "job-j") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if payload["job_id"] != "job-j" { + t.Errorf("job_id = %v, want job-j", payload["job_id"]) } - if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-123") { - t.Fatalf("missing status hint in output: %s", out) + if payload["status"] != "completed" { + t.Errorf("status = %v, want completed", payload["status"]) } } -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 - } +func TestCommandStatusPageMigrateCancelIssuesCancelAndHint(t *testing.T) { + saveAndResetGlobals(t) - 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"}) + var gotJobID string + mock := &mockStatusPageMigrate{ + cancel: func(_ context.Context, jobID string) error { + gotJobID = jobID + return nil + }, + } + newClientFn = func() (flashdutyClient, error) { return mock, nil } - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute() error = %v", err) + out, err := execCommand("statuspage", "migrate", "cancel", "--job-id", "job-c") + if err != nil { + t.Fatalf("execCommand: %v", err) } - out := buf.String() - if !strings.Contains(out, "Target page ID: 2048") { - t.Fatalf("missing target page id in output: %s", out) + if gotJobID != "job-c" { + t.Errorf("SDK received jobID %q, want job-c", gotJobID) } - if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-456") { - t.Fatalf("missing status hint in output: %s", out) + if !strings.Contains(out, "Cancellation requested.") { + t.Errorf("missing confirmation in output:\n%s", out) + } + if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-c") { + t.Errorf("missing status hint in output:\n%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 TestCommandStatusPageMigrateCancelJSON(t *testing.T) { + saveAndResetGlobals(t) + + mock := &mockStatusPageMigrate{ + cancel: func(context.Context, string) error { return nil }, } -} + newClientFn = func() (flashdutyClient, error) { return mock, nil } -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 + out, err := execCommand("--json", "statuspage", "migrate", "cancel", "--job-id", "job-c") + if err != nil { + t.Fatalf("execCommand: %v", err) } - cmd := newStatusPageMigrateCancelCmd() - buf := &bytes.Buffer{} - cmd.SetOut(buf) - cmd.SetErr(buf) - cmd.SetArgs([]string{"--job-id", "job-789"}) + var payload map[string]any + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + if payload["job_id"] != "job-c" { + t.Errorf("job_id = %v, want job-c", payload["job_id"]) + } + if payload["status"] != "cancel_requested" { + t.Errorf("status = %v, want cancel_requested", payload["status"]) + } + if command, _ := payload["command"].(string); !strings.Contains(command, "job-c") { + t.Errorf("command missing job id: %v", payload["command"]) + } + if next, _ := payload["next_command"].(string); !strings.Contains(next, "job-c") { + t.Errorf("next_command missing job id: %v", payload["next_command"]) + } +} - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute() error = %v", err) +func TestCommandStatusPageMigrateStatusPropagatesSDKError(t *testing.T) { + saveAndResetGlobals(t) + + mock := &mockStatusPageMigrate{ + getStatus: func(context.Context, string) (*flashduty.StatusPageMigrationJob, error) { + return nil, &flashduty.DutyError{Code: "not_found", Message: "job missing"} + }, } + newClientFn = func() (flashdutyClient, error) { return mock, nil } - out := buf.String() - if !strings.Contains(out, "Cancellation requested.") { - t.Fatalf("missing cancel message in output: %s", out) + _, err := execCommand("statuspage", "migrate", "status", "--job-id", "nope") + if err == nil { + t.Fatal("expected SDK error to propagate") } - if !strings.Contains(out, "flashduty statuspage migrate status --job-id job-789") { - t.Fatalf("missing status hint in output: %s", out) + if !strings.Contains(err.Error(), "not_found") || !strings.Contains(err.Error(), "job missing") { + t.Errorf("unexpected error: %v", err) } }