From 5013ee8e1abd3d10a9c6af590c3cceb591131381 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Mon, 13 Apr 2026 16:56:18 +0800 Subject: [PATCH 1/3] feat: add statuspage migration commands --- README.md | 21 +- .../2026-04-13-statuspage-migration-cli.md | 367 +++++++++++++++ internal/cli/root.go | 33 +- internal/cli/status_page.go | 3 +- internal/cli/status_page_migrate.go | 441 ++++++++++++++++++ internal/cli/status_page_migrate_test.go | 318 +++++++++++++ 6 files changed, 1169 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md create mode 100644 internal/cli/status_page_migrate.go create mode 100644 internal/cli/status_page_migrate_test.go diff --git a/README.md b/README.md index 74ea7fc..8671fec 100644 --- a/README.md +++ b/README.md @@ -173,13 +173,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/docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md b/docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md new file mode 100644 index 0000000..e5233ac --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md @@ -0,0 +1,367 @@ +# Statuspage Migration CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `flashduty statuspage migrate` subcommands for Atlassian-to-Flashduty status page migration, covering structure import, email-subscriber import, status lookup, and cancellation with simple async-job UX. + +**Architecture:** Keep the new migration transport inside `flashduty-cli` so the CLI remains buildable without waiting for a separate `flashduty-sdk` release. Add one focused migration API helper plus Cobra subcommands that call it and print concise operator guidance, especially the follow-up `migrate status` command after `structure` and `email-subscribers`. + +**Tech Stack:** Go, Cobra, `net/http`, existing CLI config/output helpers, Go `testing` + `httptest` + +--- + +### Task 1: Add a Focused Migration API Helper + +**Files:** +- Create: `internal/cli/status_page_migrate.go` +- Modify: `internal/cli/root.go` +- Test: `internal/cli/status_page_migrate_test.go` + +- [ ] **Step 1: Write failing API helper tests for structure/status/cancel paths** + +```go +func TestStatusPageMigrationAPIStartStructure(t *testing.T) { + t.Parallel() + + var gotMethod string + var gotPath string + var gotAppKey string + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"job_id": "job-1"}, + }) + })) + defer ts.Close() + + api := &statusPageMigrationAPI{ + httpClient: ts.Client(), + baseURL: ts.URL, + 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) + } +} +``` + +- [ ] **Step 2: Run the focused API helper tests and confirm they fail** + +Run: `go test ./internal/cli -run 'TestStatusPageMigrationAPI(StartStructure|GetStatus|Cancel)'` +Expected: FAIL with undefined `statusPageMigrationAPI` and related migration methods/types. + +- [ ] **Step 3: Implement the migration API helper and config resolution** + +```go +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"` +} + +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 +} +``` + +- [ ] **Step 4: Re-run the focused API helper tests and confirm they pass** + +Run: `go test ./internal/cli -run 'TestStatusPageMigrationAPI(StartStructure|GetStatus|Cancel)'` +Expected: PASS + +- [ ] **Step 5: Commit the helper foundation** + +```bash +git add internal/cli/root.go internal/cli/status_page_migrate.go internal/cli/status_page_migrate_test.go +git commit -m "feat: add statuspage migration api helper" +``` + +### Task 2: Add `statuspage migrate` Cobra Commands + +**Files:** +- Modify: `internal/cli/status_page.go` +- Modify: `internal/cli/status_page_migrate.go` +- Test: `internal/cli/status_page_migrate_test.go` + +- [ ] **Step 1: Write failing command tests for structure and email-subscribers output** + +```go +func TestStatusPageMigrateStructureCommandPrintsStatusHint(t *testing.T) { + t.Parallel() + + 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) + } +} +``` + +- [ ] **Step 2: Run the new command tests and confirm they fail** + +Run: `go test ./internal/cli -run 'TestStatusPageMigrate(StructureCommandPrintsStatusHint|EmailSubscribersCommandPrintsStatusHint)'` +Expected: FAIL with undefined migrate command constructors/service injection points. + +- [ ] **Step 3: Implement the command tree and simple async-job UX** + +```go +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 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 + } + return newPrinter(cmd.OutOrStdout()).Print(payload, nil) + } + + out := cmd.OutOrStdout() + fmt.Fprintln(out, "Migration started.") + fmt.Fprintf(out, "Type: %s\n", migrationType) + fmt.Fprintf(out, "Source: %s\n", source) + fmt.Fprintf(out, "Source page: %s\n", sourcePageID) + if targetPageID > 0 { + fmt.Fprintf(out, "Target page ID: %d\n", targetPageID) + } + fmt.Fprintf(out, "Job ID: %s\n\n", result.JobID) + fmt.Fprintln(out, "Check progress with:") + fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", result.JobID) + return nil +} +``` + +- [ ] **Step 4: Re-run the command tests and confirm they pass** + +Run: `go test ./internal/cli -run 'TestStatusPageMigrate(StructureCommandPrintsStatusHint|EmailSubscribersCommandPrintsStatusHint)'` +Expected: PASS + +- [ ] **Step 5: Commit the command layer** + +```bash +git add internal/cli/status_page.go internal/cli/status_page_migrate.go internal/cli/status_page_migrate_test.go +git commit -m "feat: add statuspage migrate commands" +``` + +### Task 3: Add Status/Cancel UX and Update Documentation + +**Files:** +- Modify: `internal/cli/status_page_migrate.go` +- Modify: `README.md` +- Test: `internal/cli/status_page_migrate_test.go` + +- [ ] **Step 1: Write failing tests for status output and cancel guidance** + +```go +func TestStatusPageMigrateStatusCommandPrintsJobDetails(t *testing.T) { + t.Parallel() + + 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) + } +} +``` + +- [ ] **Step 2: Run the new status/cancel/documentation-related tests and confirm they fail** + +Run: `go test ./internal/cli -run 'TestStatusPageMigrate(StatusCommandPrintsJobDetails|CancelCommandPrintsStatusHint)'` +Expected: FAIL until status/cancel formatting is implemented. + +- [ ] **Step 3: Implement status/cancel formatting and README command docs** + +```go +func printMigrationStatus(cmd *cobra.Command, job *migrationJob) error { + if flagJSON { + return newPrinter(cmd.OutOrStdout()).Print(job, nil) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Job ID: %s\n", job.JobID) + fmt.Fprintf(out, "Source page: %s\n", job.SourcePageID) + if job.TargetPageID > 0 { + fmt.Fprintf(out, "Target page ID: %d\n", job.TargetPageID) + } + fmt.Fprintf(out, "Phase: %s\n", job.Phase) + fmt.Fprintf(out, "Status: %s\n", job.Status) + fmt.Fprintf(out, "Progress: %d/%d\n", job.Progress.CompletedSteps, job.Progress.TotalSteps) + fmt.Fprintf(out, "Sections imported: %d\n", job.Progress.SectionsImported) + fmt.Fprintf(out, "Components imported: %d\n", job.Progress.ComponentsImported) + fmt.Fprintf(out, "Incidents imported: %d\n", job.Progress.IncidentsImported) + fmt.Fprintf(out, "Maintenances imported: %d\n", job.Progress.MaintenancesImported) + fmt.Fprintf(out, "Subscribers imported: %d\n", job.Progress.SubscribersImported) + fmt.Fprintf(out, "Subscribers skipped: %d\n", job.Progress.SubscribersSkipped) + fmt.Fprintf(out, "Templates imported: %d\n", job.Progress.TemplatesImported) + if job.Error != "" { + fmt.Fprintf(out, "Error: %s\n", job.Error) + } + if len(job.Progress.Warnings) > 0 { + fmt.Fprintln(out, "Warnings:") + for _, warning := range job.Progress.Warnings { + fmt.Fprintf(out, "- %s\n", warning) + } + } + return nil +} +``` + +- [ ] **Step 4: Run the package tests and targeted CLI build checks** + +Run: `go test ./internal/cli` +Expected: PASS + +Run: `go test ./...` +Expected: PASS + +Run: `go build ./cmd/flashduty` +Expected: PASS + +- [ ] **Step 5: Commit the status/cancel/docs work** + +```bash +git add README.md internal/cli/status_page_migrate.go internal/cli/status_page_migrate_test.go +git commit -m "feat: document statuspage migration workflow" +``` diff --git a/internal/cli/root.go b/internal/cli/root.go index 97244a9..53d893a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -6,9 +6,9 @@ import ( "io" "os" - flashduty "github.com/flashcatcloud/flashduty-sdk" "github.com/flashcatcloud/flashduty-cli/internal/config" "github.com/flashcatcloud/flashduty-cli/internal/output" + flashduty "github.com/flashcatcloud/flashduty-sdk" "github.com/spf13/cobra" ) @@ -20,9 +20,9 @@ var ( ) var rootCmd = &cobra.Command{ - Use: "flashduty", - Short: "Flashduty CLI - incident management from your terminal", - Long: "Flashduty CLI - incident management from your terminal.\n\nGet started by running 'flashduty login' to authenticate.", + Use: "flashduty", + Short: "Flashduty CLI - incident management from your terminal", + Long: "Flashduty CLI - incident management from your terminal.\n\nGet started by running 'flashduty login' to authenticate.", SilenceUsage: true, SilenceErrors: true, } @@ -55,18 +55,11 @@ func Execute() error { // newClient creates a Flashduty SDK client from resolved config + flag overrides. func newClient() (*flashduty.Client, 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") } @@ -82,6 +75,22 @@ func newClient() (*flashduty.Client, 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 e392756..a366d61 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -5,8 +5,8 @@ import ( "strconv" "strings" - flashduty "github.com/flashcatcloud/flashduty-sdk" "github.com/flashcatcloud/flashduty-cli/internal/output" + flashduty "github.com/flashcatcloud/flashduty-sdk" "github.com/spf13/cobra" ) @@ -19,6 +19,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..68f18ba --- /dev/null +++ b/internal/cli/status_page_migrate.go @@ -0,0 +1,441 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const migrationSourceAtlassian = "atlassian" + +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: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("API client error (HTTP %d)", resp.StatusCode) + } + return fmt.Errorf("API client error (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(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 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() + fmt.Fprintln(out, "Cancellation requested.") + fmt.Fprintf(out, "Job ID: %s\n\n", jobID) + fmt.Fprintln(out, "Check progress with:") + fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", jobID) + return nil + }, + } + + 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() + fmt.Fprintln(out, "Migration started.") + fmt.Fprintf(out, "Type: %s\n", migrationType) + fmt.Fprintf(out, "Source: %s\n", source) + fmt.Fprintf(out, "Source page: %s\n", sourcePageID) + if targetPageID > 0 { + fmt.Fprintf(out, "Target page ID: %d\n", targetPageID) + } + fmt.Fprintf(out, "Job ID: %s\n\n", result.JobID) + fmt.Fprintln(out, "Check progress with:") + fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", result.JobID) + return nil +} + +func printMigrationStatus(cmd *cobra.Command, job *migrationJob) error { + if flagJSON { + return newPrinter(cmd.OutOrStdout()).Print(job, nil) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Job ID: %s\n", job.JobID) + fmt.Fprintf(out, "Source page: %s\n", job.SourcePageID) + if job.TargetPageID > 0 { + fmt.Fprintf(out, "Target page ID: %d\n", job.TargetPageID) + } + fmt.Fprintf(out, "Phase: %s\n", job.Phase) + fmt.Fprintf(out, "Status: %s\n", job.Status) + fmt.Fprintf(out, "Progress: %d/%d\n", job.Progress.CompletedSteps, job.Progress.TotalSteps) + fmt.Fprintf(out, "Sections imported: %d\n", job.Progress.SectionsImported) + fmt.Fprintf(out, "Components imported: %d\n", job.Progress.ComponentsImported) + fmt.Fprintf(out, "Incidents imported: %d\n", job.Progress.IncidentsImported) + fmt.Fprintf(out, "Maintenances imported: %d\n", job.Progress.MaintenancesImported) + fmt.Fprintf(out, "Subscribers imported: %d\n", job.Progress.SubscribersImported) + fmt.Fprintf(out, "Subscribers skipped: %d\n", job.Progress.SubscribersSkipped) + fmt.Fprintf(out, "Templates imported: %d\n", job.Progress.TemplatesImported) + if job.Error != "" { + fmt.Fprintf(out, "Error: %s\n", job.Error) + } + if len(job.Progress.Warnings) > 0 { + fmt.Fprintln(out, "Warnings:") + for _, warning := range job.Progress.Warnings { + fmt.Fprintf(out, "- %s\n", warning) + } + } + return nil +} + +func parseMigrationTargetPageID(value string) (int64, error) { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid target page id: %w", err) + } + return id, 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..bef8f71 --- /dev/null +++ b/internal/cli/status_page_migrate_test.go @@ -0,0 +1,318 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestStatusPageMigrationAPIStartStructure(t *testing.T) { + t.Parallel() + + var gotMethod string + var gotPath string + var gotAppKey string + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"job_id": "job-1"}, + }) + })) + defer ts.Close() + + api := &statusPageMigrationAPI{ + httpClient: ts.Client(), + baseURL: ts.URL, + 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 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotJobID = r.URL.Query().Get("job_id") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "job_id": "job-2", + "source_page_id": "src-1", + "target_page_id": 1024, + "phase": "history", + "status": "running", + "progress": map[string]any{ + "total_steps": 5, + "completed_steps": 3, + }, + }, + }) + })) + defer ts.Close() + + api := &statusPageMigrationAPI{ + httpClient: ts.Client(), + baseURL: ts.URL, + 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 TestStatusPageMigrationAPICancel(t *testing.T) { + t.Parallel() + + var gotMethod string + var gotPath string + var gotBody map[string]any + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + })) + defer ts.Close() + + api := &statusPageMigrationAPI{ + httpClient: ts.Client(), + baseURL: ts.URL, + 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) + } +} From e4306500aef77a93ccedb20b145520514757448a Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Mon, 13 Apr 2026 17:04:39 +0800 Subject: [PATCH 2/3] fix: clean up migration docs and lint issues --- .gitignore | 1 + .../2026-04-13-statuspage-migration-cli.md | 367 ------------------ internal/cli/status_page_migrate.go | 123 ++++-- 3 files changed, 84 insertions(+), 407 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md diff --git a/.gitignore b/.gitignore index fe1e21f..0c004dd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ bin/ *.test *.out .DS_Store +docs/superpowers/ diff --git a/docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md b/docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md deleted file mode 100644 index e5233ac..0000000 --- a/docs/superpowers/plans/2026-04-13-statuspage-migration-cli.md +++ /dev/null @@ -1,367 +0,0 @@ -# Statuspage Migration CLI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `flashduty statuspage migrate` subcommands for Atlassian-to-Flashduty status page migration, covering structure import, email-subscriber import, status lookup, and cancellation with simple async-job UX. - -**Architecture:** Keep the new migration transport inside `flashduty-cli` so the CLI remains buildable without waiting for a separate `flashduty-sdk` release. Add one focused migration API helper plus Cobra subcommands that call it and print concise operator guidance, especially the follow-up `migrate status` command after `structure` and `email-subscribers`. - -**Tech Stack:** Go, Cobra, `net/http`, existing CLI config/output helpers, Go `testing` + `httptest` - ---- - -### Task 1: Add a Focused Migration API Helper - -**Files:** -- Create: `internal/cli/status_page_migrate.go` -- Modify: `internal/cli/root.go` -- Test: `internal/cli/status_page_migrate_test.go` - -- [ ] **Step 1: Write failing API helper tests for structure/status/cancel paths** - -```go -func TestStatusPageMigrationAPIStartStructure(t *testing.T) { - t.Parallel() - - var gotMethod string - var gotPath string - var gotAppKey string - var gotBody map[string]any - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - 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) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{"job_id": "job-1"}, - }) - })) - defer ts.Close() - - api := &statusPageMigrationAPI{ - httpClient: ts.Client(), - baseURL: ts.URL, - 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) - } -} -``` - -- [ ] **Step 2: Run the focused API helper tests and confirm they fail** - -Run: `go test ./internal/cli -run 'TestStatusPageMigrationAPI(StartStructure|GetStatus|Cancel)'` -Expected: FAIL with undefined `statusPageMigrationAPI` and related migration methods/types. - -- [ ] **Step 3: Implement the migration API helper and config resolution** - -```go -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"` -} - -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 -} -``` - -- [ ] **Step 4: Re-run the focused API helper tests and confirm they pass** - -Run: `go test ./internal/cli -run 'TestStatusPageMigrationAPI(StartStructure|GetStatus|Cancel)'` -Expected: PASS - -- [ ] **Step 5: Commit the helper foundation** - -```bash -git add internal/cli/root.go internal/cli/status_page_migrate.go internal/cli/status_page_migrate_test.go -git commit -m "feat: add statuspage migration api helper" -``` - -### Task 2: Add `statuspage migrate` Cobra Commands - -**Files:** -- Modify: `internal/cli/status_page.go` -- Modify: `internal/cli/status_page_migrate.go` -- Test: `internal/cli/status_page_migrate_test.go` - -- [ ] **Step 1: Write failing command tests for structure and email-subscribers output** - -```go -func TestStatusPageMigrateStructureCommandPrintsStatusHint(t *testing.T) { - t.Parallel() - - 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) - } -} -``` - -- [ ] **Step 2: Run the new command tests and confirm they fail** - -Run: `go test ./internal/cli -run 'TestStatusPageMigrate(StructureCommandPrintsStatusHint|EmailSubscribersCommandPrintsStatusHint)'` -Expected: FAIL with undefined migrate command constructors/service injection points. - -- [ ] **Step 3: Implement the command tree and simple async-job UX** - -```go -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 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 - } - return newPrinter(cmd.OutOrStdout()).Print(payload, nil) - } - - out := cmd.OutOrStdout() - fmt.Fprintln(out, "Migration started.") - fmt.Fprintf(out, "Type: %s\n", migrationType) - fmt.Fprintf(out, "Source: %s\n", source) - fmt.Fprintf(out, "Source page: %s\n", sourcePageID) - if targetPageID > 0 { - fmt.Fprintf(out, "Target page ID: %d\n", targetPageID) - } - fmt.Fprintf(out, "Job ID: %s\n\n", result.JobID) - fmt.Fprintln(out, "Check progress with:") - fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", result.JobID) - return nil -} -``` - -- [ ] **Step 4: Re-run the command tests and confirm they pass** - -Run: `go test ./internal/cli -run 'TestStatusPageMigrate(StructureCommandPrintsStatusHint|EmailSubscribersCommandPrintsStatusHint)'` -Expected: PASS - -- [ ] **Step 5: Commit the command layer** - -```bash -git add internal/cli/status_page.go internal/cli/status_page_migrate.go internal/cli/status_page_migrate_test.go -git commit -m "feat: add statuspage migrate commands" -``` - -### Task 3: Add Status/Cancel UX and Update Documentation - -**Files:** -- Modify: `internal/cli/status_page_migrate.go` -- Modify: `README.md` -- Test: `internal/cli/status_page_migrate_test.go` - -- [ ] **Step 1: Write failing tests for status output and cancel guidance** - -```go -func TestStatusPageMigrateStatusCommandPrintsJobDetails(t *testing.T) { - t.Parallel() - - 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) - } -} -``` - -- [ ] **Step 2: Run the new status/cancel/documentation-related tests and confirm they fail** - -Run: `go test ./internal/cli -run 'TestStatusPageMigrate(StatusCommandPrintsJobDetails|CancelCommandPrintsStatusHint)'` -Expected: FAIL until status/cancel formatting is implemented. - -- [ ] **Step 3: Implement status/cancel formatting and README command docs** - -```go -func printMigrationStatus(cmd *cobra.Command, job *migrationJob) error { - if flagJSON { - return newPrinter(cmd.OutOrStdout()).Print(job, nil) - } - - out := cmd.OutOrStdout() - fmt.Fprintf(out, "Job ID: %s\n", job.JobID) - fmt.Fprintf(out, "Source page: %s\n", job.SourcePageID) - if job.TargetPageID > 0 { - fmt.Fprintf(out, "Target page ID: %d\n", job.TargetPageID) - } - fmt.Fprintf(out, "Phase: %s\n", job.Phase) - fmt.Fprintf(out, "Status: %s\n", job.Status) - fmt.Fprintf(out, "Progress: %d/%d\n", job.Progress.CompletedSteps, job.Progress.TotalSteps) - fmt.Fprintf(out, "Sections imported: %d\n", job.Progress.SectionsImported) - fmt.Fprintf(out, "Components imported: %d\n", job.Progress.ComponentsImported) - fmt.Fprintf(out, "Incidents imported: %d\n", job.Progress.IncidentsImported) - fmt.Fprintf(out, "Maintenances imported: %d\n", job.Progress.MaintenancesImported) - fmt.Fprintf(out, "Subscribers imported: %d\n", job.Progress.SubscribersImported) - fmt.Fprintf(out, "Subscribers skipped: %d\n", job.Progress.SubscribersSkipped) - fmt.Fprintf(out, "Templates imported: %d\n", job.Progress.TemplatesImported) - if job.Error != "" { - fmt.Fprintf(out, "Error: %s\n", job.Error) - } - if len(job.Progress.Warnings) > 0 { - fmt.Fprintln(out, "Warnings:") - for _, warning := range job.Progress.Warnings { - fmt.Fprintf(out, "- %s\n", warning) - } - } - return nil -} -``` - -- [ ] **Step 4: Run the package tests and targeted CLI build checks** - -Run: `go test ./internal/cli` -Expected: PASS - -Run: `go test ./...` -Expected: PASS - -Run: `go build ./cmd/flashduty` -Expected: PASS - -- [ ] **Step 5: Commit the status/cancel/docs work** - -```bash -git add README.md internal/cli/status_page_migrate.go internal/cli/status_page_migrate_test.go -git commit -m "feat: document statuspage migration workflow" -``` diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index 68f18ba..6e65b75 100644 --- a/internal/cli/status_page_migrate.go +++ b/internal/cli/status_page_migrate.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/url" - "strconv" "strings" "time" @@ -349,11 +348,17 @@ func newStatusPageMigrateCancelCmd() *cobra.Command { } out := cmd.OutOrStdout() - fmt.Fprintln(out, "Cancellation requested.") - fmt.Fprintf(out, "Job ID: %s\n\n", jobID) - fmt.Fprintln(out, "Check progress with:") - fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", jobID) - return nil + 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 }, } @@ -386,17 +391,31 @@ func printMigrationStart(cmd *cobra.Command, migrationType, source, sourcePageID } out := cmd.OutOrStdout() - fmt.Fprintln(out, "Migration started.") - fmt.Fprintf(out, "Type: %s\n", migrationType) - fmt.Fprintf(out, "Source: %s\n", source) - fmt.Fprintf(out, "Source page: %s\n", sourcePageID) + 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 { - fmt.Fprintf(out, "Target page ID: %d\n", targetPageID) + if _, err := fmt.Fprintf(out, "Target page ID: %d\n", targetPageID); err != nil { + return err + } } - fmt.Fprintf(out, "Job ID: %s\n\n", result.JobID) - fmt.Fprintln(out, "Check progress with:") - fmt.Fprintf(out, " flashduty statuspage migrate status --job-id %s\n", result.JobID) - return nil + 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 { @@ -405,37 +424,61 @@ func printMigrationStatus(cmd *cobra.Command, job *migrationJob) error { } out := cmd.OutOrStdout() - fmt.Fprintf(out, "Job ID: %s\n", job.JobID) - fmt.Fprintf(out, "Source page: %s\n", job.SourcePageID) + 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 { - fmt.Fprintf(out, "Target page ID: %d\n", job.TargetPageID) - } - fmt.Fprintf(out, "Phase: %s\n", job.Phase) - fmt.Fprintf(out, "Status: %s\n", job.Status) - fmt.Fprintf(out, "Progress: %d/%d\n", job.Progress.CompletedSteps, job.Progress.TotalSteps) - fmt.Fprintf(out, "Sections imported: %d\n", job.Progress.SectionsImported) - fmt.Fprintf(out, "Components imported: %d\n", job.Progress.ComponentsImported) - fmt.Fprintf(out, "Incidents imported: %d\n", job.Progress.IncidentsImported) - fmt.Fprintf(out, "Maintenances imported: %d\n", job.Progress.MaintenancesImported) - fmt.Fprintf(out, "Subscribers imported: %d\n", job.Progress.SubscribersImported) - fmt.Fprintf(out, "Subscribers skipped: %d\n", job.Progress.SubscribersSkipped) - fmt.Fprintf(out, "Templates imported: %d\n", job.Progress.TemplatesImported) + 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 != "" { - fmt.Fprintf(out, "Error: %s\n", job.Error) + if _, err := fmt.Fprintf(out, "Error: %s\n", job.Error); err != nil { + return err + } } if len(job.Progress.Warnings) > 0 { - fmt.Fprintln(out, "Warnings:") + if _, err := fmt.Fprintln(out, "Warnings:"); err != nil { + return err + } for _, warning := range job.Progress.Warnings { - fmt.Fprintf(out, "- %s\n", warning) + if _, err := fmt.Fprintf(out, "- %s\n", warning); err != nil { + return err + } } } return nil } - -func parseMigrationTargetPageID(value string) (int64, error) { - id, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid target page id: %w", err) - } - return id, nil -} From a7d39d953b93ed5bf66dc0a2a219d00ee31d6e10 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Mon, 20 Apr 2026 15:48:15 +0800 Subject: [PATCH 3/3] fix: harden statuspage migration error handling --- internal/cli/status_page_migrate.go | 112 +++++++++++++- internal/cli/status_page_migrate_test.go | 187 ++++++++++++++++------- 2 files changed, 237 insertions(+), 62 deletions(-) diff --git a/internal/cli/status_page_migrate.go b/internal/cli/status_page_migrate.go index 6e65b75..f3fcf9d 100644 --- a/internal/cli/status_page_migrate.go +++ b/internal/cli/status_page_migrate.go @@ -15,6 +15,33 @@ import ( ) 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) @@ -173,16 +200,16 @@ func (a *statusPageMigrationAPI) do(ctx context.Context, method, path string, qu resp, err := a.httpClient.Do(req) if err != nil { - return fmt.Errorf("request failed: %w", err) + 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(resp.Body) + 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, strings.TrimSpace(string(bodyBytes))) + return fmt.Errorf("API client error (HTTP %d): %s", resp.StatusCode, sanitizeMigrationBody(string(bodyBytes))) } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { @@ -207,6 +234,85 @@ func (a *statusPageMigrationAPI) do(ctx context.Context, method, path string, qu 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", diff --git a/internal/cli/status_page_migrate_test.go b/internal/cli/status_page_migrate_test.go index bef8f71..9e68c99 100644 --- a/internal/cli/status_page_migrate_test.go +++ b/internal/cli/status_page_migrate_test.go @@ -4,12 +4,27 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "io" "net/http" - "net/http/httptest" "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() @@ -18,25 +33,21 @@ func TestStatusPageMigrationAPIStartStructure(t *testing.T) { var gotAppKey string var gotBody map[string]any - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - 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) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{"job_id": "job-1"}, - }) - })) - defer ts.Close() - api := &statusPageMigrationAPI{ - httpClient: ts.Client(), - baseURL: ts.URL, - appKey: "fd-app-key", - userAgent: "flashduty-cli/test", + 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") @@ -68,32 +79,18 @@ func TestStatusPageMigrationAPIGetStatus(t *testing.T) { var gotPath string var gotJobID string - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - gotJobID = r.URL.Query().Get("job_id") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "job_id": "job-2", - "source_page_id": "src-1", - "target_page_id": 1024, - "phase": "history", - "status": "running", - "progress": map[string]any{ - "total_steps": 5, - "completed_steps": 3, - }, - }, - }) - })) - defer ts.Close() - api := &statusPageMigrationAPI{ - httpClient: ts.Client(), - baseURL: ts.URL, - appKey: "fd-app-key", - userAgent: "flashduty-cli/test", + 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") @@ -115,6 +112,80 @@ func TestStatusPageMigrationAPIGetStatus(t *testing.T) { } } +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() @@ -122,22 +193,20 @@ func TestStatusPageMigrationAPICancel(t *testing.T) { var gotPath string var gotBody map[string]any - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Fatalf("decode body: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) - })) - defer ts.Close() - api := &statusPageMigrationAPI{ - httpClient: ts.Client(), - baseURL: ts.URL, - appKey: "fd-app-key", - userAgent: "flashduty-cli/test", + 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 {