diff --git a/go.mod b/go.mod index bd5be65..c93d4dc 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/sagernet/sing v0.7.13 github.com/sagernet/sing-box v1.12.13 github.com/stretchr/testify v1.10.0 + golang.org/x/text v0.28.0 google.golang.org/protobuf v1.36.6 ) diff --git a/go.sum b/go.sum index 4fe2780..3d58f7a 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= diff --git a/usermessage/testfixtures/fixtures.go b/usermessage/testfixtures/fixtures.go new file mode 100644 index 0000000..b2efa77 --- /dev/null +++ b/usermessage/testfixtures/fixtures.go @@ -0,0 +1,19 @@ +// Package testfixtures exposes versioned user-message JSON fixtures for +// compatibility tests in common, lantern-cloud, and Radiance. +package testfixtures + +import "embed" + +const ( + V1RequestPath = "v1/request.json" + V1ResponseEmptyPath = "v1/response_empty.json" + V1ResponseMessagePath = "v1/response_message.json" + V1ResponseNoActionPath = "v1/response_no_action.json" + V1ResponseOpenPlansPath = "v1/response_open_plans.json" +) + +// FS contains the versioned JSON fixtures named by the exported path +// constants above. +// +//go:embed v1/*.json +var FS embed.FS diff --git a/usermessage/testfixtures/v1/request.json b/usermessage/testfixtures/v1/request.json new file mode 100644 index 0000000..2a41d76 --- /dev/null +++ b/usermessage/testfixtures/v1/request.json @@ -0,0 +1,14 @@ +{ + "locale": "fa-IR", + "platform": "android", + "app_version": "9.2.1", + "capabilities": { + "version": "user_messages_v1", + "surfaces": ["snackbar"], + "actions": ["open_https_url", "open_plans"] + }, + "seen_display_ids": [ + "campaign-018f:generation-1", + "campaign-0190:generation-3" + ] +} diff --git a/usermessage/testfixtures/v1/response_empty.json b/usermessage/testfixtures/v1/response_empty.json new file mode 100644 index 0000000..c2a587f --- /dev/null +++ b/usermessage/testfixtures/v1/response_empty.json @@ -0,0 +1,3 @@ +{ + "poll_interval_seconds": 300 +} diff --git a/usermessage/testfixtures/v1/response_message.json b/usermessage/testfixtures/v1/response_message.json new file mode 100644 index 0000000..0095fa8 --- /dev/null +++ b/usermessage/testfixtures/v1/response_message.json @@ -0,0 +1,18 @@ +{ + "message": { + "display_id": "campaign-0191:generation-2", + "campaign_id": "0191d9ad-8b1c-7d5a-a70d-e4f2f744c51a", + "revision_id": "0191d9ae-05e1-70e2-924d-acde79344744", + "delivery_id": "0191d9ae-612c-7ddc-95ac-f1587b557e68", + "surface": "snackbar", + "locale": "fa-IR", + "body": "به ما بگویید تجربه شما از لنترن چگونه بوده است.", + "button_label": "شرکت در نظرسنجی", + "action": { + "type": "open_https_url", + "url": "https://example.com/survey?campaign=0191" + }, + "expires_at": "2026-09-01T00:00:00Z" + }, + "poll_interval_seconds": 300 +} diff --git a/usermessage/testfixtures/v1/response_no_action.json b/usermessage/testfixtures/v1/response_no_action.json new file mode 100644 index 0000000..95c83d2 --- /dev/null +++ b/usermessage/testfixtures/v1/response_no_action.json @@ -0,0 +1,13 @@ +{ + "message": { + "display_id": "campaign-0192:generation-1", + "campaign_id": "0192d9ad-8b1c-7d5a-a70d-e4f2f744c51a", + "revision_id": "0192d9ae-05e1-70e2-924d-acde79344744", + "delivery_id": "0192d9ae-612c-7ddc-95ac-f1587b557e68", + "surface": "snackbar", + "locale": "en-US", + "body": "Lantern maintenance is complete.", + "expires_at": "2026-09-02T00:00:00Z" + }, + "poll_interval_seconds": 300 +} diff --git a/usermessage/testfixtures/v1/response_open_plans.json b/usermessage/testfixtures/v1/response_open_plans.json new file mode 100644 index 0000000..059087f --- /dev/null +++ b/usermessage/testfixtures/v1/response_open_plans.json @@ -0,0 +1,17 @@ +{ + "message": { + "display_id": "campaign-0193:generation-1", + "campaign_id": "0193d9ad-8b1c-7d5a-a70d-e4f2f744c51a", + "revision_id": "0193d9ae-05e1-70e2-924d-acde79344744", + "delivery_id": "0193d9ae-612c-7ddc-95ac-f1587b557e68", + "surface": "snackbar", + "locale": "en-US", + "body": "Upgrade to Lantern Pro for unlimited data.", + "button_label": "View plans", + "action": { + "type": "open_plans" + }, + "expires_at": "2026-09-03T00:00:00Z" + }, + "poll_interval_seconds": 300 +} diff --git a/usermessage/types.go b/usermessage/types.go new file mode 100644 index 0000000..2f85c02 --- /dev/null +++ b/usermessage/types.go @@ -0,0 +1,95 @@ +// Package usermessage defines the public JSON wire contract used to fetch +// resolved in-app messages. Campaign authoring, targeting, and publication +// types intentionally live in lantern-cloud rather than this package. +package usermessage + +import "time" + +const ( + // CapabilityUserMessagesV1 identifies both support for user messages and + // version 1 of this wire contract. + CapabilityUserMessagesV1 = "user_messages_v1" + + // MaxPollIntervalSeconds is the longest interval a server may recommend + // between successful user-message requests. + MaxPollIntervalSeconds = 5 * 60 + + // Wire-size limits are measured in UTF-8 bytes, except + // MaxSeenDisplayIDs, which limits the number of list entries. + MaxLocaleLength = 64 + MaxPlatformLength = 32 + MaxAppVersionLength = 64 + MaxSeenDisplayIDs = 128 + MaxDisplayIDLength = 128 + MaxDiagnosticIDLength = 128 + MaxMessageBodyLength = 2048 + MaxButtonLabelLength = 128 + MaxActionURLLength = 2048 + MaxSupportedSurfaces = 16 + MaxSupportedActions = 32 +) + +// Surface identifies how a resolved message should be presented. Consumers +// must ignore messages with surfaces they do not support. +type Surface string + +const ( + SurfaceSnackbar Surface = "snackbar" +) + +// ActionType identifies an allowlisted operation initiated by a message +// button. The absence of an Action means the message has no button action. +type ActionType string + +const ( + ActionTypeOpenHTTPSURL ActionType = "open_https_url" + ActionTypeOpenPlans ActionType = "open_plans" +) + +// ClientCapabilities identifies the wire version and presentation features +// that the requesting client can safely handle. +type ClientCapabilities struct { + Version string `json:"version"` + Surfaces []Surface `json:"surfaces"` + Actions []ActionType `json:"actions,omitempty"` +} + +// UserMessageRequest contains only the client context needed to resolve a +// message. Authentication and canonical user identity are supplied by the +// transport/account layer, not asserted in this payload. +type UserMessageRequest struct { + Locale string `json:"locale"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + Capabilities ClientCapabilities `json:"capabilities"` + SeenDisplayIDs []string `json:"seen_display_ids,omitempty"` +} + +// UserMessageResponse contains at most one resolved message. A nil Message +// means no message is currently eligible for this client. +type UserMessageResponse struct { + Message *ResolvedUserMessage `json:"message,omitempty"` + PollIntervalSeconds int `json:"poll_interval_seconds"` +} + +// ResolvedUserMessage is presentation-ready. It deliberately excludes +// campaign targeting and other backoffice-only state. +type ResolvedUserMessage struct { + DisplayID string `json:"display_id"` + CampaignID string `json:"campaign_id"` + RevisionID string `json:"revision_id"` + DeliveryID string `json:"delivery_id"` + Surface Surface `json:"surface"` + Locale string `json:"locale"` + Body string `json:"body"` + ButtonLabel string `json:"button_label,omitempty"` + Action *Action `json:"action,omitempty"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Action contains the data required for an allowlisted client operation. +// URL is required only for ActionTypeOpenHTTPSURL. +type Action struct { + Type ActionType `json:"type"` + URL string `json:"url,omitempty"` +} diff --git a/usermessage/types_test.go b/usermessage/types_test.go new file mode 100644 index 0000000..a237758 --- /dev/null +++ b/usermessage/types_test.go @@ -0,0 +1,538 @@ +package usermessage + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/getlantern/common/usermessage/testfixtures" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestV1JSONFixturesRoundTrip(t *testing.T) { + t.Run("request", func(t *testing.T) { + fixture := readFixture(t, testfixtures.V1RequestPath) + var request UserMessageRequest + require.NoError(t, json.Unmarshal(fixture, &request)) + require.NoError(t, request.Validate()) + assert.Equal(t, CapabilityUserMessagesV1, request.Capabilities.Version) + assert.Equal(t, []Surface{SurfaceSnackbar}, request.Capabilities.Surfaces) + assert.Equal(t, []ActionType{ + ActionTypeOpenHTTPSURL, + ActionTypeOpenPlans, + }, request.Capabilities.Actions) + assert.Equal(t, "fa-IR", request.Locale) + assert.Equal(t, []string{ + "campaign-018f:generation-1", + "campaign-0190:generation-3", + }, request.SeenDisplayIDs) + assertJSONRoundTrip(t, fixture, request) + }) + + t.Run("response with message", func(t *testing.T) { + fixture := readFixture(t, testfixtures.V1ResponseMessagePath) + var response UserMessageResponse + require.NoError(t, json.Unmarshal(fixture, &response)) + require.NoError(t, response.Validate()) + require.NotNil(t, response.Message) + assert.Equal(t, SurfaceSnackbar, response.Message.Surface) + require.NotNil(t, response.Message.Action) + assert.Equal(t, ActionTypeOpenHTTPSURL, response.Message.Action.Type) + assertJSONRoundTrip(t, fixture, response) + }) + + t.Run("response without message", func(t *testing.T) { + fixture := readFixture(t, testfixtures.V1ResponseEmptyPath) + var response UserMessageResponse + require.NoError(t, json.Unmarshal(fixture, &response)) + require.NoError(t, response.Validate()) + assert.Nil(t, response.Message) + assertJSONRoundTrip(t, fixture, response) + }) + + t.Run("message without action", func(t *testing.T) { + fixture := readFixture(t, testfixtures.V1ResponseNoActionPath) + var response UserMessageResponse + require.NoError(t, json.Unmarshal(fixture, &response)) + require.NoError(t, response.Validate()) + require.NotNil(t, response.Message) + assert.Empty(t, response.Message.ButtonLabel) + assert.Nil(t, response.Message.Action) + assertJSONRoundTrip(t, fixture, response) + }) + + t.Run("open plans action", func(t *testing.T) { + fixture := readFixture(t, testfixtures.V1ResponseOpenPlansPath) + var response UserMessageResponse + require.NoError(t, json.Unmarshal(fixture, &response)) + require.NoError(t, response.Validate()) + require.NotNil(t, response.Message) + require.NotNil(t, response.Message.Action) + assert.Equal(t, ActionTypeOpenPlans, response.Message.Action.Type) + assert.Empty(t, response.Message.Action.URL) + assertJSONRoundTrip(t, fixture, response) + }) +} + +func TestRequestValidation(t *testing.T) { + valid := validRequest() + require.NoError(t, valid.Validate()) + + tests := []struct { + name string + mutate func(*UserMessageRequest) + field string + }{ + { + name: "missing locale", + mutate: func(r *UserMessageRequest) { + r.Locale = "" + }, + field: "locale", + }, + { + name: "non BCP 47 locale", + mutate: func(r *UserMessageRequest) { + r.Locale = "fa_IR" + }, + field: "locale", + }, + { + name: "private use locale without subtag", + mutate: func(r *UserMessageRequest) { + r.Locale = "x" + }, + field: "locale", + }, + { + name: "extension without value", + mutate: func(r *UserMessageRequest) { + r.Locale = "en-a" + }, + field: "locale", + }, + { + name: "numeric extension without value", + mutate: func(r *UserMessageRequest) { + r.Locale = "en-1" + }, + field: "locale", + }, + { + name: "missing platform", + mutate: func(r *UserMessageRequest) { + r.Platform = "" + }, + field: "platform", + }, + { + name: "missing app version", + mutate: func(r *UserMessageRequest) { + r.AppVersion = "" + }, + field: "app_version", + }, + { + name: "wrong capability version", + mutate: func(r *UserMessageRequest) { + r.Capabilities.Version = "user_messages_v2" + }, + field: "capabilities.version", + }, + { + name: "missing supported surfaces", + mutate: func(r *UserMessageRequest) { + r.Capabilities.Surfaces = nil + }, + field: "capabilities.surfaces", + }, + { + name: "unknown supported surface", + mutate: func(r *UserMessageRequest) { + r.Capabilities.Surfaces = []Surface{"future_surface"} + }, + field: "capabilities.surfaces[0]", + }, + { + name: "duplicate supported surface", + mutate: func(r *UserMessageRequest) { + r.Capabilities.Surfaces = []Surface{SurfaceSnackbar, SurfaceSnackbar} + }, + field: "capabilities.surfaces[1]", + }, + { + name: "unknown supported action", + mutate: func(r *UserMessageRequest) { + r.Capabilities.Actions = []ActionType{"future_action"} + }, + field: "capabilities.actions[0]", + }, + { + name: "duplicate supported action", + mutate: func(r *UserMessageRequest) { + r.Capabilities.Actions = []ActionType{ + ActionTypeOpenPlans, + ActionTypeOpenPlans, + } + }, + field: "capabilities.actions[1]", + }, + { + name: "too many seen IDs", + mutate: func(r *UserMessageRequest) { + r.SeenDisplayIDs = make([]string, MaxSeenDisplayIDs+1) + }, + field: "seen_display_ids", + }, + { + name: "duplicate seen ID", + mutate: func(r *UserMessageRequest) { + r.SeenDisplayIDs = []string{"display-1", "display-1"} + }, + field: "seen_display_ids[1]", + }, + { + name: "unsafe seen ID", + mutate: func(r *UserMessageRequest) { + r.SeenDisplayIDs = []string{"display 1"} + }, + field: "seen_display_ids[0]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := valid + request.SeenDisplayIDs = append([]string(nil), valid.SeenDisplayIDs...) + tt.mutate(&request) + assertValidationField(t, request.Validate(), tt.field) + }) + } +} + +func TestRequestMaximumLengths(t *testing.T) { + t.Run("locale", func(t *testing.T) { + request := validRequest() + request.Locale = "en-x-aaaaaaaa-aaaaaaaa-aaaaaaaa-aaaaaaaa-aaaaaaaa-aaaaaaaa-aaaaa" + require.Len(t, request.Locale, MaxLocaleLength) + require.NoError(t, request.Validate()) + + request.Locale += "a" + assertValidationField(t, request.Validate(), "locale") + }) + + tests := []struct { + name string + field string + max int + validChar string + mutate func(*UserMessageRequest, string) + }{ + { + name: "platform", + field: "platform", + max: MaxPlatformLength, + validChar: "a", + mutate: func(r *UserMessageRequest, value string) { + r.Platform = value + }, + }, + { + name: "app version", + field: "app_version", + max: MaxAppVersionLength, + validChar: "1", + mutate: func(r *UserMessageRequest, value string) { + r.AppVersion = value + }, + }, + { + name: "display ID", + field: "seen_display_ids[0]", + max: MaxDisplayIDLength, + validChar: "a", + mutate: func(r *UserMessageRequest, value string) { + r.SeenDisplayIDs = []string{value} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := validRequest() + tt.mutate(&request, strings.Repeat(tt.validChar, tt.max)) + require.NoError(t, request.Validate()) + + tt.mutate(&request, strings.Repeat(tt.validChar, tt.max+1)) + assertValidationField(t, request.Validate(), tt.field) + }) + } +} + +func TestResponseValidation(t *testing.T) { + valid := validResponse() + require.NoError(t, valid.Validate()) + require.NoError(t, (UserMessageResponse{PollIntervalSeconds: MaxPollIntervalSeconds}).Validate()) + + tests := []struct { + name string + mutate func(*UserMessageResponse) + field string + }{ + { + name: "zero poll interval", + mutate: func(r *UserMessageResponse) { + r.PollIntervalSeconds = 0 + }, + field: "poll_interval_seconds", + }, + { + name: "poll interval over maximum", + mutate: func(r *UserMessageResponse) { + r.PollIntervalSeconds = MaxPollIntervalSeconds + 1 + }, + field: "poll_interval_seconds", + }, + { + name: "unknown surface", + mutate: func(r *UserMessageResponse) { + r.Message.Surface = Surface("modal") + }, + field: "message.surface", + }, + { + name: "blank body", + mutate: func(r *UserMessageResponse) { + r.Message.Body = " " + }, + field: "message.body", + }, + { + name: "control character in body", + mutate: func(r *UserMessageResponse) { + r.Message.Body = "hello\x00world" + }, + field: "message.body", + }, + { + name: "missing expiration", + mutate: func(r *UserMessageResponse) { + r.Message.ExpiresAt = time.Time{} + }, + field: "message.expires_at", + }, + { + name: "button without action", + mutate: func(r *UserMessageResponse) { + r.Message.Action = nil + }, + field: "message.button_label", + }, + { + name: "action without button", + mutate: func(r *UserMessageResponse) { + r.Message.ButtonLabel = "" + }, + field: "message.button_label", + }, + { + name: "unknown action", + mutate: func(r *UserMessageResponse) { + r.Message.Action.Type = ActionType("open_deep_link") + }, + field: "message.action.type", + }, + { + name: "non HTTPS URL", + mutate: func(r *UserMessageResponse) { + r.Message.Action.URL = "http://example.com" + }, + field: "message.action.url", + }, + { + name: "URL with user information", + mutate: func(r *UserMessageResponse) { + r.Message.Action.URL = "https://user:password@example.com" + }, + field: "message.action.url", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response := cloneResponse(valid) + tt.mutate(&response) + assertValidationField(t, response.Validate(), tt.field) + }) + } +} + +func TestResponseMaximumLengths(t *testing.T) { + tests := []struct { + name string + field string + max int + mutate func(*ResolvedUserMessage, string) + }{ + { + name: "display ID", + field: "message.display_id", + max: MaxDisplayIDLength, + mutate: func(m *ResolvedUserMessage, value string) { + m.DisplayID = value + }, + }, + { + name: "diagnostic ID", + field: "message.campaign_id", + max: MaxDiagnosticIDLength, + mutate: func(m *ResolvedUserMessage, value string) { + m.CampaignID = value + }, + }, + { + name: "message body", + field: "message.body", + max: MaxMessageBodyLength, + mutate: func(m *ResolvedUserMessage, value string) { + m.Body = value + }, + }, + { + name: "button label", + field: "message.button_label", + max: MaxButtonLabelLength, + mutate: func(m *ResolvedUserMessage, value string) { + m.ButtonLabel = value + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response := cloneResponse(validResponse()) + tt.mutate(response.Message, strings.Repeat("a", tt.max)) + require.NoError(t, response.Validate()) + + tt.mutate(response.Message, strings.Repeat("a", tt.max+1)) + assertValidationField(t, response.Validate(), tt.field) + }) + } + + t.Run("action URL", func(t *testing.T) { + response := cloneResponse(validResponse()) + prefix := "https://example.com/" + response.Message.Action.URL = prefix + strings.Repeat("a", MaxActionURLLength-len(prefix)) + require.NoError(t, response.Validate()) + + response.Message.Action.URL += "a" + assertValidationField(t, response.Validate(), "message.action.url") + }) +} + +func TestActionValidation(t *testing.T) { + require.NoError(t, (Action{Type: ActionTypeOpenPlans}).Validate()) + + err := (Action{Type: ActionTypeOpenPlans, URL: "https://example.com/plans"}).Validate() + assertValidationField(t, err, "message.action.url") +} + +func TestValidationErrorSupportsErrorsAs(t *testing.T) { + request := validRequest() + request.Locale = "" + validationErr := request.Validate() + + var target *ValidationError + require.True(t, errors.As(validationErr, &target)) + assert.Equal(t, "locale", target.Field) +} + +func validRequest() UserMessageRequest { + return UserMessageRequest{ + Locale: "en-US", + Platform: "android", + AppVersion: "9.2.1", + Capabilities: ClientCapabilities{ + Version: CapabilityUserMessagesV1, + Surfaces: []Surface{SurfaceSnackbar}, + Actions: []ActionType{ActionTypeOpenHTTPSURL, ActionTypeOpenPlans}, + }, + SeenDisplayIDs: []string{"campaign-1:generation-1"}, + } +} + +func validResponse() UserMessageResponse { + return UserMessageResponse{ + Message: &ResolvedUserMessage{ + DisplayID: "campaign-2:generation-1", + CampaignID: "campaign-2", + RevisionID: "revision-3", + DeliveryID: "delivery-4", + Surface: SurfaceSnackbar, + Locale: "en-US", + Body: "Tell us what you think about Lantern.", + ButtonLabel: "Take survey", + Action: &Action{ + Type: ActionTypeOpenHTTPSURL, + URL: "https://example.com/survey", + }, + ExpiresAt: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC), + }, + PollIntervalSeconds: MaxPollIntervalSeconds, + } +} + +func cloneResponse(response UserMessageResponse) UserMessageResponse { + cloned := response + if response.Message != nil { + message := *response.Message + cloned.Message = &message + if response.Message.Action != nil { + action := *response.Message.Action + cloned.Message.Action = &action + } + } + return cloned +} + +func readFixture(t *testing.T, path string) []byte { + t.Helper() + data, err := testfixtures.FS.ReadFile(path) + require.NoError(t, err) + return data +} + +func assertJSONRoundTrip(t *testing.T, fixture []byte, value any) { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + assert.JSONEq(t, string(fixture), string(encoded)) + + // Standard JSON decoding deliberately ignores unknown fields, allowing a + // newer producer to add optional data without breaking a v1 consumer. + withUnknown := strings.TrimSuffix(string(encoded), "}") + `,"future_field":true}` + decoded := valueForRoundTrip(value) + require.NoError(t, json.Unmarshal([]byte(withUnknown), decoded)) +} + +func valueForRoundTrip(value any) any { + switch value.(type) { + case UserMessageRequest: + return &UserMessageRequest{} + case UserMessageResponse: + return &UserMessageResponse{} + default: + panic("unsupported fixture type") + } +} + +func assertValidationField(t *testing.T, err error, field string) { + t.Helper() + require.Error(t, err) + var validationErr *ValidationError + require.True(t, errors.As(err, &validationErr)) + assert.Equal(t, field, validationErr.Field) +} diff --git a/usermessage/validation.go b/usermessage/validation.go new file mode 100644 index 0000000..a5c6a2f --- /dev/null +++ b/usermessage/validation.go @@ -0,0 +1,273 @@ +package usermessage + +import ( + "fmt" + "net/url" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/language" +) + +// ValidationError identifies the invalid wire-contract field and why it was +// rejected. Field uses the corresponding JSON field path. +type ValidationError struct { + Field string + Reason string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("usermessage: invalid %s: %s", e.Field, e.Reason) +} + +// Validate checks whether r is safe and structurally valid for the v1 wire +// contract. Server-side targeting still owns semantic platform and app-version +// validation. +func (r UserMessageRequest) Validate() error { + if err := validateLocale("locale", r.Locale); err != nil { + return err + } + if err := validateSingleLine("platform", r.Platform, MaxPlatformLength, true); err != nil { + return err + } + if err := validateSingleLine("app_version", r.AppVersion, MaxAppVersionLength, true); err != nil { + return err + } + if err := r.Capabilities.Validate(); err != nil { + return err + } + if len(r.SeenDisplayIDs) > MaxSeenDisplayIDs { + return invalid("seen_display_ids", fmt.Sprintf("must contain at most %d entries", MaxSeenDisplayIDs)) + } + + seen := make(map[string]struct{}, len(r.SeenDisplayIDs)) + for i, id := range r.SeenDisplayIDs { + field := fmt.Sprintf("seen_display_ids[%d]", i) + if err := validateOpaqueID(field, id, MaxDisplayIDLength); err != nil { + return err + } + if _, ok := seen[id]; ok { + return invalid(field, "must not duplicate another display ID") + } + seen[id] = struct{}{} + } + return nil +} + +// Validate checks whether c describes a supported, internally consistent +// client feature set for this wire version. +func (c ClientCapabilities) Validate() error { + if c.Version != CapabilityUserMessagesV1 { + return invalid("capabilities.version", fmt.Sprintf("must be %q", CapabilityUserMessagesV1)) + } + if len(c.Surfaces) == 0 { + return invalid("capabilities.surfaces", "must contain at least one surface") + } + if len(c.Surfaces) > MaxSupportedSurfaces { + return invalid( + "capabilities.surfaces", + fmt.Sprintf("must contain at most %d entries", MaxSupportedSurfaces), + ) + } + seenSurfaces := make(map[Surface]struct{}, len(c.Surfaces)) + for i, surface := range c.Surfaces { + field := fmt.Sprintf("capabilities.surfaces[%d]", i) + if !surface.Valid() { + return invalid(field, fmt.Sprintf("unsupported value %q", surface)) + } + if _, found := seenSurfaces[surface]; found { + return invalid(field, "must not duplicate another surface") + } + seenSurfaces[surface] = struct{}{} + } + + if len(c.Actions) > MaxSupportedActions { + return invalid( + "capabilities.actions", + fmt.Sprintf("must contain at most %d entries", MaxSupportedActions), + ) + } + seenActions := make(map[ActionType]struct{}, len(c.Actions)) + for i, action := range c.Actions { + field := fmt.Sprintf("capabilities.actions[%d]", i) + if !action.Valid() { + return invalid(field, fmt.Sprintf("unsupported value %q", action)) + } + if _, found := seenActions[action]; found { + return invalid(field, "must not duplicate another action") + } + seenActions[action] = struct{}{} + } + return nil +} + +// Validate checks whether r is safe and structurally valid for the v1 wire +// contract. +func (r UserMessageResponse) Validate() error { + if r.PollIntervalSeconds <= 0 || r.PollIntervalSeconds > MaxPollIntervalSeconds { + return invalid( + "poll_interval_seconds", + fmt.Sprintf("must be between 1 and %d", MaxPollIntervalSeconds), + ) + } + if r.Message != nil { + return r.Message.Validate() + } + return nil +} + +// Validate checks whether m is presentation-ready and safe for the v1 wire +// contract. +func (m ResolvedUserMessage) Validate() error { + if err := validateOpaqueID("message.display_id", m.DisplayID, MaxDisplayIDLength); err != nil { + return err + } + if err := validateOpaqueID("message.campaign_id", m.CampaignID, MaxDiagnosticIDLength); err != nil { + return err + } + if err := validateOpaqueID("message.revision_id", m.RevisionID, MaxDiagnosticIDLength); err != nil { + return err + } + if err := validateOpaqueID("message.delivery_id", m.DeliveryID, MaxDiagnosticIDLength); err != nil { + return err + } + if !m.Surface.Valid() { + return invalid("message.surface", fmt.Sprintf("unsupported value %q", m.Surface)) + } + if err := validateLocale("message.locale", m.Locale); err != nil { + return err + } + if err := validatePlainText("message.body", m.Body, MaxMessageBodyLength, true, true); err != nil { + return err + } + if m.ExpiresAt.IsZero() { + return invalid("message.expires_at", "must be set") + } + + if m.Action == nil { + if m.ButtonLabel != "" { + return invalid("message.button_label", "must be empty when action is absent") + } + return nil + } + if err := validatePlainText("message.button_label", m.ButtonLabel, MaxButtonLabelLength, false, true); err != nil { + return err + } + return m.Action.Validate() +} + +// Validate checks whether a contains exactly the data required by its type. +func (a Action) Validate() error { + if !a.Type.Valid() { + return invalid("message.action.type", fmt.Sprintf("unsupported value %q", a.Type)) + } + switch a.Type { + case ActionTypeOpenHTTPSURL: + if len(a.URL) == 0 { + return invalid("message.action.url", "must be set for open_https_url") + } + if len(a.URL) > MaxActionURLLength { + return invalid("message.action.url", fmt.Sprintf("must be at most %d bytes", MaxActionURLLength)) + } + if !utf8.ValidString(a.URL) || containsDisallowedControl(a.URL, false) { + return invalid("message.action.url", "must be valid UTF-8 without control characters") + } + u, err := url.Parse(a.URL) + if err != nil || u.Scheme != "https" || u.Hostname() == "" { + return invalid("message.action.url", "must be an absolute HTTPS URL") + } + if u.User != nil { + return invalid("message.action.url", "must not contain user information") + } + case ActionTypeOpenPlans: + if a.URL != "" { + return invalid("message.action.url", "must be empty for open_plans") + } + } + return nil +} + +// Valid reports whether s is supported by this version of the contract. +func (s Surface) Valid() bool { + return s == SurfaceSnackbar +} + +// Valid reports whether t is supported by this version of the contract. +func (t ActionType) Valid() bool { + switch t { + case ActionTypeOpenHTTPSURL, ActionTypeOpenPlans: + return true + default: + return false + } +} + +func validateLocale(field, value string) error { + if err := validateSingleLine(field, value, MaxLocaleLength, true); err != nil { + return err + } + if strings.ContainsRune(value, '_') { + return invalid(field, "must be a BCP 47 language tag") + } + if tag, err := language.Parse(value); err != nil || tag == language.Und { + return invalid(field, "must be a BCP 47 language tag") + } + return nil +} + +func validateOpaqueID(field, value string, max int) error { + if len(value) == 0 { + return invalid(field, "must be set") + } + if len(value) > max { + return invalid(field, fmt.Sprintf("must be at most %d bytes", max)) + } + for _, r := range value { + if r > unicode.MaxASCII || !isOpaqueIDRune(r) { + return invalid(field, "must contain only ASCII letters, digits, '.', '_', ':', or '-'") + } + } + return nil +} + +func isOpaqueIDRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._:-", r) +} + +func validateSingleLine(field, value string, max int, required bool) error { + return validatePlainText(field, value, max, false, required) +} + +func validatePlainText(field, value string, max int, allowLineBreaks, required bool) error { + if required && strings.TrimSpace(value) == "" { + return invalid(field, "must be set") + } + if len(value) > max { + return invalid(field, fmt.Sprintf("must be at most %d bytes", max)) + } + if !utf8.ValidString(value) { + return invalid(field, "must be valid UTF-8") + } + if containsDisallowedControl(value, allowLineBreaks) { + return invalid(field, "must not contain control characters") + } + return nil +} + +func containsDisallowedControl(value string, allowLineBreaks bool) bool { + for _, r := range value { + if !unicode.IsControl(r) { + continue + } + if allowLineBreaks && (r == '\n' || r == '\r' || r == '\t') { + continue + } + return true + } + return false +} + +func invalid(field, reason string) error { + return &ValidationError{Field: field, Reason: reason} +}