diff --git a/cmd/send.go b/cmd/send.go index 03a1469..3f4664f 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -288,14 +288,13 @@ func runTrackSend(cmd *cobra.Command, sendPath string, body json.RawMessage) err return err } - // Resolve track API base URL: CIO_TRACK_URL env var, or derived from region. - trackURL := os.Getenv("CIO_TRACK_URL") - if trackURL == "" { - apiURL, _ := cmd.Flags().GetString("api-url") - region := client.ResolveRegion(apiURL, cmd.Flags().Changed("api-url")) - trackURL = client.TrackBaseURLForRegion(region) - } - trackURL = strings.TrimRight(trackURL, "/") + // Resolve track API base URL: explicit --api-url override, CIO_TRACK_URL env + // var, or derived from the active profile's region / base URL. + apiURL, _ := cmd.Flags().GetString("api-url") + trackURL := strings.TrimRight( + client.ResolveTrackBaseURL(apiURL, cmd.Flags().Changed("api-url")), + "/", + ) timeout, _ := cmd.Flags().GetDuration("timeout") diff --git a/cmd/send_test.go b/cmd/send_test.go index 10164d7..fe4f6b9 100644 --- a/cmd/send_test.go +++ b/cmd/send_test.go @@ -270,6 +270,36 @@ func TestSend_DryRun(t *testing.T) { } } +// TestSend_DryRun_APIURLOverridesTrackHost covers SELF-48: an explicit +// --api-url must direct the track send at that host, not production. +func TestSend_DryRun_APIURLOverridesTrackHost(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CIO_TOKEN", "sa_live_test123") + t.Setenv("CIO_ACCESS_TOKEN", "") + t.Setenv("CIO_TRACK_URL", "") + + stdout, _, err := executeCommand("send", "email", + "--environment-id", "71981", + "--token", "sa_live_test123", + "--to", "test@example.com", + "--from", "noreply@example.com", + "--subject", "Test", + "--body", "hello", + "--api-url", "https://track.example.test", + "--dry-run") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout) + } + if result["url"] != "https://track.example.test/v1/send/email" { + t.Errorf("expected overridden track URL, got %v", result["url"]) + } +} + // --------------------------------------------------------------------------- // Validation errors // --------------------------------------------------------------------------- diff --git a/cmd/transactional_test.go b/cmd/transactional_test.go index 61e2181..dcb9d10 100644 --- a/cmd/transactional_test.go +++ b/cmd/transactional_test.go @@ -157,6 +157,37 @@ func TestTransactionalSend_DryRun(t *testing.T) { } } +// TestTransactionalSend_DryRun_APIURLOverridesTrackHost covers SELF-48 for the +// transactional send path: --api-url must direct the send at that host. +func TestTransactionalSend_DryRun_APIURLOverridesTrackHost(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CIO_TOKEN", "sa_live_test123") + t.Setenv("CIO_ACCESS_TOKEN", "") + t.Setenv("CIO_ENVIRONMENT_ID", "") + t.Setenv("CIO_TRACK_URL", "") + resetSendFlags() + resetTransactionalFlags() + + stdout, _, err := executeCommand("transactional", "send", "email", + "--environment-id", "71981", + "--token", "sa_live_test123", + "--transactional-message-id", "3", + "--to", "test@example.com", + "--api-url", "https://track.example.test", + "--dry-run") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout) + } + if result["url"] != "https://track.example.test/v1/send/email" { + t.Errorf("expected overridden track URL, got %v", result["url"]) + } +} + func TestTransactionalSend_JQFilter(t *testing.T) { _, cleanup := setupTransactionalSendTest(t, "sa_live_test123", "123") defer cleanup() diff --git a/internal/client/auth.go b/internal/client/auth.go index 380f17f..181ecf4 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -81,7 +81,7 @@ func BaseURLForRegion(region string) string { // TrackBaseURLForRegion returns the track.customer.io URL for the given region. // The track API hosts the transactional send endpoints (POST /v1/send/*). -// To override the host, set CIO_TRACK_URL. +// To override the host, pass --api-url or set CIO_TRACK_URL. func TrackBaseURLForRegion(region string) string { switch strings.ToLower(strings.TrimSpace(region)) { case "eu": @@ -91,6 +91,24 @@ func TrackBaseURLForRegion(region string) string { } } +// ResolveTrackBaseURL determines the track API base URL for send commands, in +// priority order: +// 1. --api-url flag (when explicitly set) — used verbatim, mirroring `cio api` +// 2. CIO_TRACK_URL environment variable +// 3. Derived from the resolved region (CIO_REGION > profile region > "us") +// +// Non-production hosts that have no region mapping are reached via --api-url or +// CIO_TRACK_URL. +func ResolveTrackBaseURL(apiURLFlag string, apiURLChanged bool) string { + if apiURLChanged && apiURLFlag != "" { + return apiURLFlag + } + if v := os.Getenv("CIO_TRACK_URL"); v != "" { + return v + } + return TrackBaseURLForRegion(ResolveRegion("", false)) +} + // RegionFromBaseURL extracts the region from a base URL, or empty string if unknown. func RegionFromBaseURL(baseURL string) string { switch { diff --git a/internal/client/auth_test.go b/internal/client/auth_test.go index 85e90f6..36e8dd2 100644 --- a/internal/client/auth_test.go +++ b/internal/client/auth_test.go @@ -358,3 +358,44 @@ func TestResolveRegion(t *testing.T) { t.Errorf("expected 'eu', got %q", got) } } + +// TestResolveTrackBaseURL covers SELF-48: --api-url, CIO_TRACK_URL, and the +// active profile's region must drive the track host, not a hardcoded one. +func TestResolveTrackBaseURL(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_REGION", "") + t.Setenv("CIO_API_URL", "") + t.Setenv("CIO_TRACK_URL", "") + + // Default: no flag, no env, no profile → production US track host. + if got := ResolveTrackBaseURL("", false); got != "https://track.customer.io" { + t.Errorf("default: got %q, want https://track.customer.io", got) + } + + // Explicit --api-url override wins and is used verbatim. + if got := ResolveTrackBaseURL("https://track.example.test", true); got != "https://track.example.test" { + t.Errorf("--api-url override: got %q, want https://track.example.test", got) + } + + // CIO_TRACK_URL env override (no flag). + t.Setenv("CIO_TRACK_URL", "https://track-env.example.test") + if got := ResolveTrackBaseURL("", false); got != "https://track-env.example.test" { + t.Errorf("CIO_TRACK_URL: got %q, want https://track-env.example.test", got) + } + t.Setenv("CIO_TRACK_URL", "") + + // Derived from the active profile's stored region. + creds := Credentials{ServiceAccountToken: "sa_live_x", Region: "eu"} + data, _ := json.Marshal(creds) + dir := filepath.Join(tmpDir, ".cio") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0600); err != nil { + t.Fatal(err) + } + if got := ResolveTrackBaseURL("", false); got != "https://track-eu.customer.io" { + t.Errorf("profile-derived: got %q, want https://track-eu.customer.io", got) + } +} diff --git a/internal/validate/json.go b/internal/validate/json.go index af32817..0bdbad8 100644 --- a/internal/validate/json.go +++ b/internal/validate/json.go @@ -13,6 +13,12 @@ func ValidateJSONPayload(raw string) (json.RawMessage, error) { return nil, &JSONValidationError{Reason: "JSON payload must not be empty"} } + // Validate UTF-8 explicitly. The payload is sent to the API verbatim, and + // json.Unmarshal does NOT reject invalid UTF-8 inside string values — for a + // json.RawMessage it copies the bytes through unchanged — so this scan is + // what guarantees the bytes we send are valid UTF-8. It also rejects + // unescaped control characters (other than \n, \r, \t); escaped control + // characters (\n, \t, \r, \uXXXX) are valid JSON and pass through. for i := 0; i < len(raw); { r, size := utf8.DecodeRuneInString(raw[i:]) if r == utf8.RuneError && size <= 1 { @@ -22,7 +28,7 @@ func ValidateJSONPayload(raw string) (json.RawMessage, error) { } if r < 0x20 && r != '\n' && r != '\r' && r != '\t' { return nil, &JSONValidationError{ - Reason: fmt.Sprintf("JSON payload contains control character at byte position %d (U+%04X)", i, r), + Reason: fmt.Sprintf("JSON payload contains an unescaped control character at byte position %d (U+%04X)", i, r), } } i += size @@ -42,56 +48,12 @@ func ValidateJSONPayload(raw string) (json.RawMessage, error) { } } - if err := checkControlCharsInStrings(parsed); err != nil { - return nil, err - } - + // parsed is the raw bytes verbatim, already confirmed valid UTF-8 by the + // scan above. Escaped control characters (\n, \t, \r, \uXXXX) remain — they + // are valid JSON and required for multi-line content such as template bodies. return parsed, nil } -func checkControlCharsInStrings(data json.RawMessage) error { - var obj map[string]json.RawMessage - if err := json.Unmarshal(data, &obj); err == nil { - for key, val := range obj { - if err := stringHasControlChars(key); err != nil { - return err - } - if err := checkControlCharsInStrings(val); err != nil { - return err - } - } - return nil - } - - var arr []json.RawMessage - if err := json.Unmarshal(data, &arr); err == nil { - for _, val := range arr { - if err := checkControlCharsInStrings(val); err != nil { - return err - } - } - return nil - } - - var s string - if err := json.Unmarshal(data, &s); err == nil { - return stringHasControlChars(s) - } - - return nil -} - -func stringHasControlChars(s string) error { - for i, r := range s { - if r < 0x20 { - return &JSONValidationError{ - Reason: fmt.Sprintf("JSON string value contains control character at position %d (U+%04X)", i, r), - } - } - } - return nil -} - // ValidateStringValue rejects string values containing control characters // (U+0000–U+001F). Used for flag values that end up in request bodies. func ValidateStringValue(flag, value string) error { diff --git a/internal/validate/json_test.go b/internal/validate/json_test.go new file mode 100644 index 0000000..8f6fdac --- /dev/null +++ b/internal/validate/json_test.go @@ -0,0 +1,71 @@ +package validate + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestValidateJSONPayload_Valid(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"simple object", `{"a":"b"}`}, + {"nested object", `{"template":{"subject":"hi"}}`}, + {"array value", `{"items":["a","b"]}`}, + {"unicode value", `{"name":"café"}`}, + {"emoji value", `{"note":"hi 👋"}`}, + // SELF-47: escaped control characters are valid JSON and must be accepted, + // otherwise all multi-line content (HTML/plain-text bodies) is rejected. + {"escaped newline", `{"template":{"subject":"line1\nline2"}}`}, + {"escaped tab", `{"template":{"subject":"col1\tcol2"}}`}, + {"escaped carriage return", `{"template":{"subject":"line1\rline2"}}`}, + {"unicode-escaped newline", "{\"template\":{\"subject\":\"line1\\u000aline2\"}}"}, + {"unicode-escaped NUL", "{\"template\":{\"subject\":\"a\\u0000b\"}}"}, + {"escaped control char in key", `{"a\nb":"c"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parsed, err := ValidateJSONPayload(tc.raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The returned bytes are sent to the API verbatim, so they must be + // valid UTF-8. + if !utf8.Valid(parsed) { + t.Errorf("returned payload is not valid UTF-8: %q", string(parsed)) + } + }) + } +} + +func TestValidateJSONPayload_Invalid(t *testing.T) { + cases := []struct { + name string + raw string + errWant string + }{ + {"empty", ``, "must not be empty"}, + {"whitespace only", ` `, "must not be empty"}, + {"not json", `not json`, "not valid JSON"}, + {"array", `["a","b"]`, "must be an object"}, + {"string", `"hello"`, "must be an object"}, + {"number", `42`, "must be an object"}, + // An unescaped (literal) control character inside a string is invalid JSON + // per the spec, so it is still rejected. + {"literal NUL byte", "{\"a\":\"b\x00c\"}", "control character"}, + {"invalid utf-8", "{\"a\":\"\xff\"}", "invalid UTF-8"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateJSONPayload(tc.raw) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.errWant) + } + if !strings.Contains(err.Error(), tc.errWant) { + t.Errorf("error mismatch:\n want substring: %q\n got: %q", tc.errWant, err.Error()) + } + }) + } +}