From 2479831d5d43d8878a4980f3b3907fc7bbcac6a6 Mon Sep 17 00:00:00 2001 From: debidong <1953531014@qq.com> Date: Mon, 20 Apr 2026 14:28:08 +0800 Subject: [PATCH] feat: add status page migration SDK methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four async status page migration endpoints plus hardened body-log redaction required to ship them safely. New public API (statuspage.go): - StartStatusPageMigration — POST /status-page/migrate-structure - StartStatusPageEmailSubscriberMigration — POST /status-page/migrate-email-subscribers - GetStatusPageMigrationStatus — GET /status-page/migration/status - CancelStatusPageMigration — POST /status-page/migration/cancel New exported types: StartStatusPageMigrationInput, StartStatusPageEmailSubscriberMigrationInput, StartStatusPageMigrationOutput (shared by both start methods), StatusPageMigrationProgress, StatusPageMigrationJob. Body-log redaction (client.go): - sanitizeBody now recurses into nested maps and arrays, normalizes keys (case + punctuation insensitive), and covers ~23 credential aliases (api_key, authorization, bearertoken, refresh_token, client_secret, private_key, etc). - Applied to request log (makeRequest), response log (parseResponse), handleAPIError log, and to the error strings returned from parseResponse and handleAPIError — so echoed credentials cannot leak either through logs or through the Go error chain. These migration endpoints are the first SDK calls that put a third-party credential (source provider api_key) in a JSON request body, so the existing URL-only sanitization was insufficient. Tests: httptest-backed coverage per method + per redaction site. go test -race -count=1 ./... green. New-code coverage: sanitizeBody 91.7%, sanitizeJSONValue 100%, parseResponse 88.9%, handleAPIError 91.7%, all migration methods ≥84%. Additive, backward-compatible. Intended for v0.7.0. --- client.go | 116 ++++++++++++++- client_test.go | 336 +++++++++++++++++++++++++++++++++++++++++++ statuspage.go | 177 +++++++++++++++++++++++ statuspage_test.go | 348 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 970 insertions(+), 7 deletions(-) create mode 100644 statuspage_test.go diff --git a/client.go b/client.go index 73fda56..5a0c0c5 100644 --- a/client.go +++ b/client.go @@ -85,7 +85,7 @@ func (c *Client) makeRequest(ctx context.Context, method, path string, body any) } logAttrs := traceLogAttrsFromRequest(req) - logAttrs = append(logAttrs, "method", method, "url", sanitizeURL(fullURL), "body", truncateBody(string(reqBodyBytes))) + logAttrs = append(logAttrs, "method", method, "url", sanitizeURL(fullURL), "body", truncateBody(sanitizeBody(string(reqBodyBytes)))) c.logger.Info("duty request", logAttrs...) resp, err := c.httpClient.Do(req) @@ -113,6 +113,106 @@ func sanitizeURL(u *url.URL) string { return sanitized.String() } +// sensitiveBodyKeys enumerates normalized JSON keys whose values must be +// redacted before bodies are logged. The set intentionally covers common +// credential aliases seen in API payloads and echoed error responses. +var sensitiveBodyKeys = 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": {}, +} + +// sanitizeBody redacts values of well-known sensitive JSON keys so that +// secrets do not appear in request/response logs. It is best-effort: empty or +// non-JSON bodies pass through unchanged. Callers must still use sanitizeURL +// for URL-borne secrets. +func sanitizeBody(body string) string { + if body == "" { + return body + } + var v any + if err := json.Unmarshal([]byte(body), &v); err != nil { + return body + } + + sanitized, redacted := sanitizeJSONValue(v) + if !redacted { + return body + } + out, err := json.Marshal(sanitized) + if err != nil { + return body + } + return string(out) +} + +func sanitizeJSONValue(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 isSensitiveBodyKey(key) { + sanitized[key] = "[REDACTED]" + redacted = true + continue + } + + sanitizedItem, itemRedacted := sanitizeJSONValue(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 := sanitizeJSONValue(item) + sanitized[i] = sanitizedItem + redacted = redacted || itemRedacted + } + return sanitized, redacted + default: + return v, false + } +} + +func isSensitiveBodyKey(key string) bool { + _, ok := sensitiveBodyKeys[normalizeSensitiveBodyKey(key)] + return ok +} + +func normalizeSensitiveBodyKey(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() +} + // sanitizeError removes potential URL with sensitive data from error messages func sanitizeError(err error) string { errStr := err.Error() @@ -166,23 +266,24 @@ func parseResponse(logger Logger, resp *http.Response, v any) error { if err != nil { return fmt.Errorf("failed to read response body: %w", err) } + sanitizedBody := sanitizeBody(string(body)) logAttrs := traceLogAttrsFromRequest(resp.Request) logAttrs = append(logAttrs, "status", resp.StatusCode, - "body", truncateBody(string(body)), + "body", truncateBody(sanitizedBody), ) requestID := resp.Header.Get("Flashcat-Request-Id") if resp.StatusCode >= 500 { logger.Error("duty response", logAttrs...) - return fmt.Errorf("API server error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) + return fmt.Errorf("API server error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, sanitizedBody) } if resp.StatusCode >= 400 { logger.Warn("duty response", logAttrs...) - return fmt.Errorf("API client error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) + return fmt.Errorf("API client error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, sanitizedBody) } logger.Info("duty response", logAttrs...) @@ -203,22 +304,23 @@ func handleAPIError(logger Logger, resp *http.Response) error { if err != nil { return fmt.Errorf("API request failed (HTTP %d): unable to read response body: %v", resp.StatusCode, err) } + sanitizedBody := sanitizeBody(string(body)) logAttrs := traceLogAttrsFromRequest(resp.Request) logAttrs = append(logAttrs, "status", resp.StatusCode, - "body", truncateBody(string(body)), + "body", truncateBody(sanitizedBody), ) requestID := resp.Header.Get("Flashcat-Request-Id") if resp.StatusCode >= 500 { logger.Error("duty error", logAttrs...) - return fmt.Errorf("API server error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) + return fmt.Errorf("API server error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, sanitizedBody) } logger.Warn("duty error", logAttrs...) - return fmt.Errorf("API client error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, string(body)) + return fmt.Errorf("API client error (HTTP %d, request_id: %s): %s", resp.StatusCode, requestID, sanitizedBody) } // truncateBody truncates a string body if it exceeds the default max size for logging diff --git a/client_test.go b/client_test.go index 0e31eb9..1b5af50 100644 --- a/client_test.go +++ b/client_test.go @@ -3,8 +3,10 @@ package flashduty import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "sync" "testing" ) @@ -526,3 +528,337 @@ func TestClientHookOverridesStaticHeaders(t *testing.T) { t.Errorf("X-Overlap = %q; want %q (hook should override static header)", v, "hook-value") } } + +func TestSanitizeBodyRedactsSensitiveKeys(t *testing.T) { + tests := []struct { + name string + input string + mustNotAppear []string + mustAppear []string + }{ + { + name: "api_key", + input: `{"api_key":"secret-123","source_page_id":"p1"}`, + mustNotAppear: []string{"secret-123"}, + mustAppear: []string{"[REDACTED]", "p1"}, + }, + { + name: "password", + input: `{"password":"hunter2","user":"ada"}`, + mustNotAppear: []string{"hunter2"}, + mustAppear: []string{"[REDACTED]", "ada"}, + }, + { + name: "token", + input: `{"token":"abcd","other":"x"}`, + mustNotAppear: []string{"abcd"}, + mustAppear: []string{"[REDACTED]", "x"}, + }, + { + name: "secret", + input: `{"secret":"sshh","x":1}`, + mustNotAppear: []string{"sshh"}, + mustAppear: []string{"[REDACTED]"}, + }, + { + name: "no_sensitive_keys_preserved", + input: `{"page_id":42,"title":"hi"}`, + mustAppear: []string{"page_id", "42", "hi"}, + }, + { + name: "empty_input_passthrough", + input: "", + mustAppear: nil, + }, + { + name: "non_json_passthrough", + input: "raw=not-json", + mustAppear: []string{"raw=not-json"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := sanitizeBody(tc.input) + for _, s := range tc.mustNotAppear { + if strings.Contains(got, s) { + t.Errorf("sanitizeBody(%q) = %q; must not contain %q", tc.input, got, s) + } + } + for _, s := range tc.mustAppear { + if !strings.Contains(got, s) { + t.Errorf("sanitizeBody(%q) = %q; want to contain %q", tc.input, got, s) + } + } + }) + } +} + +func TestSanitizeBodyRedactsNestedCaseInsensitiveAliases(t *testing.T) { + input := `{"outer":{"ApiKey":"secret-123","nested":[{"ACCESS_TOKEN":"token-abc"},{"safe":"ok"}],"credentials":{"clientSecret":"super-secret"}},"Authorization":"Bearer top-secret","page_id":"p1"}` + + got := sanitizeBody(input) + + for _, secret := range []string{"secret-123", "token-abc", "super-secret", "Bearer top-secret"} { + if strings.Contains(got, secret) { + t.Errorf("sanitizeBody(%q) = %q; must not contain %q", input, got, secret) + } + } + + for _, want := range []string{"[REDACTED]", `"safe":"ok"`, `"page_id":"p1"`} { + if !strings.Contains(got, want) { + t.Errorf("sanitizeBody(%q) = %q; want to contain %q", input, got, want) + } + } +} + +func TestMakeRequestLogsRedactedBody(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"job_id": "j1"}}) + })) + t.Cleanup(ts.Close) + + logger := &capturingLogger{} + client, err := NewClient("app-key", WithBaseURL(ts.URL), WithLogger(logger)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.StartStatusPageMigration(context.Background(), &StartStatusPageMigrationInput{ + SourceAPIKey: "atlassian-secret", + SourcePageID: "page_123", + }) + if err != nil { + t.Fatalf("StartStatusPageMigration: %v", err) + } + + entries := logger.snapshot() + req, ok := findLogEntry(entries, "duty request") + if !ok { + t.Fatalf("expected a 'duty request' log entry; got %d entries", len(entries)) + } + + body := logKVString(req.kv, "body") + if body == "" { + t.Fatalf("body field missing from log entry") + } + if strings.Contains(body, "atlassian-secret") { + t.Errorf("request log leaked api_key: body = %q", body) + } + if !strings.Contains(body, "[REDACTED]") { + t.Errorf("request log missing redaction marker: body = %q", body) + } +} + +func TestParseResponseLogsRedactedBody(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "job_id": "j1", + "echo": map[string]any{ + "ApiKey": "response-secret", + "nested": []any{ + map[string]any{"AUTHORIZATION": "Bearer response-token"}, + }, + }, + }, + }) + })) + t.Cleanup(ts.Close) + + logger := &capturingLogger{} + client, err := NewClient("app-key", WithBaseURL(ts.URL), WithLogger(logger)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.StartStatusPageMigration(context.Background(), &StartStatusPageMigrationInput{ + SourceAPIKey: "request-secret", + SourcePageID: "page_123", + }) + if err != nil { + t.Fatalf("StartStatusPageMigration: %v", err) + } + + entries := logger.snapshot() + resp, ok := findLogEntry(entries, "duty response") + if !ok { + t.Fatalf("expected a 'duty response' log entry; got %d entries", len(entries)) + } + + body := logKVString(resp.kv, "body") + if body == "" { + t.Fatalf("body field missing from log entry") + } + for _, secret := range []string{"response-secret", "Bearer response-token"} { + if strings.Contains(body, secret) { + t.Errorf("response log leaked secret %q: body = %q", secret, body) + } + } + if !strings.Contains(body, "[REDACTED]") { + t.Errorf("response log missing redaction marker: body = %q", body) + } +} + +func TestHandleAPIErrorLogsRedactedBody(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": "invalid credentials", + "details": map[string]any{ + "clientSecret": "response-secret", + "nested": []any{ + map[string]any{"ACCESS_TOKEN": "response-token"}, + }, + }, + }, + }) + })) + t.Cleanup(ts.Close) + + logger := &capturingLogger{} + client, err := NewClient("app-key", WithBaseURL(ts.URL), WithLogger(logger)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.StartStatusPageMigration(context.Background(), &StartStatusPageMigrationInput{ + SourceAPIKey: "request-secret", + SourcePageID: "page_123", + }) + if err == nil { + t.Fatal("expected StartStatusPageMigration to fail, got nil") + } + + entries := logger.snapshot() + resp, ok := findLogEntry(entries, "duty error") + if !ok { + t.Fatalf("expected a 'duty error' log entry; got %d entries", len(entries)) + } + + body := logKVString(resp.kv, "body") + if body == "" { + t.Fatalf("body field missing from log entry") + } + for _, secret := range []string{"response-secret", "response-token"} { + if strings.Contains(body, secret) { + t.Errorf("error log leaked secret %q: body = %q", secret, body) + } + } + if !strings.Contains(body, "[REDACTED]") { + t.Errorf("error log missing redaction marker: body = %q", body) + } +} + +func TestParseResponseReturnsSanitizedErrorBody(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Header: http.Header{"Flashcat-Request-Id": []string{"req-1"}}, + Body: io.NopCloser(strings.NewReader( + `{"error":{"message":"upstream failed","details":{"ApiKey":"response-secret","nested":[{"ACCESS_TOKEN":"response-token"}]}}}`, + )), + Request: &http.Request{Header: make(http.Header)}, + } + + err := parseResponse(&capturingLogger{}, resp, nil) + if err == nil { + t.Fatal("expected parseResponse to fail, got nil") + } + if !strings.HasPrefix(err.Error(), "API server error (HTTP 502, request_id: req-1): ") { + t.Fatalf("parseResponse returned unexpected error format: %v", err) + } + if strings.Contains(err.Error(), "response-secret") || strings.Contains(err.Error(), "response-token") { + t.Fatalf("parseResponse leaked secret in error: %v", err) + } + if !strings.Contains(err.Error(), "[REDACTED]") { + t.Fatalf("parseResponse error missing redaction marker: %v", err) + } +} + +func TestHandleAPIErrorReturnsSanitizedErrorBody(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{"Flashcat-Request-Id": []string{"req-2"}}, + Body: io.NopCloser(strings.NewReader( + `{"error":{"message":"invalid credentials","details":{"clientSecret":"response-secret","nested":[{"AUTHORIZATION":"Bearer response-token"}]}}}`, + )), + Request: &http.Request{Header: make(http.Header)}, + } + + err := handleAPIError(&capturingLogger{}, resp) + if err == nil { + t.Fatal("expected handleAPIError to fail, got nil") + } + if !strings.HasPrefix(err.Error(), "API client error (HTTP 403, request_id: req-2): ") { + t.Fatalf("handleAPIError returned unexpected error format: %v", err) + } + if strings.Contains(err.Error(), "response-secret") || strings.Contains(err.Error(), "response-token") { + t.Fatalf("handleAPIError leaked secret in error: %v", err) + } + if !strings.Contains(err.Error(), "[REDACTED]") { + t.Fatalf("handleAPIError error missing redaction marker: %v", err) + } +} + +func TestReturnedAPIErrorsPreserveNonJSONBody(t *testing.T) { + tests := []struct { + name string + call func(*http.Response) error + want string + }{ + { + name: "parseResponse", + call: func(resp *http.Response) error { + return parseResponse(&capturingLogger{}, resp, nil) + }, + want: "API client error (HTTP 400, request_id: req-plain-parse): upstream returned plaintext secret=response-secret", + }, + { + name: "handleAPIError", + call: func(resp *http.Response) error { + return handleAPIError(&capturingLogger{}, resp) + }, + want: "API client error (HTTP 400, request_id: req-plain-handle): upstream returned plaintext secret=response-secret", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("upstream returned plaintext secret=response-secret")), + Request: &http.Request{Header: make(http.Header)}, + } + if tc.name == "parseResponse" { + resp.Header.Set("Flashcat-Request-Id", "req-plain-parse") + } else { + resp.Header.Set("Flashcat-Request-Id", "req-plain-handle") + } + + err := tc.call(resp) + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); got != tc.want { + t.Fatalf("error = %q; want %q", got, tc.want) + } + }) + } +} + +func logKVString(kv []any, key string) string { + for i := 0; i+1 < len(kv); i += 2 { + if k, ok := kv[i].(string); ok && k == key { + if v, ok := kv[i+1].(string); ok { + return v + } + } + } + return "" +} diff --git a/statuspage.go b/statuspage.go index 183d724..cf57dfa 100644 --- a/statuspage.go +++ b/statuspage.go @@ -3,6 +3,7 @@ package flashduty import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -273,3 +274,179 @@ func (c *Client) CreateChangeTimeline(ctx context.Context, input *CreateChangeTi return nil } + +// StartStatusPageMigrationInput contains parameters for starting a status page +// structure and history migration from an external provider. +type StartStatusPageMigrationInput struct { + SourceAPIKey string // Required. API key for the source provider (e.g. Atlassian Statuspage) + SourcePageID string // Required. Page identifier in the source provider +} + +// StartStatusPageEmailSubscriberMigrationInput contains parameters for starting +// an email subscriber migration from an external provider into an existing +// Flashduty status page. +type StartStatusPageEmailSubscriberMigrationInput struct { + SourceAPIKey string // Required. API key for the source provider + SourcePageID string // Required. Page identifier in the source provider + TargetPageID int64 // Required. Flashduty status page ID to import into +} + +// StartStatusPageMigrationOutput contains the result of starting an async +// status page migration. Both structure and email subscriber migrations return +// a job ID that can be polled with GetStatusPageMigrationStatus. +type StartStatusPageMigrationOutput struct { + JobID string `json:"job_id"` +} + +// StatusPageMigrationProgress describes incremental counters reported by a +// migration job. Fields are populated best-effort; zero values indicate +// either the counter does not apply to the current phase or no items of that +// kind have been processed yet. +type StatusPageMigrationProgress 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"` +} + +// StatusPageMigrationJob describes the state of a status page migration job. +type StatusPageMigrationJob 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 StatusPageMigrationProgress `json:"progress"` + Error string `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// StartStatusPageMigration starts an asynchronous migration of status page +// structure and history from an external provider into Flashduty. The returned +// job ID can be polled with GetStatusPageMigrationStatus and cancelled with +// CancelStatusPageMigration. +func (c *Client) StartStatusPageMigration(ctx context.Context, input *StartStatusPageMigrationInput) (*StartStatusPageMigrationOutput, error) { + if input == nil { + return nil, errors.New("input is required") + } + return c.startStatusPageMigration(ctx, "/status-page/migrate-structure", map[string]any{ + "api_key": input.SourceAPIKey, + "source_page_id": input.SourcePageID, + }) +} + +// StartStatusPageEmailSubscriberMigration starts an asynchronous migration of +// email subscribers from an external provider into an existing Flashduty +// status page. The returned job ID can be polled with +// GetStatusPageMigrationStatus and cancelled with CancelStatusPageMigration. +func (c *Client) StartStatusPageEmailSubscriberMigration(ctx context.Context, input *StartStatusPageEmailSubscriberMigrationInput) (*StartStatusPageMigrationOutput, error) { + if input == nil { + return nil, errors.New("input is required") + } + return c.startStatusPageMigration(ctx, "/status-page/migrate-email-subscribers", map[string]any{ + "api_key": input.SourceAPIKey, + "source_page_id": input.SourcePageID, + "target_page_id": input.TargetPageID, + }) +} + +func (c *Client) startStatusPageMigration(ctx context.Context, path string, body map[string]any) (*StartStatusPageMigrationOutput, error) { + resp, err := c.makeRequest(ctx, "POST", path, body) + if err != nil { + return nil, fmt.Errorf("failed to start status page migration: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, handleAPIError(c.logger, resp) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *StartStatusPageMigrationOutput `json:"data,omitempty"` + } + if err := parseResponse(c.logger, resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, result.Error + } + if result.Data == nil { + return nil, errors.New("status page migration response missing data") + } + + return result.Data, nil +} + +// GetStatusPageMigrationStatus fetches the current state of a status page +// migration job identified by jobID. +func (c *Client) GetStatusPageMigrationStatus(ctx context.Context, jobID string) (*StatusPageMigrationJob, error) { + if jobID == "" { + return nil, errors.New("jobID is required") + } + + params := url.Values{} + params.Set("job_id", jobID) + resp, err := c.makeRequest(ctx, "GET", "/status-page/migration/status?"+params.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("failed to get status page migration status: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, handleAPIError(c.logger, resp) + } + + var result struct { + Error *DutyError `json:"error,omitempty"` + Data *StatusPageMigrationJob `json:"data,omitempty"` + } + if err := parseResponse(c.logger, resp, &result); err != nil { + return nil, err + } + if result.Error != nil { + return nil, result.Error + } + if result.Data == nil { + return nil, fmt.Errorf("status page migration status response missing data") + } + + return result.Data, nil +} + +// CancelStatusPageMigration requests cancellation of an in-flight status page +// migration job identified by jobID. +func (c *Client) CancelStatusPageMigration(ctx context.Context, jobID string) error { + if jobID == "" { + return errors.New("jobID is required") + } + + resp, err := c.makeRequest(ctx, "POST", "/status-page/migration/cancel", map[string]any{ + "job_id": jobID, + }) + if err != nil { + return fmt.Errorf("failed to cancel status page migration: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleAPIError(c.logger, resp) + } + + var result FlashdutyResponse + if err := parseResponse(c.logger, resp, &result); err != nil { + return err + } + if result.Error != nil { + return result.Error + } + + return nil +} diff --git a/statuspage_test.go b/statuspage_test.go new file mode 100644 index 0000000..1fb20c2 --- /dev/null +++ b/statuspage_test.go @@ -0,0 +1,348 @@ +package flashduty + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestStartStatusPageMigration(t *testing.T) { + var gotMethod, gotPath, gotAppKey, gotContentType string + var gotBody map[string]any + + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAppKey = r.URL.Query().Get("app_key") + gotContentType = r.Header.Get("Content-Type") + gotBody = decodeJSONBody(t, r) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"job_id": "job-1"}, + }) + }) + + out, err := client.StartStatusPageMigration(context.Background(), &StartStatusPageMigrationInput{ + SourceAPIKey: "atlassian-key", + SourcePageID: "page_123", + }) + if err != nil { + t.Fatalf("StartStatusPageMigration() error = %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("method = %s, want POST", gotMethod) + } + if gotPath != "/status-page/migrate-structure" { + t.Errorf("path = %s, want /status-page/migrate-structure", gotPath) + } + if gotAppKey != "test-key" { + t.Errorf("app_key = %s, want test-key", gotAppKey) + } + if gotContentType != "application/json" { + t.Errorf("Content-Type = %s, want application/json", gotContentType) + } + if gotBody["api_key"] != "atlassian-key" { + t.Errorf("api_key = %v, want atlassian-key", gotBody["api_key"]) + } + if gotBody["source_page_id"] != "page_123" { + t.Errorf("source_page_id = %v, want page_123", gotBody["source_page_id"]) + } + if out.JobID != "job-1" { + t.Errorf("JobID = %s, want job-1", out.JobID) + } +} + +func TestStartStatusPageEmailSubscriberMigration(t *testing.T) { + var gotMethod, gotPath string + var gotBody map[string]any + + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotBody = decodeJSONBody(t, r) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"job_id": "job-2"}, + }) + }) + + out, err := client.StartStatusPageEmailSubscriberMigration(context.Background(), &StartStatusPageEmailSubscriberMigrationInput{ + SourceAPIKey: "atlassian-key", + SourcePageID: "page_123", + TargetPageID: 1024, + }) + if err != nil { + t.Fatalf("StartStatusPageEmailSubscriberMigration() error = %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("method = %s, want POST", gotMethod) + } + if gotPath != "/status-page/migrate-email-subscribers" { + t.Errorf("path = %s, want /status-page/migrate-email-subscribers", gotPath) + } + if gotBody["api_key"] != "atlassian-key" { + t.Errorf("api_key = %v, want atlassian-key", gotBody["api_key"]) + } + if gotBody["source_page_id"] != "page_123" { + t.Errorf("source_page_id = %v, want page_123", gotBody["source_page_id"]) + } + // JSON decoding of map[string]any produces float64 for numbers. + if got, ok := gotBody["target_page_id"].(float64); !ok || int64(got) != 1024 { + t.Errorf("target_page_id = %#v, want 1024", gotBody["target_page_id"]) + } + if out.JobID != "job-2" { + t.Errorf("JobID = %s, want job-2", out.JobID) + } +} + +func TestGetStatusPageMigrationStatus(t *testing.T) { + var gotMethod, gotPath, gotJobID string + + client := newSDKExtensionsTestClient(t, 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-3", + "source_page_id": "src-1", + "target_page_id": 2048, + "phase": "history", + "status": "running", + "progress": map[string]any{ + "total_steps": 5, + "completed_steps": 3, + "components_imported": 10, + "sections_imported": 2, + "incidents_imported": 4, + "maintenances_imported": 1, + "subscribers_imported": 50, + "subscribers_skipped": 3, + "templates_imported": 2, + "warnings": []string{"missing field X"}, + }, + "created_at": 1713225600, + "updated_at": 1713225700, + }, + }) + }) + + out, err := client.GetStatusPageMigrationStatus(context.Background(), "job-3") + if err != nil { + t.Fatalf("GetStatusPageMigrationStatus() error = %v", err) + } + + if gotMethod != http.MethodGet { + t.Errorf("method = %s, want GET", gotMethod) + } + if gotPath != "/status-page/migration/status" { + t.Errorf("path = %s, want /status-page/migration/status", gotPath) + } + if gotJobID != "job-3" { + t.Errorf("job_id query = %s, want job-3", gotJobID) + } + + if out.JobID != "job-3" { + t.Errorf("JobID = %s, want job-3", out.JobID) + } + if out.SourcePageID != "src-1" { + t.Errorf("SourcePageID = %s, want src-1", out.SourcePageID) + } + if out.TargetPageID != 2048 { + t.Errorf("TargetPageID = %d, want 2048", out.TargetPageID) + } + if out.Phase != "history" { + t.Errorf("Phase = %s, want history", out.Phase) + } + if out.Status != "running" { + t.Errorf("Status = %s, want running", out.Status) + } + if out.Progress.CompletedSteps != 3 || out.Progress.TotalSteps != 5 { + t.Errorf("Progress steps = %d/%d, want 3/5", out.Progress.CompletedSteps, out.Progress.TotalSteps) + } + if out.Progress.SubscribersImported != 50 { + t.Errorf("Progress.SubscribersImported = %d, want 50", out.Progress.SubscribersImported) + } + if len(out.Progress.Warnings) != 1 || out.Progress.Warnings[0] != "missing field X" { + t.Errorf("Progress.Warnings = %v, want [missing field X]", out.Progress.Warnings) + } + if out.CreatedAt != 1713225600 || out.UpdatedAt != 1713225700 { + t.Errorf("timestamps = (%d, %d)", out.CreatedAt, out.UpdatedAt) + } +} + +func TestGetStatusPageMigrationStatusRejectsEmptyJobID(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be hit for empty jobID; got %s %s", r.Method, r.URL.Path) + }) + + if _, err := client.GetStatusPageMigrationStatus(context.Background(), ""); err == nil { + t.Fatalf("expected error for empty jobID, got nil") + } +} + +func TestCancelStatusPageMigration(t *testing.T) { + var gotMethod, gotPath string + var gotBody map[string]any + + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotBody = decodeJSONBody(t, r) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) + }) + + if err := client.CancelStatusPageMigration(context.Background(), "job-4"); err != nil { + t.Fatalf("CancelStatusPageMigration() error = %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("method = %s, want POST", gotMethod) + } + if gotPath != "/status-page/migration/cancel" { + t.Errorf("path = %s, want /status-page/migration/cancel", gotPath) + } + if gotBody["job_id"] != "job-4" { + t.Errorf("job_id = %v, want job-4", gotBody["job_id"]) + } +} + +func TestCancelStatusPageMigrationRejectsEmptyJobID(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be hit for empty jobID; got %s %s", r.Method, r.URL.Path) + }) + + if err := client.CancelStatusPageMigration(context.Background(), ""); err == nil { + t.Fatalf("expected error for empty jobID, got nil") + } +} + +func TestStatusPageMigrationReturnsDutyError(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": "invalid_api_key", + "message": "source provider API key is invalid", + }, + }) + }) + + _, err := client.StartStatusPageMigration(context.Background(), &StartStatusPageMigrationInput{ + SourceAPIKey: "bad", + SourcePageID: "page_123", + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + + dutyErr, ok := err.(*DutyError) + if !ok { + t.Fatalf("error type = %T, want *DutyError (err: %v)", err, err) + } + if dutyErr.Code != "invalid_api_key" { + t.Errorf("Code = %s, want invalid_api_key", dutyErr.Code) + } + if dutyErr.Message != "source provider API key is invalid" { + t.Errorf("Message = %s, want source provider API key is invalid", dutyErr.Message) + } +} + +func TestStatusPageMigrationWrapsHTTPError(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"code":"internal","message":"boom"}}`)) + }) + + _, err := client.GetStatusPageMigrationStatus(context.Background(), "job-5") + if err == nil { + t.Fatalf("expected error, got nil") + } + + // handleAPIError wraps the HTTP status into the error message. + if !strings.Contains(err.Error(), "500") { + t.Errorf("error = %v; want it to mention HTTP 500", err) + } +} + +func TestStartStatusPageMigrationErrorsOnMissingData(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }) + + _, err := client.StartStatusPageMigration(context.Background(), &StartStatusPageMigrationInput{ + SourceAPIKey: "k", + SourcePageID: "p", + }) + if err == nil || !strings.Contains(err.Error(), "missing data") { + t.Fatalf("expected missing-data error, got %v", err) + } +} + +func TestGetStatusPageMigrationStatusErrorsOnMissingData(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }) + + _, err := client.GetStatusPageMigrationStatus(context.Background(), "job-6") + if err == nil || !strings.Contains(err.Error(), "missing data") { + t.Fatalf("expected missing-data error, got %v", err) + } +} + +func TestCancelStatusPageMigrationReturnsDutyError(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": "not_found", "message": "job not found"}, + }) + }) + + err := client.CancelStatusPageMigration(context.Background(), "missing-job") + if err == nil { + t.Fatalf("expected error, got nil") + } + if _, ok := err.(*DutyError); !ok { + t.Errorf("error type = %T, want *DutyError", err) + } +} + +func TestStartStatusPageMigrationRejectsNilInput(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be hit for nil input; got %s %s", r.Method, r.URL.Path) + }) + + if _, err := client.StartStatusPageMigration(context.Background(), nil); err == nil { + t.Fatalf("expected error for nil input, got nil") + } + if _, err := client.StartStatusPageEmailSubscriberMigration(context.Background(), nil); err == nil { + t.Fatalf("expected error for nil input, got nil") + } +} + +func TestCancelStatusPageMigrationWrapsHTTPError(t *testing.T) { + client := newSDKExtensionsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":"forbidden","message":"nope"}}`)) + }) + + err := client.CancelStatusPageMigration(context.Background(), "job-7") + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("error = %v; want it to mention HTTP 403", err) + } +}