Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions cmd/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
30 changes: 30 additions & 0 deletions cmd/send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
31 changes: 31 additions & 0 deletions cmd/transactional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 19 additions & 1 deletion internal/client/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions internal/client/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
58 changes: 10 additions & 48 deletions internal/validate/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions internal/validate/json_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}