diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index f9b756ed..7d3a8a4e 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -30,6 +30,9 @@ jobs: bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV + - name: Generate adapter certificates + run: bash tests/generate-adapter-certificates.sh tests/certs + - name: Start Services working-directory: ./tests run: docker compose up -d --build diff --git a/.gitignore b/.gitignore index 8f4caf4b..3bd4ee82 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ android/app/debug/ android/app/release/ tests/firebase-credentials.json +tests/certs/ tests/emulator/emulator.exe SECURITY_AUDIT_REPORT.md @@ -16,3 +17,4 @@ SECURITY_AUDIT_REPORT.md .output .agents/ skills-lock.json +.worktrees/ diff --git a/api/docs/docs.go b/api/docs/docs.go index 96cf0e55..eb72ac76 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2420,7 +2420,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -2483,7 +2483,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -4947,6 +4947,7 @@ const docTemplate = `{ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4975,6 +4976,7 @@ const docTemplate = `{ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 4926b5e5..59c5637b 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -2417,7 +2417,7 @@ "ApiKeyAuth": [] } ], - "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -2480,7 +2480,7 @@ "ApiKeyAuth": [] } ], - "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'", + "description": "Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.", "consumes": [ "application/json" ], @@ -4944,6 +4944,7 @@ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, @@ -4972,6 +4973,7 @@ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 9cfefbf5..6dc24021 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -971,6 +971,8 @@ definitions: requests.PhoneFCMToken: properties: fcm_token: + description: FcmToken is either a Firebase registration token or a public + HTTPS adapter callback URL. example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string phone_number: @@ -989,6 +991,8 @@ definitions: requests.PhoneUpsert: properties: fcm_token: + description: FcmToken is either a Firebase registration token or a public + HTTPS adapter callback URL. example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string max_send_attempts: @@ -3337,7 +3341,8 @@ paths: consumes: - application/json description: Updates properties of a user's phone. If the phone with this number - does not exist, a new one will be created. Think of this method like an 'upsert' + does not exist, a new one will be created. Think of this method like an 'upsert'. + URL-backed phone gateways receive FCM-compatible HTTP wake-ups. parameters: - description: Payload of new phone number. in: body @@ -3417,8 +3422,10 @@ paths: put: consumes: - application/json - description: Updates the FCM token of a phone. If the phone with this number - does not exist, a new one will be created. Think of this method like an 'upsert' + description: Updates the FCM token or adapter callback URL of a phone. If the + phone with this number does not exist, a new one will be created. Think of + this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible + HTTP wake-ups. parameters: - description: Payload of new FCM token. in: body diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index ebe57662..8d9e778d 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -565,6 +565,24 @@ func (container *Container) FCMClient() services.FCMClient { return services.NewFirebaseFCMClient(messagingClient) } +// NotificationHTTPClient creates the OpenTelemetry-instrumented client for phone notification adapters. +func (container *Container) NotificationHTTPClient() *http.Client { + return &http.Client{ + Transport: container.HTTPRoundTripperWithoutRetry("phone_notification_http"), + } +} + +// PhoneNotificationClients creates notification clients keyed by phone transport. +func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient { + return map[entities.NotificationTransport]services.FCMClient{ + entities.NotificationTransportFCM: container.FCMClient(), + entities.NotificationTransportHTTP: services.NewHTTPNotificationSender( + container.Logger(), + container.NotificationHTTPClient(), + ), + } +} + // FirebaseCredentials returns firebase credentials as bytes. func (container *Container) FirebaseCredentials() []byte { container.logger.Debug("creating firebase credentials") @@ -1715,7 +1733,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi return services.NewNotificationService( container.Logger(), container.Tracer(), - container.FCMClient(), + container.PhoneNotificationClients(), container.PhoneRepository(), container.PhoneNotificationRepository(), container.MessageSendScheduleRepository(), diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go new file mode 100644 index 00000000..611708d3 --- /dev/null +++ b/api/pkg/di/container_test.go @@ -0,0 +1,36 @@ +package di + +import ( + "reflect" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) { + t.Setenv("ENV", "local") + client := NewLiteContainer().NotificationHTTPClient() + + assert.Zero(t, client.Timeout) + assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(client.Transport).String()) + assert.Nil(t, client.CheckRedirect) +} + +func TestPhoneNotificationClientsMapsConfiguredTransports(t *testing.T) { + t.Setenv("ENV", "local") + t.Setenv("FCM_ENDPOINT", "http://localhost") + + clients := NewLiteContainer().PhoneNotificationClients() + + require.Len(t, clients, 2) + assert.IsType(t, &services.EmulatorFCMClient{}, clients[entities.NotificationTransportFCM]) + httpSender, ok := clients[entities.NotificationTransportHTTP].(*services.HTTPNotificationSender) + require.True(t, ok) + client := reflect.ValueOf(httpSender).Elem().FieldByName("client").Elem() + transport := client.FieldByName("Transport").Elem() + + assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String()) +} diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 4f3c33f2..36be28ae 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -1,8 +1,11 @@ package entities import ( + "net/url" + "strings" "time" + "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" ) @@ -31,6 +34,25 @@ type Phone struct { UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` } +// NotificationTransport identifies how a phone receives wake-up notifications. +type NotificationTransport string + +const ( + // NotificationTransportFCM sends notifications through Firebase. + NotificationTransportFCM NotificationTransport = "fcm" + // NotificationTransportHTTP sends notifications to an HTTPS endpoint. + NotificationTransportHTTP NotificationTransport = "http" +) + +func isNotificationURLCandidate(token string) bool { + lower := strings.ToLower(token) + + return strings.Contains(token, "://") || + strings.HasPrefix(lower, "http:") || + strings.HasPrefix(lower, "https:") || + strings.HasPrefix(lower, "ftp:") +} + // MessageExpirationDuration returns the message expiration as time.Duration func (phone *Phone) MessageExpirationDuration() time.Duration { return time.Duration(int(phone.MessageExpirationSecondsSanitized())) * time.Second @@ -51,3 +73,52 @@ func (phone *Phone) MaxSendAttemptsSanitized() uint { } return phone.MaxSendAttempts } + +// NotificationTransport returns the transport encoded by FcmToken. +func (phone *Phone) NotificationTransport() (NotificationTransport, error) { + if phone == nil || phone.FcmToken == nil { + return "", stacktrace.NewErrorf("phone has no notification token") + } + + token := strings.TrimSpace(*phone.FcmToken) + if token == "" { + return "", stacktrace.NewErrorf("phone has no notification token") + } + + if !isNotificationURLCandidate(token) { + return NotificationTransportFCM, nil + } + + endpoint, err := url.Parse(token) + if err != nil { + return "", stacktrace.NewError("invalid notification URL") + } + + if !strings.EqualFold(endpoint.Scheme, "https") { + return "", stacktrace.NewErrorf("notification URL must use https") + } + if endpoint.Hostname() == "" { + return "", stacktrace.NewErrorf("notification URL must include a hostname") + } + + return NotificationTransportHTTP, nil +} + +// NotificationURL returns the parsed endpoint for an HTTP notification token. +func (phone *Phone) NotificationURL() (*url.URL, error) { + transport, err := phone.NotificationTransport() + if err != nil { + return nil, err + } + + if transport != NotificationTransportHTTP { + return nil, stacktrace.NewErrorf("phone notification transport is [%s], not HTTP", transport) + } + + endpoint, err := url.Parse(strings.TrimSpace(*phone.FcmToken)) + if err != nil { + return nil, stacktrace.NewError("cannot parse notification URL") + } + + return endpoint, nil +} diff --git a/api/pkg/entities/phone_notification.go b/api/pkg/entities/phone_notification.go index 8720579a..ab3fc590 100644 --- a/api/pkg/entities/phone_notification.go +++ b/api/pkg/entities/phone_notification.go @@ -18,7 +18,7 @@ const ( // PhoneNotificationStatus is the status of a phone notification type PhoneNotificationStatus string -// PhoneNotification represents an FCM notification to a mobile phone +// PhoneNotification represents a scheduled wake-up notification for a phone gateway. type PhoneNotification struct { ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;"` MessageID uuid.UUID `json:"message_id"` diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go new file mode 100644 index 00000000..886d31f6 --- /dev/null +++ b/api/pkg/entities/phone_test.go @@ -0,0 +1,85 @@ +package entities + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stringPointer(value string) *string { + return &value +} + +func TestPhoneNotificationTransport(t *testing.T) { + tests := []struct { + name string + token *string + transport NotificationTransport + hasError bool + }{ + {name: "firebase token with colon", token: stringPointer("fcm-token:value"), transport: NotificationTransportFCM}, + {name: "opaque token with slash", token: stringPointer("projects/alpha/messages/123"), transport: NotificationTransportFCM}, + {name: "public https url", token: stringPointer("https://adapter.example.com/notify"), transport: NotificationTransportHTTP}, + {name: "missing token", token: nil, hasError: true}, + {name: "empty token", token: stringPointer(" "), hasError: true}, + {name: "http url", token: stringPointer("http://adapter.example.com/notify"), hasError: true}, + {name: "ftp url", token: stringPointer("ftp://adapter.example.com/notify"), hasError: true}, + {name: "scheme-like https token", token: stringPointer("https:adapter.example.com"), hasError: true}, + {name: "scheme-like http token", token: stringPointer("http:foo"), hasError: true}, + {name: "scheme-like ftp token", token: stringPointer("ftp:foo"), hasError: true}, + {name: "missing host", token: stringPointer("https:///notify"), hasError: true}, + {name: "embedded user information", token: stringPointer("https://user:password@adapter.example.com/notify"), transport: NotificationTransportHTTP}, + {name: "malformed url", token: stringPointer("https://[::1"), hasError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + phone := &Phone{FcmToken: test.token} + + transport, err := phone.NotificationTransport() + + if test.hasError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.transport, transport) + }) + } +} + +func TestPhoneNotificationURL(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("https://user:password@adapter.example.com/notify?tenant=42")} + + endpoint, err := phone.NotificationURL() + + require.NoError(t, err) + assert.Equal(t, "https", endpoint.Scheme) + assert.Equal(t, "user", endpoint.User.Username()) + password, hasPassword := endpoint.User.Password() + assert.True(t, hasPassword) + assert.Equal(t, "password", password) + assert.Equal(t, "adapter.example.com", endpoint.Hostname()) + assert.Equal(t, "/notify", endpoint.Path) + assert.Equal(t, "tenant=42", endpoint.RawQuery) +} + +func TestPhoneNotificationURLRejectsFCMToken(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("fcm-token:value")} + + _, err := phone.NotificationURL() + + require.Error(t, err) +} + +func TestPhoneNotificationTransportDoesNotExposeMalformedToken(t *testing.T) { + token := "https://[::1/secret?token=customer-secret" + phone := &Phone{FcmToken: &token} + + _, err := phone.NotificationTransport() + + require.Error(t, err) + assert.NotContains(t, err.Error(), token) + assert.NotContains(t, err.Error(), "customer-secret") +} diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index 55380ed9..c81ef4dd 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -94,7 +94,7 @@ func (h *PhoneHandler) Index(c fiber.Ctx) error { // Upsert a phone // @Summary Upsert Phone -// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json @@ -172,7 +172,7 @@ func (h *PhoneHandler) Delete(c fiber.Ctx) error { // UpsertFCMToken upserts the FCM token of a phone // @Summary Upserts the FCM token of a phone -// @Description Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Description Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups. // @Security ApiKeyAuth // @Tags Phones // @Accept json diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go index e8de42e2..cd11c600 100644 --- a/api/pkg/middlewares/http_request_logger_middleware.go +++ b/api/pkg/middlewares/http_request_logger_middleware.go @@ -24,7 +24,7 @@ func HTTPRequestLogger(tracer telemetry.Tracer, logger telemetry.Logger) fiber.H statusCode := c.Response().StatusCode() span.AddEvent(fmt.Sprintf("finished handling request with traceID: [%s], statusCode: [%d]", span.SpanContext().TraceID().String(), statusCode)) if statusCode >= 300 && len(c.Request().Body()) > 0 && !slices.Contains([]int{401, 402}, statusCode) { - ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, string(c.Request().Body()))) + ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, c.Request().Body())) } return response diff --git a/api/pkg/requests/phone_fcm_token_request.go b/api/pkg/requests/phone_fcm_token_request.go index dde935b5..bdd3ab1a 100644 --- a/api/pkg/requests/phone_fcm_token_request.go +++ b/api/pkg/requests/phone_fcm_token_request.go @@ -13,7 +13,8 @@ import ( type PhoneFCMToken struct { request PhoneNumber string `json:"phone_number" example:"[+18005550199]"` - FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` + // FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. + FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` } diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index 96b2882e..cedd51a0 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -25,6 +25,7 @@ type PhoneUpsert struct { // MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline. MaxSendAttempts uint `json:"max_send_attempts" example:"2"` + // FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."` diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go index 4e56f316..6b60b824 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -6,9 +6,9 @@ import ( "firebase.google.com/go/messaging" ) -// FCMClient is the interface for sending Firebase Cloud Messaging notifications. +// FCMClient sends Firebase-compatible messages through a phone notification transport. type FCMClient interface { - // Send sends a message via FCM and returns the message name on success. + // Send sends a message and returns the transport's delivery identifier on success. Send(ctx context.Context, message *messaging.Message) (string, error) } diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go new file mode 100644 index 00000000..9477611a --- /dev/null +++ b/api/pkg/services/http_notification_sender.go @@ -0,0 +1,230 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" + "github.com/avast/retry-go/v5" +) + +const ( + maxNotificationResponseDiscardBytes = 4 * 1024 + notificationHTTPAttempts = 3 + notificationHTTPTimeout = 5 * time.Second + notificationHTTPRetryDelay = 250 * time.Millisecond +) + +// HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. +type HTTPNotificationSender struct { + logger telemetry.Logger + client *http.Client + retrier *retry.Retrier + timeout time.Duration +} + +var _ FCMClient = (*HTTPNotificationSender)(nil) + +// NewHTTPNotificationSender creates an HTTP notification sender. +func NewHTTPNotificationSender( + logger telemetry.Logger, + client *http.Client, +) *HTTPNotificationSender { + return newHTTPNotificationSenderWithRetrier( + logger, + client, + newHTTPNotificationRetrier(notificationHTTPRetryDelay), + ) +} + +func newHTTPNotificationSenderWithRetrier( + logger telemetry.Logger, + client *http.Client, + retrier *retry.Retrier, +) *HTTPNotificationSender { + return &HTTPNotificationSender{ + logger: logger, + client: client, + retrier: retrier, + timeout: notificationHTTPTimeout, + } +} + +// Send delivers a notification to an HTTPS adapter. A successful response only accepts wake-up delivery. +func (sender *HTTPNotificationSender) Send( + ctx context.Context, + message *messaging.Message, +) (string, error) { + if message == nil { + return "", sender.notificationError("", "notification message is nil") + } + + endpoint, err := url.Parse(message.Token) + if err != nil { + return "", sender.notificationError("", "cannot parse notification endpoint") + } + hostname := endpoint.Hostname() + + body, err := encodeHTTPNotificationPayload(message) + if err != nil { + return "", sender.notificationError(hostname, "cannot encode notification") + } + + err = sender.retrier.Do(func() error { + return sender.deliver(ctx, endpoint, body) + }) + if err == nil { + return "http/success", nil + } + if ctx.Err() != nil { + return "", sender.notificationError(hostname, "notification request cancelled") + } + + return "", sender.notificationError(hostname, "notification request failed") +} + +func encodeHTTPNotificationPayload(message *messaging.Message) ([]byte, error) { + return json.Marshal(map[string]any{ + "message": message, + }) +} + +func (sender *HTTPNotificationSender) deliver( + ctx context.Context, + endpoint *url.URL, + body []byte, +) error { + if err := ctx.Err(); err != nil { + return terminalNotificationRequestError{cause: err} + } + + attemptCtx, cancel := context.WithTimeout(ctx, sender.timeout) + defer cancel() + + request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body) + if err != nil { + return terminalNotificationRequestError{cause: err} + } + + err = sender.sendAttempt(request) + if ctx.Err() != nil { + return terminalNotificationRequestError{cause: ctx.Err()} + } + return err +} + +func createHTTPNotificationRequest( + ctx context.Context, + endpoint *url.URL, + body []byte, +) (*http.Request, error) { + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint.String(), + bytes.NewReader(body), + ) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + return request, nil +} + +func (sender *HTTPNotificationSender) sendAttempt(request *http.Request) error { + response, err := sender.client.Do(request) + if err != nil { + return err + } + if response.Body != nil { + _, _ = io.CopyN(io.Discard, response.Body, maxNotificationResponseDiscardBytes) + _ = response.Body.Close() + } + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return nil + } + if isRetryableNotificationStatus(response.StatusCode) { + return retryableNotificationStatusError{statusCode: response.StatusCode} + } + return terminalNotificationStatusError{statusCode: response.StatusCode} +} + +func newHTTPNotificationRetrier(delay time.Duration) *retry.Retrier { + return retry.New( + retry.Attempts(notificationHTTPAttempts), + retry.Delay(delay), + retry.DelayType(retry.BackOffDelay), + retry.LastErrorOnly(true), + retry.RetryIf(isRetryableNotificationError), + ) +} + +func (sender *HTTPNotificationSender) notificationError(hostname string, message string) error { + if hostname == "" { + hostname = "unknown" + } + err := stacktrace.Propagatef(stacktrace.NewErrorf("%s", message), "cannot send notification to [%s]", hostname) + if sender.logger != nil { + sender.logger.Error(err) + } + return err +} + +func isRetryableNotificationStatus(statusCode int) bool { + return statusCode == http.StatusRequestTimeout || + statusCode == http.StatusTooManyRequests || + (statusCode >= http.StatusInternalServerError && statusCode < 600) +} + +type retryableNotificationStatusError struct { + statusCode int +} + +func (error retryableNotificationStatusError) Error() string { + return http.StatusText(error.statusCode) +} + +type terminalNotificationStatusError struct { + statusCode int +} + +func (error terminalNotificationStatusError) Error() string { + return http.StatusText(error.statusCode) +} + +type terminalNotificationRequestError struct { + cause error +} + +func (notificationError terminalNotificationRequestError) Error() string { + return notificationError.cause.Error() +} + +func (notificationError terminalNotificationRequestError) Unwrap() error { + return notificationError.cause +} + +func isRetryableNotificationError(err error) bool { + if err == nil || isTerminalNotificationError(err) { + return false + } + return true +} + +func isTerminalNotificationError(err error) bool { + var statusError terminalNotificationStatusError + if errors.As(err, &statusError) { + return true + } + + var requestError terminalNotificationRequestError + return errors.As(err, &requestError) +} diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go new file mode 100644 index 00000000..075f99c8 --- /dev/null +++ b/api/pkg/services/http_notification_sender_test.go @@ -0,0 +1,426 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "reflect" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +type httpNotificationPayload struct { + Message struct { + Token string `json:"token"` + Data map[string]string `json:"data"` + Android struct { + Priority string `json:"priority"` + TTL string `json:"ttl,omitempty"` + } `json:"android"` + } `json:"message"` +} + +func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { + ttl := 10 * time.Minute + message := &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + Android: &messaging.AndroidConfig{ + Priority: "high", + TTL: &ttl, + }, + } + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "application/json", request.Header.Get("Content-Type")) + + var payload httpNotificationPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "https://adapter.example.com/notify", request.URL.String()) + assert.Equal(t, "https://adapter.example.com/notify", payload.Message.Token) + assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}, payload.Message.Data) + assert.Equal(t, "high", payload.Message.Android.Priority) + assert.Equal(t, "600s", payload.Message.Android.TTL) + + return response(http.StatusNoContent, http.NoBody), nil + })) + + result, err := sender.Send(context.Background(), message) + + require.NoError(t, err) + assert.Equal(t, "http/success", result) +} + +func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { + tests := []struct { + name string + outcomes []roundTripOutcome + wantCalls int + wantErr bool + }{ + { + name: "network error then accepted", + outcomes: []roundTripOutcome{ + {err: errors.New("connection reset")}, + {statusCode: http.StatusAccepted}, + }, + wantCalls: 2, + }, + { + name: "request timeout then success", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusRequestTimeout}, + {statusCode: http.StatusOK}, + }, + wantCalls: 2, + }, + { + name: "rate limited then no content", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusTooManyRequests}, + {statusCode: http.StatusNoContent}, + }, + wantCalls: 2, + }, + { + name: "server errors then no content", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusInternalServerError}, + {statusCode: http.StatusBadGateway}, + {statusCode: http.StatusNoContent}, + }, + wantCalls: 3, + }, + { + name: "bad request fails immediately", + outcomes: []roundTripOutcome{{statusCode: http.StatusBadRequest}}, + wantCalls: 1, + wantErr: true, + }, + { + name: "three service unavailable responses fail", + outcomes: []roundTripOutcome{ + {statusCode: http.StatusServiceUnavailable}, + {statusCode: http.StatusServiceUnavailable}, + {statusCode: http.StatusServiceUnavailable}, + }, + wantCalls: 3, + wantErr: true, + }, + { + name: "redirect fails immediately", + outcomes: []roundTripOutcome{{statusCode: http.StatusFound}}, + wantCalls: 1, + wantErr: true, + }, + { + name: "nonstandard 6xx response fails immediately", + outcomes: []roundTripOutcome{{statusCode: 600}}, + wantCalls: 1, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + outcome := test.outcomes[calls] + calls++ + if outcome.err != nil { + return nil, outcome.err + } + return response(outcome.statusCode, http.NoBody), nil + })) + result, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + ) + + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, test.wantCalls, calls) + if !test.wantErr { + assert.Equal(t, "http/success", result) + } + }) + } +} + +func TestHTTPNotificationSenderReusesRetrierAcrossSends(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + calls++ + if calls%2 == 1 { + return response(http.StatusServiceUnavailable, http.NoBody), nil + } + return response(http.StatusNoContent, http.NoBody), nil + })) + + for range 2 { + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + ) + require.NoError(t, err) + } + + assert.Equal(t, 4, calls) +} + +func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *testing.T) { + var requests []*http.Request + var bodies [][]byte + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests = append(requests, request) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + bodies = append(bodies, body) + if len(requests) < 3 { + return response(http.StatusServiceUnavailable, http.NoBody), nil + } + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + }, + ) + + require.NoError(t, err) + require.Len(t, requests, 3) + assert.NotSame(t, requests[0], requests[1]) + assert.NotSame(t, requests[1], requests[2]) + require.Len(t, bodies, 3) + assert.NotEmpty(t, bodies[0]) + assert.Equal(t, bodies[0], bodies[1]) + assert.Equal(t, bodies[1], bodies[2]) +} + +func TestHTTPNotificationSenderBoundsResponseBodyDiscard(t *testing.T) { + body := &boundedReadCloser{remaining: 8192} + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, body), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + ) + + require.NoError(t, err) + assert.Equal(t, int64(4096), body.read) + assert.True(t, body.closed) +} + +func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload httpNotificationPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "high", payload.Message.Android.Priority) + assert.Empty(t, payload.Message.Android.TTL) + assert.Equal(t, "heartbeat-1", payload.Message.Data["KEY_HEARTBEAT_ID"]) + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send( + context.Background(), + &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"}, + Android: &messaging.AndroidConfig{ + Priority: "high", + }, + }, + ) + + require.NoError(t, err) +} + +func TestHTTPNotificationSenderUsesInjectedHTTPClientUnchanged(t *testing.T) { + transport := roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + }) + client := &http.Client{ + Transport: transport, + Timeout: time.Minute, + } + + sender := NewHTTPNotificationSender(nil, client) + + assert.Same(t, client, sender.client) + assert.Equal(t, reflect.ValueOf(transport).Pointer(), reflect.ValueOf(sender.client.Transport).Pointer()) + assert.Equal(t, time.Minute, sender.client.Timeout) +} + +func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + username, password, ok := request.BasicAuth() + assert.True(t, ok) + assert.Equal(t, "adapter-user", username) + assert.Equal(t, "adapter-password", password) + return response(http.StatusNoContent, http.NoBody), nil + })) + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com", + Path: "/notify", + } + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: endpoint.String()}, + ) + + require.NoError(t, err) +} + +func TestHTTPNotificationSenderBoundsEveryAttemptByTimeout(t *testing.T) { + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + <-request.Context().Done() + return nil, request.Context().Err() + })) + sender.timeout = 10 * time.Millisecond + + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + ) + + require.Error(t, err) + assert.Equal(t, 3, calls) +} + +func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + cancel() + <-request.Context().Done() + return nil, request.Context().Err() + })) + + _, err := sender.Send( + ctx, + &messaging.Message{Token: "https://adapter.example.com/notify"}, + ) + + require.Error(t, err) + assert.Equal(t, 1, calls) +} + +func TestHTTPNotificationSenderRejectsNilMessage(t *testing.T) { + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + })) + + _, err := sender.Send(context.Background(), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "notification message is nil") +} + +type roundTripOutcome struct { + statusCode int + err error +} + +type boundedReadCloser struct { + remaining int64 + read int64 + closed bool +} + +func (reader *boundedReadCloser) Read(buffer []byte) (int, error) { + if reader.remaining == 0 { + return 0, io.EOF + } + read := int64(len(buffer)) + if read > reader.remaining { + read = reader.remaining + } + reader.remaining -= read + reader.read += read + return int(read), nil +} + +func (reader *boundedReadCloser) Close() error { + reader.closed = true + return nil +} + +type httpNotificationRecordingLogger struct { + errors []string +} + +func (logger *httpNotificationRecordingLogger) Error(err error) { + logger.errors = append(logger.errors, err.Error()) +} + +func (logger *httpNotificationRecordingLogger) WithService(string) telemetry.Logger { return logger } + +func (logger *httpNotificationRecordingLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *httpNotificationRecordingLogger) WithSpan(trace.SpanContext) telemetry.Logger { + return logger +} + +func (logger *httpNotificationRecordingLogger) Trace(string) {} +func (logger *httpNotificationRecordingLogger) Info(string) {} +func (logger *httpNotificationRecordingLogger) Warn(error) {} +func (logger *httpNotificationRecordingLogger) Debug(string) {} +func (logger *httpNotificationRecordingLogger) Fatal(error) {} +func (logger *httpNotificationRecordingLogger) Printf(string, ...interface{}) {} + +func newHTTPNotificationSender(t *testing.T, transport roundTripFunc) *HTTPNotificationSender { + t.Helper() + return newHTTPNotificationSenderWithLogger(t, nil, transport) +} + +func newHTTPNotificationSenderWithLogger( + t *testing.T, + logger telemetry.Logger, + transport roundTripFunc, +) *HTTPNotificationSender { + t.Helper() + return newHTTPNotificationSenderWithRetrier( + logger, + &http.Client{Transport: transport}, + newHTTPNotificationRetrier(0), + ) +} + +func response(statusCode int, body io.ReadCloser) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: body, + Header: make(http.Header), + } +} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 0d4d8d20..d3166854 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" + "strings" "time" + "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/events" cloudevents "github.com/cloudevents/sdk-go/v2" - "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -18,7 +19,7 @@ import ( "go.opentelemetry.io/otel/trace" ) -// PhoneNotificationService sends out notifications to mobile phones +// PhoneNotificationService sends wake-up notifications to phone gateways. type PhoneNotificationService struct { service logger telemetry.Logger @@ -26,7 +27,7 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - messagingClient FCMClient + phoneNotificationClients map[entities.NotificationTransport]FCMClient eventDispatcher *EventDispatcher } @@ -34,20 +35,20 @@ type PhoneNotificationService struct { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - messagingClient FCMClient, + phoneNotificationClients map[entities.NotificationTransport]FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, - dispatcher *EventDispatcher, + eventDispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, - messagingClient: messagingClient, + phoneNotificationClients: phoneNotificationClients, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, - eventDispatcher: dispatcher, + eventDispatcher: eventDispatcher, } } @@ -77,7 +78,7 @@ func (service *PhoneNotificationService) DeleteByMessageID(ctx context.Context, return nil } -// SendHeartbeatFCM sends a heartbeat message so the phone can request a heartbeat +// SendHeartbeatFCM sends a heartbeat notification so the phone gateway can request a heartbeat. func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, payload *events.PhoneHeartbeatMissedPayload) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -88,25 +89,27 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p } if phone.FcmToken == nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "phone with id [%s] has no FCM token", phone.ID)) + return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID)) } - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, _, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - Token: *phone.FcmToken, + Android: &messaging.AndroidConfig{Priority: "high"}, }) if err != nil { - ctxLogger.Warn(stacktrace.Propagatef(err, "cannot send heartbeat FCM to phone with id [%s] for user [%s]", phone.ID, phone.UserID)) + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot send heartbeat notification to phone with id [%s] for user [%s]", + phone.ID, + phone.UserID, + )) return nil } ctxLogger.Info(fmt.Sprintf( - "successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", + "successfully sent heartbeat notification [%s] to phone with ID [%s] for user [%s] and monitor [%s]", result, payload.PhoneID, payload.UserID, @@ -125,7 +128,7 @@ type PhoneNotificationSendParams struct { MessageID uuid.UUID } -// Send sends a message when a message is sent +// Send sends a phone gateway notification when a message is sent. func (service *PhoneNotificationService) Send(ctx context.Context, params *PhoneNotificationSendParams) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -142,7 +145,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, transport, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, @@ -150,24 +153,80 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone Priority: "normal", TTL: &ttl, }, - Token: *phone.FcmToken, }) if err != nil { + if transport == "" { + ctxLogger.Warn(stacktrace.Propagatef( + err, + "cannot determine notification transport for phone with ID [%s] for user with ID [%s] and message [%s]", + phone.ID, + phone.UserID, + params.MessageID, + )) + msg := fmt.Sprintf("cannot send notification to phone [%s]. Check the notification configuration.", phone.PhoneNumber) + return service.handleNotificationFailed(ctx, errors.New(msg), params) + } + ctxLogger.Warn(stacktrace.Propagatef( err, - - "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", + "cannot send %s notification to phone with ID [%s] for user with ID [%s] and message [%s]", + transport, phone.ID, phone.UserID, params.MessageID, )) msg := fmt.Sprintf("cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) + if transport == entities.NotificationTransportHTTP { + msg = fmt.Sprintf( + "cannot notify the configured adapter for phone [%s]. Check the adapter URL and availability.", + phone.PhoneNumber, + ) + } return service.handleNotificationFailed(ctx, errors.New(msg), params) } return service.handleNotificationSent(ctx, phone, result, params) } +func (service *PhoneNotificationService) sendPhoneNotification( + ctx context.Context, + phone *entities.Phone, + message *messaging.Message, +) (string, entities.NotificationTransport, error) { + if message == nil { + return "", "", stacktrace.NewErrorf("notification message is nil") + } + + transport, err := phone.NotificationTransport() + if err != nil { + return "", "", stacktrace.Propagatef( + err, + "cannot determine notification transport for phone [%s]", + phone.ID, + ) + } + + client, ok := service.phoneNotificationClients[transport] + if !ok || client == nil { + return "", transport, stacktrace.NewErrorf( + "notification client is not configured for transport [%s]", + transport, + ) + } + + message.Token = strings.TrimSpace(*phone.FcmToken) + result, err := client.Send(ctx, message) + if err != nil { + return "", transport, stacktrace.Propagatef( + err, + "cannot send [%s] notification to phone [%s]", + transport, + phone.ID, + ) + } + return result, transport, nil +} + // PhoneNotificationScheduleParams are parameters for sending a notification type PhoneNotificationScheduleParams struct { UserID entities.UserID diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go new file mode 100644 index 00000000..9908f5d9 --- /dev/null +++ b/api/pkg/services/phone_notification_service_test.go @@ -0,0 +1,325 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +type phoneNotificationPhoneRepository struct { + repositories.PhoneRepository + phone *entities.Phone + err error +} + +func (repository *phoneNotificationPhoneRepository) LoadByID( + _ context.Context, + _ entities.UserID, + _ uuid.UUID, +) (*entities.Phone, error) { + return repository.phone, repository.err +} + +type phoneNotificationRepository struct { + repositories.PhoneNotificationRepository + notificationID uuid.UUID + status entities.PhoneNotificationStatus +} + +func (repository *phoneNotificationRepository) UpdateStatus( + _ context.Context, + notificationID uuid.UUID, + status entities.PhoneNotificationStatus, +) error { + repository.notificationID = notificationID + repository.status = status + return nil +} + +type phoneNotificationEventQueue struct { + events []cloudevents.Event +} + +func (queue *phoneNotificationEventQueue) Enqueue( + _ context.Context, + task *PushQueueTask, + _ time.Duration, +) (string, error) { + var event cloudevents.Event + if err := json.Unmarshal(task.Body, &event); err != nil { + return "", err + } + queue.events = append(queue.events, event) + return "", nil +} + +type phoneNotificationLogger struct { + warnings []string +} + +var _ telemetry.Logger = (*phoneNotificationLogger)(nil) + +func (logger *phoneNotificationLogger) Error(error) {} +func (logger *phoneNotificationLogger) WithService(string) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) WithString(string, string) telemetry.Logger { + return logger +} + +func (logger *phoneNotificationLogger) WithSpan(trace.SpanContext) telemetry.Logger { return logger } +func (logger *phoneNotificationLogger) Trace(string) {} +func (logger *phoneNotificationLogger) Info(string) {} +func (logger *phoneNotificationLogger) Warn(err error) { + logger.warnings = append(logger.warnings, err.Error()) +} +func (logger *phoneNotificationLogger) Debug(string) {} +func (logger *phoneNotificationLogger) Fatal(error) {} +func (logger *phoneNotificationLogger) Printf(string, ...interface{}) {} + +type recordingPhoneNotificationClient struct { + message *messaging.Message + result string + err error + calls int +} + +func (client *recordingPhoneNotificationClient) Send( + _ context.Context, + message *messaging.Message, +) (string, error) { + client.calls++ + client.message = message + return client.result, client.err +} + +func TestPhoneNotificationServiceSendPhoneNotificationUsesMappedClient(t *testing.T) { + endpoint := " https://adapter.example.com/notify " + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{result: "accepted"} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportHTTP: httpClient, + }, + } + message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + + result, transport, err := service.sendPhoneNotification(context.Background(), phone, message) + + require.NoError(t, err) + assert.Equal(t, "accepted", result) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Equal(t, "https://adapter.example.com/notify", message.Token) + assert.Same(t, message, httpClient.message) + assert.Equal(t, 1, httpClient.calls) +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsMissingClient(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &endpoint} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{}, + } + + _, transport, err := service.sendPhoneNotification( + context.Background(), + phone, + &messaging.Message{}, + ) + + require.Error(t, err) + assert.Equal(t, entities.NotificationTransportHTTP, transport) + assert.Contains(t, err.Error(), "notification client is not configured for transport [http]") +} + +func TestPhoneNotificationServiceSendPhoneNotificationRejectsNilMessage(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &token} + fcmClient := &recordingPhoneNotificationClient{} + service := &PhoneNotificationService{ + phoneNotificationClients: map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + }, + } + + _, transport, err := service.sendPhoneNotification(context.Background(), phone, nil) + + require.Error(t, err) + assert.Empty(t, transport) + assert.Contains(t, err.Error(), "notification message is nil") + assert.Zero(t, fcmClient.calls) +} + +func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ + ID: uuid.New(), + UserID: "user-1", + FcmToken: &endpoint, + PhoneNumber: "+18005550199", + MessageExpirationSeconds: 90, + } + httpClient := &recordingPhoneNotificationClient{result: "http/success"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + ScheduledAt: time.Now().UTC(), + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + assert.Equal(t, params.MessageID.String(), httpClient.message.Data["KEY_MESSAGE_ID"]) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "normal", httpClient.message.Android.Priority) + require.NotNil(t, httpClient.message.Android.TTL) + assert.Equal(t, phone.MessageExpirationDuration(), *httpClient.message.Android.TTL) + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationSent, eventQueue.events[0].Type()) + assert.Equal(t, params.PhoneNotificationID, notificationRepository.notificationID) + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusSent), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{ + err: errors.New("adapter unavailable"), + }, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationFailed, eventQueue.events[0].Type()) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot notify the configured adapter for phone [+18005550199]. Check the adapter URL and availability.", payload.ErrorMessage) + assert.NotContains(t, payload.ErrorMessage, "Reinstall the httpSMS app") + assert.Equal(t, entities.PhoneNotificationStatus(entities.PhoneNotificationStatusFailed), notificationRepository.status) +} + +func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testing.T) { + token := "fcm-token" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &token, PhoneNumber: "+18005550199"} + eventQueue := &phoneNotificationEventQueue{} + fcmClient := &recordingPhoneNotificationClient{err: errors.New("firebase unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{}, + }, + ) + params := &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + } + + require.NoError(t, service.Send(context.Background(), params)) + + require.Len(t, eventQueue.events, 1) + require.NotNil(t, fcmClient.message) + assert.Equal(t, token, fcmClient.message.Token) + assert.Equal(t, 1, fcmClient.calls) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventQueue.events[0].DataAs(&payload)) + assert.Equal(t, "cannot send notification to your phone [+18005550199]. Reinstall the httpSMS app on your Android phone.", payload.ErrorMessage) +} + +func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSMessage(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} + httpClient := &recordingPhoneNotificationClient{err: errors.New("adapter unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + &phoneNotificationEventQueue{}, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) + + err := service.SendHeartbeatFCM(context.Background(), &events.PhoneHeartbeatMissedPayload{ + UserID: phone.UserID, + PhoneID: phone.ID, + MonitorID: uuid.New(), + }) + + require.NoError(t, err) + require.NotNil(t, httpClient.message) + assert.Equal(t, endpoint, httpClient.message.Token) + heartbeatID := httpClient.message.Data["KEY_HEARTBEAT_ID"] + _, err = time.Parse(time.RFC3339, heartbeatID) + require.NoError(t, err) + require.NotNil(t, httpClient.message.Android) + assert.Equal(t, "high", httpClient.message.Android.Priority) + assert.Nil(t, httpClient.message.Android.TTL) +} + +func newPhoneNotificationServiceForTest( + phone *entities.Phone, + notificationRepository repositories.PhoneNotificationRepository, + eventQueue *phoneNotificationEventQueue, + clients map[entities.NotificationTransport]FCMClient, +) *PhoneNotificationService { + logger := &phoneNotificationLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + return NewNotificationService( + logger, + tracer, + clients, + &phoneNotificationPhoneRepository{phone: phone}, + notificationRepository, + nil, + NewEventDispatcher(logger, tracer, nil, eventQueue, PushQueueConfig{}), + ) +} diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go index e9d4274e..9e21d26a 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -103,6 +103,11 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user return result } + validator.validateNotificationToken(request.FcmToken, result) + if len(result) > 0 { + return result + } + if strings.TrimSpace(request.MessageSendScheduleID) != "" { scheduleID, _ := uuid.Parse(strings.TrimSpace(request.MessageSendScheduleID)) if _, err := validator.scheduleService.Load(ctx, userID, scheduleID); err != nil { @@ -133,7 +138,29 @@ func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, requ }, }) - return v.ValidateStruct() + result := v.ValidateStruct() + if len(result) > 0 { + return result + } + + validator.validateNotificationToken(request.FcmToken, result) + return result +} + +func (validator *PhoneHandlerValidator) validateNotificationToken( + token string, + result url.Values, +) { + token = strings.TrimSpace(token) + if token == "" { + return + } + + phone := &entities.Phone{FcmToken: &token} + _, err := phone.NotificationTransport() + if err != nil { + result.Add("fcm_token", err.Error()) + } } // ValidateDelete ValidateUpsert validates requests.PhoneDelete diff --git a/api/pkg/validators/phone_handler_validator_test.go b/api/pkg/validators/phone_handler_validator_test.go new file mode 100644 index 00000000..c9dc72b6 --- /dev/null +++ b/api/pkg/validators/phone_handler_validator_test.go @@ -0,0 +1,144 @@ +package validators + +import ( + "context" + "net/url" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" +) + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURL(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURLOnUpsert(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsPrivateAndLoopbackNotificationHosts(t *testing.T) { + validator := newPhoneHandlerValidator() + + for _, token := range []string{ + "https://localhost/notify", + "https://127.0.0.1/notify", + "https://10.0.0.5/notify", + } { + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors, token) + } +} + +func TestPhoneHandlerValidatorRejectsInvalidNotificationURLs(t *testing.T) { + tests := []struct { + name string + token string + }{ + {name: "insecure HTTP", token: "http://adapter.example.com/notify"}, + {name: "missing host", token: "https:///notify"}, + {name: "malformed HTTPS", token: "https://%"}, + } + + validationPaths := []struct { + name string + validate func(*PhoneHandlerValidator, string) map[string][]string + }{ + { + name: "FCM token upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + }) + }, + }, + { + name: "phone upsert", + validate: func(validator *PhoneHandlerValidator, token string) map[string][]string { + return validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ + PhoneNumber: "+18005550199", + FcmToken: token, + SIM: entities.SIM1.String(), + MessageExpirationSeconds: 60, + }) + }, + }, + } + + for _, validationPath := range validationPaths { + t.Run(validationPath.name, func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + validationErrors := validationPath.validate(newPhoneHandlerValidator(), test.token) + + assert.NotEmpty(t, validationErrors["fcm_token"]) + }) + } + }) + } +} + +func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationToken(t *testing.T) { + validator := newPhoneHandlerValidator() + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "opaque-firebase-registration-token", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func TestPhoneHandlerValidatorAcceptsNotificationURLWithUserInformation(t *testing.T) { + validator := newPhoneHandlerValidator() + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com", + Path: "/notify", + } + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: endpoint.String(), + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func newPhoneHandlerValidator() *PhoneHandlerValidator { + logger := &contactValidatorNoopLogger{} + return NewPhoneHandlerValidator( + logger, + telemetry.NewOtelLogger("test", logger), + nil, + ) +} diff --git a/docs/superpowers/plans/2026-09-02-url-backed-phone-notification-adapter.md b/docs/superpowers/plans/2026-09-02-url-backed-phone-notification-adapter.md new file mode 100644 index 00000000..6b81ee35 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-url-backed-phone-notification-adapter.md @@ -0,0 +1,1863 @@ +# URL-backed Phone Notification Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow a phone whose existing `fcm_token` is an HTTPS URL to receive message and heartbeat wake-ups over HTTP while preserving the current scheduling, backpressure, outstanding-message, and status-event flows. + +**Architecture:** Add transport helpers to `entities.Phone`, inject a map of existing `FCMClient` implementations keyed by transport, and let `PhoneNotificationService` select the client through map lookup. The HTTP path uses a standard OpenTelemetry-wrapped `http.Client`, application retries with `github.com/avast/retry-go/v5`, FCM-compatible JSON, and the existing notification success/failure state transitions. + +**Tech Stack:** Go 1.25.8, Fiber v3, Firebase Admin Messaging, OpenTelemetry, `net/http`, `retry-go/v5`, Testify, Docker Compose. + +**Spec:** `docs/superpowers/specs/2026-09-02-url-backed-phone-notification-adapter-design.md` + +## Final Accepted Architecture + +This section supersedes later historical task text and code samples that +introduce an endpoint policy, DNS/IP filtering, custom dialing, or a +private-host allowlist. + +- `Phone.NotificationTransport` performs only URL syntax, HTTPS scheme, and + hostname classification; URL user information remains valid. +- `Container.NotificationHTTPClient` uses the existing + `go-otelroundtripper` pattern with the `phone_notification_http` name and + the default transport, matching the webhook client style without custom URL + redaction. +- `HTTPNotificationSender` creates one reusable `*retry.Retrier` during + initialization. It owns exactly three application attempts with exponential + backoff, a fresh request/body, and a five-second child context per attempt; + caller contexts are enforced per operation rather than stored on the + reusable retrier. +- Payload encoding, request creation, one-attempt delivery, and retry + configuration are separate focused methods. OpenTelemetry HTTP telemetry is + owned by the injected round-tripper, with no sender-specific attempt metrics + or spans. +- No endpoint DNS/IP/SSRF policy, custom notification transport, or private-host + allowlist is part of the final implementation. +- `PhoneNotificationService` receives + `map[entities.NotificationTransport]FCMClient`, determines transport once, + selects the client by map lookup, sets the trimmed destination in + `message.Token`, and passes the same pointer to `FCMClient.Send`. There is no + extra sender interface, Firebase wrapper, or dispatcher class. +- `HTTPNotificationSender` implements `FCMClient` exactly and returns + `http/success` after a successful callback response. +- Domain events continue to use the existing `EventDispatcher` directly. + +## Global Constraints + +- Reuse the existing `Phone.FcmToken` database field and `fcm_token` API field; add no transport or endpoint columns. +- A valid `https://` URL with a hostname selects HTTP; an opaque non-URL token selects Firebase. +- URL-like malformed or unsupported tokens are invalid and must never fall through to Firebase. +- Send both outstanding-message and heartbeat notifications through the selected transport. +- HTTP callback requests are unsigned and contain no message content, user API key, phone API key, or other credentials. +- HTTPS callback URLs may include standard URL user information. +- Accept any HTTP `2xx`; ignore response content. +- Make at most three HTTP attempts with a five-second timeout per attempt. +- Retry network failures, `408`, `429`, and `5xx`; do not retry other non-`2xx` responses. +- Use the standard OpenTelemetry-wrapped HTTP transport without destination + DNS/IP filtering, custom dialing, or a private-host allowlist. +- Preserve existing schedules, per-minute backpressure, message expiration, send-attempt counting, outstanding-message fetching, and message event routes. +- Use `stacktrace.Propagate` or `stacktrace.Propagatef` for returned errors. +- Use GORM query builders with context propagation; this feature requires no database query changes or migration. +- Format Go code with `go-fumpt` through the repository's existing tooling. + +--- + +## File Structure + +### Create + +- `api/pkg/entities/phone_test.go` - table-driven transport classification tests. +- `api/pkg/services/notification_endpoint_policy.go` - public HTTPS URL validation, reserved-IP rejection, and validated dialing. +- `api/pkg/services/notification_endpoint_policy_test.go` - deterministic resolver/dialer tests, including DNS rebinding protection. +- `api/pkg/services/fcm_client.go` - common phone notification client contract and Firebase client. +- `api/pkg/services/phone_notification_service.go` - message construction and transport-client lookup. +- `api/pkg/services/http_notification_sender.go` - HTTP request encoding, retry classification, and timeout. +- `api/pkg/services/http_notification_sender_test.go` - payload, retry, and response tests. +- `api/pkg/services/phone_notification_service_test.go` - message and heartbeat integration tests with hand-written fakes. +- `api/pkg/validators/phone_handler_validator_test.go` - URL token validation tests for both phone update routes. +- `tests/adapter-emulator/Dockerfile` - container image for the HTTPS adapter emulator. +- `tests/adapter-emulator/go.mod` - isolated emulator module. +- `tests/adapter-emulator/main.go` - HTTPS callback and HTTP control server startup. +- `tests/adapter-emulator/emulator.go` - gateway registry, deduplication, and callback records. +- `tests/adapter-emulator/api_client.go` - existing httpSMS phone API calls. +- `tests/adapter-emulator/notification_handler.go` - FCM-envelope message and heartbeat handling. +- `tests/adapter-emulator/control_handler.go` - test registration, incoming-message, and record endpoints. +- `tests/adapter_integration_test.go` - outgoing, incoming, and heartbeat end-to-end tests. +- `tests/generate-adapter-certificates.sh` - throwaway CA and server-certificate generation. + +### Modify + +- `api/pkg/entities/phone.go` - add `NotificationTransport`, `NotificationTransport()`, and `NotificationURL()`. +- `api/pkg/services/fcm_client.go` - keep the SDK wrapper; document its role as the low-level Firebase client. +- `api/pkg/services/phone_notification_service.go` - replace direct Firebase messages with neutral notifications and transport-aware failure text. +- `api/pkg/validators/phone_handler_validator.go` - inject and apply the endpoint policy for URL-like tokens. +- `api/pkg/di/container.go` - construct the HTTP client, transport-client map, + and updated service dependencies. +- `api/pkg/requests/phone_update_request.go` - document dual-purpose `fcm_token`. +- `api/pkg/requests/phone_fcm_token_request.go` - document dual-purpose `fcm_token`. +- `api/pkg/entities/phone_notification.go` - update FCM-specific comments to transport-neutral wording. +- `api/docs/docs.go` - regenerate with `swag`. +- `api/docs/swagger.json` - regenerate with `swag`. +- `api/docs/swagger.yaml` - regenerate with `swag`. +- `tests/docker-compose.yml` - run the emulator and mount TLS material. +- `tests/.env.test` - allowlist the emulator hostname only in local mode. +- `tests/helpers_test.go` - adapter setup, control client, and internal-event helpers. +- `tests/README.md` - document emulator architecture and commands. +- `.github/workflows/api.yml` - generate adapter certificates before Docker startup. +- `.gitignore` - ignore generated adapter certificates. + +--- + +### Task 1: Classify the Existing Token Field + +**Files:** +- Modify: `api/pkg/entities/phone.go` +- Create: `api/pkg/entities/phone_test.go` + +**Interfaces:** +- Consumes: `Phone.FcmToken *string` +- Produces: + +```go +type NotificationTransport string + +const ( + NotificationTransportFCM NotificationTransport = "fcm" + NotificationTransportHTTP NotificationTransport = "http" +) + +func (phone *Phone) NotificationTransport() (NotificationTransport, error) +func (phone *Phone) NotificationURL() (*url.URL, error) +``` + +- [ ] **Step 1: Write failing transport-classification tests** + +Create `api/pkg/entities/phone_test.go`: + +```go +package entities + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stringPointer(value string) *string { + return &value +} + +func TestPhoneNotificationTransport(t *testing.T) { + tests := []struct { + name string + token *string + transport NotificationTransport + hasError bool + }{ + {name: "firebase token", token: stringPointer("fcm-token:value"), transport: NotificationTransportFCM}, + {name: "public https url", token: stringPointer("https://adapter.example.com/notify"), transport: NotificationTransportHTTP}, + {name: "missing token", token: nil, hasError: true}, + {name: "empty token", token: stringPointer(" "), hasError: true}, + {name: "http url", token: stringPointer("http://adapter.example.com/notify"), hasError: true}, + {name: "ftp url", token: stringPointer("ftp://adapter.example.com/notify"), hasError: true}, + {name: "missing host", token: stringPointer("https:///notify"), hasError: true}, + {name: "embedded credentials", token: stringPointer("https://user:pass@adapter.example.com/notify"), hasError: true}, + {name: "malformed url", token: stringPointer("https://[::1"), hasError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + phone := &Phone{FcmToken: test.token} + + transport, err := phone.NotificationTransport() + + if test.hasError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.transport, transport) + }) + } +} + +func TestPhoneNotificationURL(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("https://adapter.example.com/notify?tenant=42")} + + endpoint, err := phone.NotificationURL() + + require.NoError(t, err) + assert.Equal(t, "https", endpoint.Scheme) + assert.Equal(t, "adapter.example.com", endpoint.Hostname()) + assert.Equal(t, "/notify", endpoint.Path) + assert.Equal(t, "tenant=42", endpoint.RawQuery) +} + +func TestPhoneNotificationURLRejectsFCMToken(t *testing.T) { + phone := &Phone{FcmToken: stringPointer("fcm-token:value")} + + _, err := phone.NotificationURL() + + require.Error(t, err) +} +``` + +- [ ] **Step 2: Run the entity tests and confirm the new API is missing** + +Run: + +```bash +cd api +go test ./pkg/entities -run 'TestPhoneNotification' -count=1 +``` + +Expected: compilation fails because `NotificationTransport`, +`NotificationTransportFCM`, `NotificationTransportHTTP`, +`Phone.NotificationTransport`, and `Phone.NotificationURL` do not exist. + +- [ ] **Step 3: Implement token classification once on `Phone`** + +Add imports for `fmt`, `net/url`, and `strings` in +`api/pkg/entities/phone.go`, then add: + +```go +// NotificationTransport identifies how a phone receives wake-up notifications. +type NotificationTransport string + +const ( + // NotificationTransportFCM sends notifications through Firebase. + NotificationTransportFCM NotificationTransport = "fcm" + // NotificationTransportHTTP sends notifications to a public HTTPS endpoint. + NotificationTransportHTTP NotificationTransport = "http" +) + +// NotificationTransport returns the transport encoded by FcmToken. +func (phone *Phone) NotificationTransport() (NotificationTransport, error) { + if phone.FcmToken == nil || strings.TrimSpace(*phone.FcmToken) == "" { + return "", fmt.Errorf("phone has no notification token") + } + + token := strings.TrimSpace(*phone.FcmToken) + endpoint, err := url.Parse(token) + if err != nil { + if strings.Contains(token, "://") { + return "", fmt.Errorf("invalid notification URL: %w", err) + } + return NotificationTransportFCM, nil + } + + if endpoint.Scheme == "" { + return NotificationTransportFCM, nil + } + if endpoint.Scheme != "https" { + return "", fmt.Errorf("notification URL must use https") + } + if endpoint.Hostname() == "" { + return "", fmt.Errorf("notification URL must include a hostname") + } + return NotificationTransportHTTP, nil +} + +// NotificationURL returns the parsed endpoint for an HTTP notification token. +func (phone *Phone) NotificationURL() (*url.URL, error) { + transport, err := phone.NotificationTransport() + if err != nil { + return nil, err + } + if transport != NotificationTransportHTTP { + return nil, fmt.Errorf("phone notification transport is [%s], not HTTP", transport) + } + + endpoint, err := url.Parse(strings.TrimSpace(*phone.FcmToken)) + if err != nil { + return nil, fmt.Errorf("cannot parse notification URL: %w", err) + } + return endpoint, nil +} +``` + +Before finishing this step, replace the plain `fmt.Errorf` wrappers with +`stacktrace.Propagatef` or `stacktrace.NewErrorf` to match repository error +conventions. Preserve the exact public method signatures above. + +- [ ] **Step 4: Run the focused entity tests** + +Run: + +```bash +cd api +go test ./pkg/entities -run 'TestPhoneNotification' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +Run: + +```bash +cd api +go-fumpt -w pkg/entities/phone.go pkg/entities/phone_test.go +git add pkg/entities/phone.go pkg/entities/phone_test.go +git commit -m "feat(api): classify phone notification tokens" +``` + +--- + +### Task 2: Enforce Public HTTPS Endpoint Policy + +**Files:** +- Create: `api/pkg/services/notification_endpoint_policy.go` +- Create: `api/pkg/services/notification_endpoint_policy_test.go` + +**Interfaces:** +- Consumes: parsed HTTPS endpoints from `Phone.NotificationURL()` +- Produces: + +```go +type HostResolver interface { + LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) +} + +type NotificationEndpointPolicy struct { + resolver HostResolver + allowedPrivateHosts map[string]struct{} +} + +func NewNotificationEndpointPolicy(resolver HostResolver, allowedPrivateHosts []string) *NotificationEndpointPolicy +func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint *url.URL) ([]netip.Addr, error) +func (policy *NotificationEndpointPolicy) DialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) +``` + +- [ ] **Step 1: Write failing policy tests with a deterministic resolver** + +Create `api/pkg/services/notification_endpoint_policy_test.go` with: + +```go +package services + +import ( + "context" + "net/netip" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type staticHostResolver struct { + addresses map[string][]netip.Addr + err error +} + +func (resolver *staticHostResolver) LookupNetIP(_ context.Context, _ string, host string) ([]netip.Addr, error) { + if resolver.err != nil { + return nil, resolver.err + } + return resolver.addresses[host], nil +} + +func TestNotificationEndpointPolicyValidate(t *testing.T) { + tests := []struct { + name string + rawURL string + addresses []netip.Addr + hasError bool + }{ + {name: "public IPv4", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}, + {name: "public IPv6", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("2606:4700:4700::1111")}}, + {name: "loopback", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}, hasError: true}, + {name: "private", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("10.0.0.5")}, hasError: true}, + {name: "link local", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("169.254.169.254")}, hasError: true}, + {name: "carrier grade NAT", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, hasError: true}, + {name: "documentation range", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, hasError: true}, + {name: "unique local IPv6", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("fd00::1")}, hasError: true}, + {name: "mixed public and private", rawURL: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("10.0.0.5")}, hasError: true}, + {name: "embedded credentials", rawURL: "https://user:pass@adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, hasError: true}, + {name: "insecure scheme", rawURL: "http://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, hasError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + endpoint, err := url.Parse(test.rawURL) + require.NoError(t, err) + policy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{endpoint.Hostname(): test.addresses}, + }, nil) + + addresses, err := policy.Validate(context.Background(), endpoint) + + if test.hasError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.addresses, addresses) + }) + } +} +``` + +Add a connection-time test whose resolver returns a public address during the +first `Validate` call and `127.0.0.1` during `DialContext`. Assert that the +recording dialer is never invoked. Implement the recording dialer as a local +function injected through an unexported `dialValidated` helper so the test does +not make a real network connection. + +Add an exact-host allowlist test: + +```go +func TestNotificationEndpointPolicyAllowsPrivateAddressForExactLocalHost(t *testing.T) { + endpoint, err := url.Parse("https://adapter-emulator:9091/notifications/gateway-1") + require.NoError(t, err) + policy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + "adapter-emulator": {netip.MustParseAddr("172.20.0.8")}, + }, + }, []string{"adapter-emulator"}) + + addresses, err := policy.Validate(context.Background(), endpoint) + + require.NoError(t, err) + assert.Equal(t, []netip.Addr{netip.MustParseAddr("172.20.0.8")}, addresses) +} +``` + +Also assert that `adapter-emulator.example.com`, a private IP-literal URL, and +any non-allowlisted private hostname remain rejected. + +- [ ] **Step 2: Run the policy tests and confirm the types are missing** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestNotificationEndpointPolicy' -count=1 +``` + +Expected: compilation fails because `NotificationEndpointPolicy` and its +constructor do not exist. + +- [ ] **Step 3: Implement reserved-range checks** + +Create `api/pkg/services/notification_endpoint_policy.go`. Define the resolver +interface above and these blocked prefixes: + +```go +var blockedNotificationPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("224.0.0.0/4"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("::/128"), + netip.MustParsePrefix("::1/128"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("ff00::/8"), +} +``` + +Implement: + +```go +func isPublicNotificationAddress(address netip.Addr) bool { + address = address.Unmap() + if !address.IsValid() || !address.IsGlobalUnicast() { + return false + } + for _, prefix := range blockedNotificationPrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} +``` + +`Validate` must verify HTTPS, hostname presence, at least one DNS result, and +every resolved address passing +`isPublicNotificationAddress`. Private addresses are accepted only when the +lowercased hostname exactly matches `allowedPrivateHosts`; never wildcard or +suffix-match. Wrap resolver and validation errors with stacktrace context +without including the raw URL. + +- [ ] **Step 4: Implement validated dialing** + +Add: + +```go +func (policy *NotificationEndpointPolicy) DialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network string, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, stacktrace.Propagatef(err, "cannot split notification endpoint address") + } + + endpoint := &url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)} + addresses, err := policy.Validate(ctx, endpoint) + if err != nil { + return nil, stacktrace.Propagatef(err, "notification endpoint is not public") + } + + var lastErr error + for _, resolved := range addresses { + connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.String(), port)) + if dialErr == nil { + return connection, nil + } + lastErr = dialErr + } + return nil, stacktrace.Propagatef(lastErr, "cannot connect to notification endpoint") + } +} +``` + +Factor the final connection loop through an unexported function variable or +method that accepts a dial function, allowing the DNS-rebinding test to assert +the selected address without opening a socket. Do not perform a normal second +hostname dial after validation. + +- [ ] **Step 5: Run focused tests** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestNotificationEndpointPolicy' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +Run: + +```bash +cd api +go-fumpt -w pkg/services/notification_endpoint_policy.go pkg/services/notification_endpoint_policy_test.go +git add pkg/services/notification_endpoint_policy.go pkg/services/notification_endpoint_policy_test.go +git commit -m "feat(api): validate adapter endpoints" +``` + +--- + +### Task 3: Add the Transport Client Map + +**Files:** +- Modify: `api/pkg/services/fcm_client.go` +- Modify: `api/pkg/services/phone_notification_service.go` +- Modify: `api/pkg/services/phone_notification_service_test.go` + +**Interfaces:** +- Consumes: + +```go +func (phone *entities.Phone) NotificationTransport() (entities.NotificationTransport, error) +``` + +- Produces: + +```go +type FCMClient interface { + Send(context.Context, *messaging.Message) (string, error) +} +``` + +- [ ] **Step 1: Write failing transport-client map tests** + +Create a recording client in `phone_notification_service_test.go`: + +```go +type recordingPhoneNotificationClient struct { + message *messaging.Message + result string + err error + calls int +} + +func (client *recordingPhoneNotificationClient) Send(_ context.Context, message *messaging.Message) (string, error) { + client.calls++ + client.message = message + return client.result, client.err +} +``` + +Add tests for map-based FCM and HTTP selection, the same message pointer, +trimmed `message.Token`, absent transport entries, invalid tokens, and nil +messages. + +- [ ] **Step 2: Run the service tests and confirm the map dependency is missing** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestPhoneNotificationService' -count=1 +``` + +Expected: compilation fails because the service does not accept the client map. + +- [ ] **Step 3: Implement focused map-based delivery** + +Store `map[entities.NotificationTransport]FCMClient` on +`PhoneNotificationService` and implement: + +```go +func (service *PhoneNotificationService) sendPhoneNotification( + ctx context.Context, + phone *entities.Phone, + message *messaging.Message, +) (string, entities.NotificationTransport, error) { + // Validate message, classify once, map lookup, set trimmed token, send. +} +``` + +Update comments in `api/pkg/services/fcm_client.go` to describe `FCMClient` as +the common phone notification transport boundary. Use the Firebase client +directly; do not add a wrapper or dispatcher. + +- [ ] **Step 4: Run focused tests** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestPhoneNotificationService' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +Run: + +```bash +cd api +go-fumpt -w pkg/services/fcm_client.go pkg/services/phone_notification_service.go pkg/services/phone_notification_service_test.go +git add pkg/services/fcm_client.go pkg/services/phone_notification_service.go pkg/services/phone_notification_service_test.go +git commit -m "refactor(api): dispatch gateway notifications" +``` + +--- + +### Task 4: Implement the SSRF-safe HTTP Sender + +**Files:** +- Create: `api/pkg/services/http_notification_sender.go` +- Create: `api/pkg/services/http_notification_sender_test.go` + +**Interfaces:** +- Consumes: + +```go +type FCMClient interface { + Send(context.Context, *messaging.Message) (string, error) +} +``` + +- Produces: + +```go +type HTTPNotificationSender struct { + logger telemetry.Logger + client *http.Client + retrier *retry.Retrier + timeout time.Duration +} + +func NewHTTPNotificationSender( + logger telemetry.Logger, + client *http.Client, +) *HTTPNotificationSender + +func (sender *HTTPNotificationSender) Send( + ctx context.Context, + message *messaging.Message, +) (string, error) +``` + +- [ ] **Step 1: Write failing payload and success tests** + +Create `api/pkg/services/http_notification_sender_test.go`. Use a custom +`roundTripFunc`: + +```go +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} +``` + +Construct the sender in tests with a reusable retrier configured through +`newHTTPNotificationRetrier(0)` so retry delay is zero from initialization. +Use a public address from the test resolver, and a client whose custom +RoundTripper does not dial. + +Assert the request: + +```go +assert.Equal(t, http.MethodPost, request.Method) +assert.Equal(t, "application/json", request.Header.Get("Content-Type")) +``` + +Keep a test-only decoding struct and assert this structure: + +```go +type httpNotificationRequest struct { + Message struct { + Token string `json:"token"` + Data map[string]string `json:"data"` + Android struct { + Priority string `json:"priority"` + TTL string `json:"ttl,omitempty"` + } `json:"android"` + } `json:"message"` +} +``` + +Return `204 No Content` and assert `Send` succeeds with result `http/success`. + +- [ ] **Step 2: Write failing retry-classification tests** + +Add table-driven tests for: + +- network error then `202`: two calls, success; +- `408` then `200`: two calls, success; +- `429` then `204`: two calls, success; +- `500`, `502`, then `204`: three calls, success; +- `400`: one call, error; +- three `503` responses: three calls, error; +- redirects use Go's default client behavior; do not add terminal redirect + handling; +- response with a body larger than the discard limit: success without reading + unbounded content. + +Assert retry attempts receive fresh requests and bodies. + +- [ ] **Step 3: Run the HTTP sender tests and confirm the type is missing** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestHTTPNotificationSender' -count=1 +``` + +Expected: compilation fails because `HTTPNotificationSender` does not exist. + +- [ ] **Step 4: Implement the FCM-compatible HTTP payload** + +Create `api/pkg/services/http_notification_sender.go` and build the one-use +payload directly with `map[string]any`: + +```go +payload := map[string]any{ + "message": message, +} +``` + +Obtain the destination from `message.Token`. Rely on +`messaging.Message.MarshalJSON` and `messaging.AndroidConfig.MarshalJSON` for +the FCM shape and protobuf TTL, including `10*time.Minute` as `"600s"`. Build a +new request body for every attempt so retries never reuse a consumed reader. +Reject nil messages explicitly with a stacktrace-consistent error. + +- [ ] **Step 5: Implement bounded retries** + +Implement these helpers: + +```go +func isRetryableNotificationStatus(statusCode int) bool { + return statusCode == http.StatusRequestTimeout || + statusCode == http.StatusTooManyRequests || + statusCode >= http.StatusInternalServerError +} + +func notificationRetryDelay(attempt uint) time.Duration { + return time.Duration(1<<(attempt-1)) * 250 * time.Millisecond +} +``` + +Create one reusable retrier during sender initialization: + +```go +func newHTTPNotificationRetrier(delay time.Duration) *retry.Retrier { + return retry.New( + retry.Attempts(3), + retry.Delay(delay), + retry.DelayType(retry.BackOffDelay), + retry.LastErrorOnly(true), + retry.RetryIf(isRetryableNotificationError), + ) +} +``` + +Do not configure this retrier with `retry.Context`: each `Send` has a different +caller context. Instead, check caller cancellation in the operation and return +a terminal error, while deriving each attempt's five-second context from that +caller. + +Focused sender methods must: + +1. parse `message.Token`; +2. marshal the request body once, then create a fresh reader per request; +3. create a child context with the configured timeout per attempt; +4. set `Content-Type`; +5. call `client.Do`; +6. close each response body after copying at most 4 KiB to `io.Discard`; +7. return `http/success` for any `2xx`; +8. retry only network errors, `408`, `429`, and `5xx` while attempts remain; +9. treat request construction, caller cancellation, and other statuses as + terminal; +10. return a stacktrace-wrapped error. + +Do not use the container's retrying HTTP client; retries belong in this sender +so status classification and attempt count are explicit. + +- [ ] **Step 6: Add heartbeat tests** + +Add a heartbeat payload test with `KEY_HEARTBEAT_ID`, high priority, and nil +TTL; assert the `ttl` field is omitted. +Use the standard HTTP telemetry and logging path without feature-specific URL +redaction. + +- [ ] **Step 7: Run focused tests** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestHTTPNotificationSender' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 8: Format and commit** + +Run: + +```bash +cd api +go-fumpt -w pkg/services/http_notification_sender.go pkg/services/http_notification_sender_test.go +git add pkg/services/http_notification_sender.go pkg/services/http_notification_sender_test.go +git commit -m "feat(api): send notifications to adapters" +``` + +--- + +### Task 5: Integrate Message and Heartbeat Notifications + +**Files:** +- Modify: `api/pkg/services/phone_notification_service.go` +- Create: `api/pkg/services/phone_notification_service_test.go` +- Modify: `api/pkg/entities/phone_notification.go` + +**Interfaces:** +- Consumes: + +```go +map[entities.NotificationTransport]FCMClient +``` + +- Produces: + +```go +type NotificationEventDispatcher interface { + Dispatch(ctx context.Context, event cloudevents.Event) error + DispatchWithTimeout(ctx context.Context, event cloudevents.Event, timeout time.Duration) (string, error) +} + +func NewNotificationService( + logger telemetry.Logger, + tracer telemetry.Tracer, + phoneNotificationClients map[entities.NotificationTransport]FCMClient, + phoneRepository repositories.PhoneRepository, + phoneNotificationRepository repositories.PhoneNotificationRepository, + messageSendScheduleRepository repositories.MessageSendScheduleRepository, + dispatcher NotificationEventDispatcher, +) *PhoneNotificationService +``` + +- [ ] **Step 1: Write failing service tests with hand-written fakes** + +Create `api/pkg/services/phone_notification_service_test.go`. + +Define repository fakes by embedding the interfaces and overriding only methods +used by the tests: + +```go +type phoneNotificationPhoneRepository struct { + repositories.PhoneRepository + phone *entities.Phone + err error +} + +func (repository *phoneNotificationPhoneRepository) LoadByID( + _ context.Context, + _ entities.UserID, + _ uuid.UUID, +) (*entities.Phone, error) { + return repository.phone, repository.err +} + +type phoneNotificationRepository struct { + repositories.PhoneNotificationRepository + notificationID uuid.UUID + status entities.PhoneNotificationStatus +} + +func (repository *phoneNotificationRepository) UpdateStatus( + _ context.Context, + notificationID uuid.UUID, + status entities.PhoneNotificationStatus, +) error { + repository.notificationID = notificationID + repository.status = status + return nil +} +``` + +Add a fake event dispatcher that records CloudEvents and returns no error. Add +recording `FCMClient` implementations through a transport-keyed map. + +Test `Send` with an HTTPS token and assert: + +- `KEY_MESSAGE_ID` equals `params.MessageID.String()`; +- priority is `normal`; +- TTL equals `phone.MessageExpirationDuration()`; +- the same `messaging.Message` pointer reaches the HTTP client; +- a `message.notification.sent` event is dispatched; +- phone-notification status becomes sent. + +Test an HTTP sender error and assert: + +- `message.notification.failed` is dispatched; +- status becomes failed; +- the payload error says the adapter endpoint could not be notified; +- the payload does not tell the user to reinstall Android. + +Test an FCM sender error and assert the existing Android reinstallation guidance +is preserved. + +Test `SendHeartbeatFCM` with an HTTPS token and assert: + +- `KEY_HEARTBEAT_ID` parses as RFC3339; +- priority is `high`; +- TTL is nil; +- heartbeat sender errors are logged and return nil, preserving current + heartbeat behavior. + +Also test explicit nil-message handling and a missing transport map entry. + +- [ ] **Step 2: Run the service tests and confirm the constructor mismatch** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestPhoneNotificationService' -count=1 +``` + +Expected: compilation fails because `PhoneNotificationService` still consumes +`FCMClient` and a concrete `*EventDispatcher`. + +- [ ] **Step 3: Replace direct Firebase message creation** + +In `api/pkg/services/phone_notification_service.go`: + +- construct Firebase `messaging.Message` values directly; +- replace the direct client with + `phoneNotificationClients map[entities.NotificationTransport]FCMClient`; +- change the constructor to the exact signature above; +- change `eventDispatcher` to `NotificationEventDispatcher`. + +Add a private `sendPhoneNotification` helper that validates the message, +classifies transport once, performs a map lookup, sets the trimmed token, calls +the selected client, and returns the transport with the result or error. + +For message notifications, construct: + +```go +ttl := phone.MessageExpirationDuration() +message := &messaging.Message{ + Data: map[string]string{ + "KEY_MESSAGE_ID": params.MessageID.String(), + }, + Android: &messaging.AndroidConfig{ + Priority: "normal", + TTL: &ttl, + }, +} +``` + +For heartbeat notifications, call: + +```go +message := &messaging.Message{ + Data: map[string]string{ + "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), + }, + Android: &messaging.AndroidConfig{Priority: "high"}, +} +``` + +- [ ] **Step 4: Add transport-aware failure text** + +Use the transport returned by `sendPhoneNotification`; do not classify the +phone a second time. + +For HTTP use: + +```go +msg := fmt.Sprintf( + "cannot notify the configured adapter for phone [%s]. Check the adapter URL and availability.", + phone.PhoneNumber, +) +``` + +For Firebase preserve: + +```go +msg := fmt.Sprintf( + "cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", + phone.PhoneNumber, +) +``` + +Log the technical wrapped error without logging the raw token. If transport +classification unexpectedly fails here, send that error through +`handleNotificationFailed` with a generic notification-configuration message. + +- [ ] **Step 5: Update transport-specific comments** + +In `api/pkg/entities/phone_notification.go`, change: + +```go +// PhoneNotification represents an FCM notification to a mobile phone +``` + +to: + +```go +// PhoneNotification represents a scheduled wake-up notification for a phone gateway. +``` + +Update `PhoneNotificationService` and `SendHeartbeatFCM` comments so they refer +to phone gateway notifications rather than only mobile phones. Keep the method +name `SendHeartbeatFCM` in this change to avoid an unrelated listener rename. + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestPhoneNotificationService|TestHTTPNotificationSender' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Format and commit** + +Run: + +```bash +cd api +go-fumpt -w pkg/services/phone_notification_service.go pkg/services/phone_notification_service_test.go pkg/entities/phone_notification.go +git add pkg/services/phone_notification_service.go pkg/services/phone_notification_service_test.go pkg/entities/phone_notification.go +git commit -m "feat(api): route phone gateway wake-ups" +``` + +--- + +### Task 6: Validate URL Tokens, Wire Dependencies, and Regenerate Swagger + +**Files:** +- Modify: `api/pkg/validators/phone_handler_validator.go` +- Create: `api/pkg/validators/phone_handler_validator_test.go` +- Modify: `api/pkg/di/container.go` +- Modify: `api/pkg/requests/phone_update_request.go` +- Modify: `api/pkg/requests/phone_fcm_token_request.go` +- Modify: `api/docs/docs.go` +- Modify: `api/docs/swagger.json` +- Modify: `api/docs/swagger.yaml` + +**Interfaces:** +- Consumes: + +```go +func (container *Container) FCMClient() services.FCMClient +func NewHTTPNotificationSender(logger telemetry.Logger, client *http.Client) *HTTPNotificationSender +``` + +- Produces container factories: + +```go +func (container *Container) NotificationHTTPClient() *http.Client +func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient +``` + +- Produces validator constructor: + +```go +func NewPhoneHandlerValidator( + logger telemetry.Logger, + tracer telemetry.Tracer, + scheduleService *services.MessageSendScheduleService, + endpointPolicy *services.NotificationEndpointPolicy, +) *PhoneHandlerValidator +``` + +- [ ] **Step 1: Write failing validator tests** + +Create `api/pkg/validators/phone_handler_validator_test.go`. + +Build the validator with a static public resolver and nil schedule service for +requests without a schedule ID. Add tests for both `ValidateUpsert` and +`ValidateFCMToken`: + +```go +func TestPhoneHandlerValidatorAcceptsPublicHTTPSNotificationURL(t *testing.T) { + validator := newPhoneHandlerValidatorWithAddresses(map[string][]netip.Addr{ + "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, + }) + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "https://adapter.example.com/notify", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} +``` + +Add rejection tests for `http://`, loopback resolution, private resolution, +mixed public/private resolution, and malformed HTTPS. +Add an opaque FCM token test to prove the resolver is not required for Firebase +tokens. + +- [ ] **Step 2: Run validator tests and confirm unsafe URLs are accepted** + +Run: + +```bash +cd api +go test ./pkg/validators -run 'TestPhoneHandlerValidator.*Notification' -count=1 +``` + +Expected: tests fail because the validator only checks token length. + +- [ ] **Step 3: Inject and apply endpoint policy** + +Add `endpointPolicy *services.NotificationEndpointPolicy` to +`PhoneHandlerValidator` and its constructor. + +Add: + +```go +func (validator *PhoneHandlerValidator) validateNotificationToken( + ctx context.Context, + token string, + result url.Values, +) { + token = strings.TrimSpace(token) + if token == "" { + return + } + + phone := &entities.Phone{FcmToken: &token} + transport, err := phone.NotificationTransport() + if err != nil { + result.Add("fcm_token", err.Error()) + return + } + if transport != entities.NotificationTransportHTTP { + return + } + + endpoint, err := phone.NotificationURL() + if err != nil { + result.Add("fcm_token", err.Error()) + return + } + if _, err = validator.endpointPolicy.Validate(ctx, endpoint); err != nil { + result.Add("fcm_token", "fcm_token must be a public HTTPS adapter URL") + } +} +``` + +Call it after structural validation succeeds in both `ValidateUpsert` and +`ValidateFCMToken`. Change `ValidateFCMToken` to use its context argument. + +- [ ] **Step 4: Add SSRF-safe container factories** + +In `api/pkg/di/container.go`, add: + +```go +func (container *Container) NotificationEndpointPolicy() *services.NotificationEndpointPolicy { + if container.notificationEndpointPolicy != nil { + return container.notificationEndpointPolicy + } + + allowedPrivateHosts := []string{} // Superseded: no private-host allowlist is configured. + container.notificationEndpointPolicy = services.NewNotificationEndpointPolicy( + net.DefaultResolver, + allowedPrivateHosts, + ) + return container.notificationEndpointPolicy +} +``` + +Add a client factory: + +```go +func (container *Container) NotificationHTTPClient() *http.Client { + policy := container.NotificationEndpointPolicy() + transport := &http.Transport{ + Proxy: nil, + DialContext: policy.DialContext(&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }), + ForceAttemptHTTP2: true, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + + return &http.Client{ + Transport: otelroundtripper.New( + otelroundtripper.WithName("phone_notification_http"), + otelroundtripper.WithParent(transport), + otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), + ), + } +} +``` + +Do not set a client-wide timeout; the sender creates the approved five-second +context for each attempt. + +Add: + +```go +func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient { + return map[entities.NotificationTransport]services.FCMClient{ + entities.NotificationTransportFCM: container.FCMClient(), + entities.NotificationTransportHTTP: services.NewHTTPNotificationSender( + container.Logger(), + container.NotificationHTTPClient(), + ), + } +} +``` + +Add this field to `Container`: + +```go +notificationEndpointPolicy *services.NotificationEndpointPolicy +``` + +The cached policy ensures validation and connection-time checks use the same +allowlist. Do not cache per-request sender state. + +- [ ] **Step 5: Wire service and validator constructors** + +Change `container.NotificationService()` to pass +`container.PhoneNotificationClients()`. + +Find `container.PhoneHandlerValidator()` and pass +`container.NotificationEndpointPolicy()` as its fourth argument. Keep existing +logger, tracer, and schedule-service arguments unchanged. + +- [ ] **Step 6: Run focused package tests** + +Run: + +```bash +cd api +go test ./pkg/entities ./pkg/services ./pkg/validators ./pkg/di -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Update API descriptions** + +In both phone request structs, replace the generic FCM token comment with: + +```go +// FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. +FcmToken string `json:"fcm_token" example:"https://adapter.example.com/notifications"` +``` + +Update handler Swagger descriptions for phone upsert and FCM-token upsert to +state that URL-backed phones receive FCM-compatible HTTP wake-ups. Do not add or +rename routes or JSON fields. + +- [ ] **Step 8: Regenerate Swagger** + +Run: + +```bash +cd api +swag init --requiredByDefault --parseDependency --parseInternal +``` + +Expected: `docs/docs.go`, `docs/swagger.json`, and `docs/swagger.yaml` update +with the dual-purpose `fcm_token` descriptions. + +- [ ] **Step 9: Run the complete API test suite** + +Run: + +```bash +cd api +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 10: Build the API** + +Run: + +```bash +cd api +go build -o ./tmp/main.exe . +``` + +Expected: build succeeds. + +- [ ] **Step 11: Inspect the final diff for forbidden changes** + +Run: + +```bash +git diff --check +git diff --stat +git grep -n "fcm_token" -- api/pkg/entities/phone.go api/pkg/requests/phone_update_request.go api/pkg/requests/phone_fcm_token_request.go +``` + +Confirm: + +- no database field or migration was added; +- `fcm_token` remains the persisted/API field; +- no message content or API key is added to the HTTP callback payload; +- scheduling and repository code are unchanged; + +- [ ] **Step 12: Format and commit** + +Run: + +```bash +cd api +go-fumpt -w pkg/validators/phone_handler_validator.go pkg/validators/phone_handler_validator_test.go pkg/di/container.go pkg/requests/phone_update_request.go pkg/requests/phone_fcm_token_request.go +git add pkg/validators/phone_handler_validator.go pkg/validators/phone_handler_validator_test.go pkg/di/container.go pkg/requests/phone_update_request.go pkg/requests/phone_fcm_token_request.go pkg/handlers/phone_handler.go docs/docs.go docs/swagger.json docs/swagger.yaml +git commit -m "feat(api): enable URL-backed phone gateways" +``` + +--- + +### Task 7: Build the Adapter Emulator and End-to-End Scenarios + +**Files:** +- Create: `tests/adapter-emulator/Dockerfile` +- Create: `tests/adapter-emulator/go.mod` +- Create: `tests/adapter-emulator/main.go` +- Create: `tests/adapter-emulator/emulator.go` +- Create: `tests/adapter-emulator/api_client.go` +- Create: `tests/adapter-emulator/notification_handler.go` +- Create: `tests/adapter-emulator/control_handler.go` +- Create: `tests/adapter_integration_test.go` +- Create: `tests/generate-adapter-certificates.sh` +- Modify: `tests/docker-compose.yml` +- Modify: `tests/.env.test` +- Modify: `tests/helpers_test.go` +- Modify: `tests/README.md` +- Modify: `.github/workflows/api.yml` +- Modify: `.gitignore` + +**Interfaces:** +- Emulator callback: `POST https://adapter-emulator:9091/notifications/{gatewayID}` +- Emulator control: + +```text +PUT http://localhost:9092/test/gateways/{gatewayID} +POST http://localhost:9092/test/gateways/{gatewayID}/incoming +GET http://localhost:9092/test/gateways/{gatewayID}/notifications +GET http://localhost:9092/health +``` + +- Gateway registration: + +```go +type gatewayRegistration struct { + PhoneNumber string `json:"phone_number"` + PhoneAPIKey string `json:"phone_api_key"` +} +``` + +- Incoming control payload: + +```go +type incomingMessageRequest struct { + Contact string `json:"contact"` + Content string `json:"content"` + Encrypted bool `json:"encrypted"` +} +``` + +- Callback record: + +```go +type notificationRecord struct { + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` +} +``` + +- [ ] **Step 1: Generate throwaway TLS material** + +Create `tests/generate-adapter-certificates.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +output_dir="${1:-certs}" +mkdir -p "$output_dir" + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$output_dir/ca-key.pem" \ + -out "$output_dir/ca.pem" \ + -days 2 \ + -subj "/CN=httpSMS integration adapter CA" + +openssl req -newkey rsa:2048 -nodes \ + -keyout "$output_dir/server-key.pem" \ + -out "$output_dir/server.csr" \ + -subj "/CN=adapter-emulator" + +cat >"$output_dir/server.ext" <<'EOF' +subjectAltName=DNS:adapter-emulator +extendedKeyUsage=serverAuth +EOF + +openssl x509 -req \ + -in "$output_dir/server.csr" \ + -CA "$output_dir/ca.pem" \ + -CAkey "$output_dir/ca-key.pem" \ + -CAcreateserial \ + -out "$output_dir/server.pem" \ + -days 2 \ + -extfile "$output_dir/server.ext" +``` + +Add `tests/certs/` to `.gitignore`. Do not commit generated keys or +certificates. + +- [ ] **Step 2: Scaffold the isolated emulator module** + +Create `tests/adapter-emulator/go.mod`: + +```go +module github.com/NdoleStudio/httpsms/tests/adapter-emulator + +go 1.25.0 +``` + +Use only the standard library. Run: + +```bash +cd tests/adapter-emulator +go mod tidy +``` + +Expected: the command succeeds without adding dependencies. + +- [ ] **Step 3: Implement emulator state and deduplication** + +In `tests/adapter-emulator/emulator.go`, implement: + +```go +type gateway struct { + PhoneNumber string + PhoneAPIKey string +} + +type emulator struct { + apiBaseURL string + client *http.Client + mu sync.RWMutex + gateways map[string]gateway + records []*notificationRecord +} + +func newEmulator(apiBaseURL string, client *http.Client) *emulator { + return &emulator{ + apiBaseURL: strings.TrimRight(apiBaseURL, "/"), + client: client, + gateways: make(map[string]gateway), + } +} +``` + +Add locked methods to register a gateway, load a gateway, record each callback, +mark it processed/failed, and list copied records for one gateway. + +- [ ] **Step 4: Implement existing phone API calls** + +In `tests/adapter-emulator/api_client.go`, implement: + +```go +func (emulator *emulator) fetchOutstanding( + ctx context.Context, + gateway gateway, + messageID string, +) (map[string]any, error) + +func (emulator *emulator) fireMessageEvent( + ctx context.Context, + gateway gateway, + messageID string, + eventName string, +) error + +func (emulator *emulator) receiveMessage( + ctx context.Context, + gateway gateway, + request incomingMessageRequest, +) (map[string]any, error) + +func (emulator *emulator) storeHeartbeat( + ctx context.Context, + gateway gateway, +) error +``` + +Use the existing routes: + +```text +GET /v1/messages/outstanding?message_id={messageID} +POST /v1/messages/{messageID}/events +POST /v1/messages/receive +POST /v1/heartbeats +``` + +Every request sets `x-api-key`. Message events use `SENT` then `DELIVERED` with +UTC RFC3339 timestamps. Incoming messages use the gateway phone number as `to`, +the control request contact as `from`, `SIM1`, and the requested encryption +flag. Return contextual `fmt.Errorf` errors because the emulator module +intentionally does not depend on the production API module. + +- [ ] **Step 5: Implement FCM-compatible callback handling** + +In `tests/adapter-emulator/notification_handler.go`, decode: + +```go +type callbackEnvelope struct { + Message struct { + Token string `json:"token"` + Data map[string]string `json:"data"` + } `json:"message"` +} +``` + +The handler must: + +1. load `gatewayID` from the route; +2. return `404` for unknown gateways; +3. return `400` for unsupported data; +4. record every callback; +5. for `KEY_MESSAGE_ID`, fetch outstanding, fire `SENT`, fire `DELIVERED`, and + mark the record processed with kind `message`; +6. for `KEY_HEARTBEAT_ID`, store a heartbeat and mark the record processed with + kind `heartbeat`; +7. return `500` and retain the error string in the record when processing + fails, allowing the API sender's retry behavior to be exercised. + +Processing may be synchronous because the API accepts any `2xx` and the +integration stack controls response time. + +- [ ] **Step 6: Implement control and server endpoints** + +In `tests/adapter-emulator/control_handler.go`, implement registration, +incoming-message, record listing, and health handlers using `http.ServeMux`. + +In `main.go`, read: + +```text +API_BASE_URL=http://api:8000 +ADAPTER_TLS_CERT=/certs/server.pem +ADAPTER_TLS_KEY=/certs/server-key.pem +``` + +Start: + +- HTTPS callback server on `:9091`; +- HTTP control server on `:9092`. + +Use `http.Server` with finite `ReadHeaderTimeout`, `ReadTimeout`, +`WriteTimeout`, and `IdleTimeout`. Shut both servers down on SIGINT/SIGTERM. + +- [ ] **Step 7: Add the emulator container** + +Create `tests/adapter-emulator/Dockerfile`: + +```dockerfile +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/adapter-emulator . + +FROM alpine:3.22 +RUN adduser -D -u 10001 app +USER app +COPY --from=build /out/adapter-emulator /usr/local/bin/adapter-emulator +ENTRYPOINT ["adapter-emulator"] +``` + +In `tests/docker-compose.yml`, add `adapter-emulator`: + +```yaml +adapter-emulator: + build: + context: ./adapter-emulator + ports: + - "9092:9092" + environment: + API_BASE_URL: http://api:8000 + ADAPTER_TLS_CERT: /certs/server.pem + ADAPTER_TLS_KEY: /certs/server-key.pem + volumes: + - ./certs:/certs:ro + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9092/health"] + interval: 5s + timeout: 5s + retries: 10 +``` + +Make `api` depend on the healthy emulator. Mount `./certs/ca.pem` into the API +container and set: + +```yaml +SSL_CERT_FILE: /adapter-certs/ca.pem +``` + +Do not add a private-host allowlist; Docker DNS is used through the standard Go +HTTP transport. Keep `SSL_CERT_FILE` so the emulator certificate remains +trusted. + +- [ ] **Step 8: Add adapter test helpers** + +In `tests/helpers_test.go`, add `adapterControlURL`, +`systemAPIKey`, and: + +```go +type adapterTestPhone struct { + testPhone + PhoneID string + GatewayID string +} + +func setupAdapterPhone(ctx context.Context, t *testing.T, messagesPerMinute uint) adapterTestPhone +func dispatchInternalEvent(ctx context.Context, t *testing.T, event map[string]any) +func waitForAdapterMessageRecords(t *testing.T, gatewayID string, messageID string, timeout time.Duration) []notificationRecord +func waitForAdapterHeartbeatRecord(t *testing.T, gatewayID string, timeout time.Duration) notificationRecord +func triggerAdapterIncoming(ctx context.Context, t *testing.T, phone adapterTestPhone, contact string, content string) string +``` + +`setupAdapterPhone` must: + +1. generate a gateway UUID and phone number; +2. create a phone API key; +3. register the gateway with the emulator control API; +4. use callback URL + `https://adapter-emulator:9091/notifications/{gatewayID}`; +5. upsert the phone through the user API and capture its phone ID; +6. bind the same callback through the phone API-key route; +7. wait for phone authorization using the existing helper. + +`dispatchInternalEvent` posts a valid CloudEvent JSON body to `/v1/events` with +`x-api-key: system-user-api-key`. + +- [ ] **Step 9: Write the outgoing adapter integration test** + +Create `tests/adapter_integration_test.go`: + +```go +func TestAdapterGatewayOutgoingMessage(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + contact := randomPhoneNumber() + content := "Adapter outgoing " + randomEncryptionKey() + + response, httpResponse, err := newAPIClient().Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: content, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, httpResponse.HTTPResponse.StatusCode) + + messageID := response.Data.ID.String() + message := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) + + assert.Equal(t, phone.PhoneNumber, message.Owner) + assert.Equal(t, contact, message.Contact) + assert.Equal(t, content, message.Content) + records := waitForAdapterMessageRecords(t, phone.GatewayID, messageID, 30*time.Second) + require.Len(t, records, 1) + assert.Equal(t, "message", records[0].Kind) + assert.True(t, records[0].Processed) + assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"]) +} +``` + +The record-list helper queries by message ID. Assert one processed record for +the successful callback flow. + +- [ ] **Step 10: Write the incoming adapter integration test** + +Add: + +```go +func TestAdapterGatewayIncomingMessage(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + contact := randomPhoneNumber() + content := "Adapter incoming " + randomEncryptionKey() + + messageID := triggerAdapterIncoming(ctx, t, phone, contact, content) + message := pollMessageStatus(ctx, t, messageID, "received", 15*time.Second) + + assert.Equal(t, phone.PhoneNumber, message.Owner) + assert.Equal(t, contact, message.Contact) + assert.Equal(t, content, message.Content) + assert.Equal(t, "received", message.Status) +} +``` + +This test must call the emulator control endpoint; it must not post +`/v1/messages/receive` directly from the test runner. + +- [ ] **Step 11: Write the heartbeat callback integration test** + +Add: + +```go +func TestAdapterGatewayHeartbeatWakeUp(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + monitorID := uuid.NewString() + + dispatchInternalEvent(ctx, t, map[string]any{ + "specversion": "1.0", + "id": uuid.NewString(), + "source": "/tests/adapter-emulator", + "type": "phone.heartbeat.missed", + "time": time.Now().UTC().Format(time.RFC3339), + "datacontenttype": "application/json", + "data": map[string]any{ + "phone_id": phone.PhoneID, + "user_id": "test-user-id", + "last_heartbeat_timestamp": time.Now().UTC().Add(-20 * time.Minute).Format(time.RFC3339), + "timestamp": time.Now().UTC().Format(time.RFC3339), + "monitor_id": monitorID, + "owner": phone.PhoneNumber, + }, + }) + + record := waitForAdapterHeartbeatRecord(t, phone.GatewayID, 30*time.Second) + assert.Equal(t, "heartbeat", record.Kind) + assert.NotEmpty(t, record.Data["KEY_HEARTBEAT_ID"]) + + heartbeats, response, err := newAPIClient().Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{ + Owner: phone.PhoneNumber, + Limit: 1, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + require.NotEmpty(t, heartbeats.Data) + assert.Equal(t, phone.PhoneNumber, heartbeats.Data[0].Owner) +} +``` + +The direct internal event avoids waiting for the production 16-minute monitor +interval while still exercising `PhoneNotificationListener`, +`PhoneNotificationService`, the HTTP client, the emulator callback, and the +existing heartbeat API. + +- [ ] **Step 12: Update local and CI commands** + +In `.github/workflows/api.yml`, after Firebase credential generation, add: + +```yaml +- name: Generate adapter certificates + run: bash tests/generate-adapter-certificates.sh tests/certs +``` + +Update `tests/README.md` architecture, project tree, coverage checklist, +troubleshooting logs, and setup commands. The documented local sequence must +run both credential scripts before `docker compose up`. + +- [ ] **Step 13: Run the complete integration suite** + +Run: + +```bash +cd tests +bash generate-firebase-credentials.sh firebase-credentials.json +bash generate-adapter-certificates.sh certs +docker compose up -d --build --wait +docker compose wait seed +go test -v -timeout 300s ./... +docker compose down -v +``` + +Expected: existing FCM/WireMock tests and all three adapter tests pass. + +- [ ] **Step 14: Inspect failure logs if a scenario times out** + +Run: + +```bash +cd tests +docker compose logs --tail 200 api adapter-emulator +``` + +The outgoing logs must show callback receipt, outstanding fetch, `SENT`, and +`DELIVERED`. Incoming logs must show the emulator calling the receive route. +Heartbeat logs must show `KEY_HEARTBEAT_ID` and a successful heartbeat POST. + +- [ ] **Step 15: Commit integration coverage** + +Run: + +```bash +git add .gitignore .github/workflows/api.yml tests +git commit -m "test(api): cover URL-backed phone gateways" +``` diff --git a/docs/superpowers/specs/2026-09-02-url-backed-phone-notification-adapter-design.md b/docs/superpowers/specs/2026-09-02-url-backed-phone-notification-adapter-design.md new file mode 100644 index 00000000..5d833dfb --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-url-backed-phone-notification-adapter-design.md @@ -0,0 +1,514 @@ +# URL-backed Phone Notification Adapter + +- Date: 2026-09-02 +- Status: Approved (design) +- Scope: `api/` Go backend plus `tests/` integration infrastructure and API CI. + Web and Android clients are unchanged. + +## Problem + +httpSMS currently wakes an Android phone through Firebase Cloud Messaging when +an outgoing message is ready. The phone then fetches the outstanding message, +sends it, and reports sent, delivered, or failed events through the phone API. + +Users should be able to register a non-Android gateway, such as a WhatsApp +adapter, as a phone number. The gateway must reuse the existing message queue, +send schedules, per-phone backpressure, expiration, retry, incoming-message, +and status-event flows. The only behavioral difference is how httpSMS wakes the +gateway: when the stored `fcm_token` is a URL, httpSMS calls that URL instead of +Firebase. + +Customer-controlled callback URLs create additional security and delivery +requirements: + +- callbacks are wake-up hints, not proof that a message was sent; +- callback delivery is at least once, so adapters must tolerate duplicate + wake-up hints; +- transient endpoint failures need bounded retries; +- existing Android registrations must retain their current behavior. + +## Decisions + +- Reuse the existing `Phone.FcmToken` database field and `fcm_token` API field. + Do not add transport or callback URL columns. +- Determine transport through helper methods on `Phone`; callers do not inspect + or parse `FcmToken` directly. +- A valid `https://` URL with a hostname selects HTTP delivery. Non-URL tokens + select Firebase. URL-like but malformed or unsupported values are rejected. +- Inject phone notification clients as a map keyed by + `entities.NotificationTransport`; domain events continue to use the existing + `EventDispatcher`. +- Send both outstanding-message and heartbeat notifications to URL-backed + phones. +- POST an FCM-compatible JSON envelope to adapter endpoints. +- Treat any `2xx` response as successful wake-up acceptance and ignore its + body. +- Make up to three total HTTP attempts with `retry-go/v5` and a five-second + timeout per attempt. +- Retry network failures, HTTP `408`, HTTP `429`, and `5xx` responses. Other + non-`2xx` responses fail immediately. +- After callback retries are exhausted, use the current notification failure + path and mark the message failed. +- Do not sign or authenticate callback requests. The payload contains no + message content or API credentials. +- Use a standard OpenTelemetry-wrapped `http.Client` without endpoint DNS/IP + filtering, custom dialing, or a private-host allowlist. +- Preserve all existing phone API-key authorization and message-processing + behavior. + +## Existing Flow + +The current backend already isolates scheduling from phone wake-up delivery: + +1. `MessageService.SendMessage` creates the outgoing message event using the + phone's configured send attempts, SIM, and rate settings. +2. `PhoneNotificationService.Schedule` persists a `PhoneNotification` and uses + `PhoneNotificationRepository.Schedule` or `ScheduleExact` to apply + per-minute limits and send schedules. +3. `message.notification.send` invokes `PhoneNotificationService.Send`. +4. The service sends an FCM data notification containing `KEY_MESSAGE_ID`. +5. A successful notification increments the message send-attempt count and + schedules the existing expiration check. +6. The Android phone fetches + `GET /v1/messages/outstanding?message_id=` using a phone API key scoped + to its number. +7. The phone reports sent, delivered, or failed events and submits inbound + messages through the existing phone API routes. + +The adapter feature changes step 4 only. The same dispatcher also applies to +the heartbeat notification currently sent by `SendHeartbeatFCM`. + +## Design + +### 1. Phone transport helpers + +Add helpers to `api/pkg/entities/phone.go` that classify and expose the +notification destination while continuing to store only `FcmToken`. + +The helpers provide three outcomes: + +- **Firebase:** the token has no URL syntax and is passed unchanged to Firebase. +- **HTTP:** the token is an absolute, syntactically valid `https://` URL. +- **Invalid:** the value is URL-like but malformed, uses another scheme, or has + no hostname. + +Use these entity-level types and methods: + +```go +type NotificationTransport string + +const ( + NotificationTransportFCM NotificationTransport = "fcm" + NotificationTransportHTTP NotificationTransport = "http" +) + +func (phone *Phone) NotificationTransport() (NotificationTransport, error) +func (phone *Phone) NotificationURL() (*url.URL, error) +``` + +`NotificationTransport` returns an error for a missing token or a URL-like +invalid token. `NotificationURL` succeeds only for the HTTP transport. +`PhoneNotificationService`, validation, and tests must not duplicate +string-prefix checks. + +A token is considered URL-like when it declares a URI scheme. This prevents an +invalid `http://`, `ftp://`, or malformed HTTPS endpoint from falling through +to Firebase as if it were an FCM token. Ordinary FCM tokens remain opaque. + +### 2. Transport client map + +Use the existing `FCMClient` contract for every phone notification transport: + +```go +type FCMClient interface { + Send(context.Context, *messaging.Message) (string, error) +} +``` + +`PhoneNotificationService` receives +`map[entities.NotificationTransport]FCMClient` and constructs +`messaging.Message` directly. A focused private helper: + +1. rejects a nil message with a stacktrace error; +2. determines the phone transport once; +3. looks up the matching client in the map; +4. trims the configured token into `message.Token`; +5. passes the same message pointer to the client; +6. returns the selected transport with the result or error for existing + Firebase-versus-HTTP failure guidance. + +The result string is used only by existing notification event bookkeeping. +Firebase keeps the message name returned by the SDK. HTTP delivery uses a +generated identifier that does not expose the callback URL. + +### 3. Firebase client + +The Firebase map entry is the existing production or emulator `FCMClient` +directly. There is no wrapper sender and no separate dispatcher class. + +The production Firebase client and emulator client remain available. Android +tokens follow the same SDK path, payload keys, priorities, TTL values, success +events, and failure events as before. + +### 4. HTTP sender and request contract + +The HTTP sender posts `Content-Type: application/json` with an FCM-compatible +envelope: + +```json +{ + "message": { + "token": "https://adapter.example.com/notifications", + "data": { + "KEY_MESSAGE_ID": "32343a19-da5e-4b1b-a767-3298a73703cb" + }, + "android": { + "priority": "normal", + "ttl": "600s" + } + } +} +``` + +Heartbeat notifications use the same shape with: + +```json +{ + "data": { + "KEY_HEARTBEAT_ID": "2026-09-02T19:22:20Z" + }, + "android": { + "priority": "high" + } +} +``` + +Adapters should depend on `message.data`; the Android object exists for payload +compatibility and communicates priority and expiration hints. + +`HTTPNotificationSender` obtains the destination from `message.Token` and +marshals `map[string]any{"message": message}`. Firebase's +`messaging.Message` and `messaging.AndroidConfig.MarshalJSON` own the FCM JSON +shape and protobuf duration formatting, including a ten-minute TTL as `600s`. + +Every request includes: + +```text +Content-Type: application/json +``` + +`HTTPNotificationSender` returns the stable result `http/success` after any +successful callback response. The persisted `PhoneNotification.ID` remains in +the existing repository and event flow but is not sent to the callback. + +The response contract is deliberately small: + +- any `2xx` status means the adapter accepted the wake-up; +- response headers and body do not affect message state; +- the sender reads at most a small bounded amount needed to safely reuse or + close the connection, then discards the body. + +The callback is not sent the message content, phone API key, user ID, or +credentials. + +### 5. HTTP retry and state semantics + +Each HTTP delivery makes no more than three total attempts. Each attempt has a +five-second context timeout and uses short exponential backoff. + +Retryable failures are: + +- connection, DNS, TLS, and timeout failures; +- HTTP `408 Request Timeout`; +- HTTP `429 Too Many Requests`; +- HTTP `5xx`. + +Other non-`2xx` statuses are terminal for that notification and are not +retried. Ignore `Retry-After`; use the sender's bounded exponential backoff so +a customer response cannot extend the listener into an unbounded worker. + +When a callback returns `2xx`, `PhoneNotificationService` uses its existing +success path: + +1. dispatch `message.notification.sent`; +2. set the `PhoneNotification` status to sent; +3. increment the message send-attempt count; +4. schedule the configured message expiration check. + +This means callback success is only proof that the adapter was notified. The +message remains pending/scheduled until the adapter fetches it, at which point +the existing `message.phone.sending` path runs. + +When callback delivery reaches a terminal failure or exhausts retries, +`PhoneNotificationService` uses its existing failed-notification path: + +1. dispatch `message.notification.failed`; +2. set the `PhoneNotification` status to failed; +3. store a failed message event through the existing message listener. + +The HTTP-specific error message tells the user that the configured adapter +endpoint could not be notified. It must not reuse the current Android +reinstallation guidance. + +### 6. At-least-once delivery + +HTTP wake-up delivery is at least once. A request may reach the adapter even if +httpSMS observes a timeout or connection failure while receiving the response. +The retry can therefore deliver the same wake-up payload again. + +Adapters must: + +- treat callbacks as hints to fetch work, not as message content; +- tolerate repeated callbacks and use the outstanding message state and + message ID to avoid sending external messages twice; +- retain their own provider-level idempotency and reconciliation where the + external channel supports it. + +httpSMS does not add a new acknowledgement endpoint. Fetching the outstanding +message and posting existing message events remain the source of truth. + +### 7. Adapter API flow + +A URL-backed adapter uses the existing public API in the same way as the +Android gateway: + +1. A user creates or updates a phone with an E.164 number and sets `fcm_token` + to the adapter's HTTPS URL. +2. The user creates a phone API key assigned to that phone/number and configures + the adapter with it. +3. httpSMS schedules outgoing messages with the existing rate limit and send + schedule. +4. When a message becomes due, httpSMS POSTs the callback containing + `KEY_MESSAGE_ID`. +5. The adapter fetches the message through `/v1/messages/outstanding` using its + phone API key. +6. The adapter sends the message through WhatsApp or another external channel. +7. The adapter posts the existing sent, delivered, or failed message events. +8. The adapter posts inbound messages through `/v1/messages/receive`. +9. For `KEY_HEARTBEAT_ID`, the adapter performs the same heartbeat callback flow + expected from the Android application. + +The phone API key's existing phone-number scope prevents an adapter from +fetching messages belonging to another number. The adapter must not use a +general user API key for gateway operations. + +Encrypted message content remains unchanged. If a user enables encryption, the +adapter is responsible for implementing the same compatible encryption and +decryption behavior expected of the Android gateway. + +### 8. HTTP client and retry ownership + +Phone registration validates only URL syntax, the HTTPS scheme, and the +presence of a hostname. URL user information remains valid. The feature does +not perform endpoint DNS/IP classification, custom dialing, or private-host +allowlisting. + +`Container.NotificationHTTPClient` is a standard `http.Client` using the +existing webhook-style `go-otelroundtripper` pattern without transport-level +retries or custom URL redaction. The client preserves Go's default transport +and redirect behavior. + +`HTTPNotificationSender` creates one reusable `retry-go/v5` retrier during +initialization. The retrier owns exactly three total attempts and exponential +backoff, but does not bind a caller context because it is reused across sends. +Each delivery checks its caller context, creates a fresh request and body, and +applies a five-second child context per attempt. Payload encoding, request +creation, one-attempt delivery, and retry configuration remain focused +operations. + +### 9. Validation and API compatibility + +Keep these public fields and routes unchanged: + +- `Phone.FcmToken` / JSON `fcm_token`; +- `PUT /v1/phones`; +- `PUT /v1/phones/fcm-token`; +- all outstanding-message, event, receive-message, and heartbeat routes. + +Extend phone validation only when `fcm_token` is URL-like: + +- enforce valid HTTPS syntax and require a hostname; +- preserve the existing maximum token length; +- return field-level `fcm_token` validation errors for malformed or non-HTTPS + URL-like values. + +Opaque FCM token validation remains unchanged. Existing stored Android tokens +require no migration. + +Update request and Swagger descriptions to explain that `fcm_token` accepts +either an FCM registration token or an HTTPS adapter callback URL. +Regenerate Swagger documentation after implementation. + +### 10. Observability + +Use the existing request, database, and OpenTelemetry logging behavior without +special redaction for notification tokens or callback URLs. Rely on the +existing OpenTelemetry HTTP round-tripper for outbound request spans and +metrics; do not add sender-specific attempt spans or metrics. + +## Components and Expected Files + +Implementation is expected to touch: + +- `api/pkg/entities/phone.go` for transport and URL helpers; +- `api/pkg/validators/phone_handler_validator.go` for URL-token validation; +- `api/pkg/services/phone_notification_service.go` for transport-client lookup, + message construction, and existing state transitions; +- `api/pkg/services/fcm_client.go` for the common transport-client contract and + Firebase implementation; +- `api/pkg/services/http_notification_sender.go` for HTTP payload encoding and + delivery; +- `api/pkg/services/emulator_fcm_client.go` only as needed to preserve the + emulator behind the adapted interface; +- `api/pkg/di/container.go` for dispatcher, OpenTelemetry HTTP client, and + sender construction; +- phone request annotations and generated Swagger files. +- `tests/adapter-emulator/` for an HTTPS gateway emulator that consumes + callbacks and exercises existing phone API routes; +- `tests/adapter_integration_test.go`, `tests/docker-compose.yml`, test + certificate generation, CI setup, and `tests/README.md` for end-to-end + coverage. + +The implementation must not move scheduling, message expiration, message event +handling, or phone API-key authorization into the new transport code. + +## Testing + +### Phone helper tests + +Cover: + +- ordinary FCM tokens selecting Firebase; +- valid HTTPS URLs selecting HTTP; +- empty and nil tokens; +- `http`, `ftp`, missing host, and malformed URLs being invalid; +- URL-like invalid values never falling through to Firebase. + +### HTTP client wiring tests + +Cover the standard OpenTelemetry round-tripper wrapping +`http.DefaultTransport` without retryable HTTP transport attempts. URL +validation must not perform DNS or address checks. + +### HTTP sender tests + +Use an HTTP test server or controlled transport to cover: + +- FCM-compatible message and heartbeat JSON; +- message priority and TTL mapping; +- stable `http/success` result for successful delivery; +- any `2xx` response succeeding with the body ignored; +- retrying network errors, `408`, `429`, and `5xx`; +- not retrying other `4xx` responses; +- three-attempt maximum and five-second per-attempt timeout; +- bounded response-body handling; +- errors and logs not exposing the complete URL. + +### Dispatcher and service tests + +Cover: + +- Firebase tokens using the Firebase sender; +- URL tokens using the HTTP sender; +- outgoing messages and heartbeats using the same dispatcher; +- HTTP success using the existing sent-notification path; +- HTTP terminal failure using the existing failed-notification path; +- message send-attempt count and expiration scheduling remaining unchanged; +- scheduling, exact-send time, per-minute limits, and send schedules remaining + independent of transport. + +### Adapter integration emulator + +Add a dedicated Go service under `tests/adapter-emulator/`. It exposes: + +- an HTTPS callback listener used by the API; +- an HTTP-only test control listener exposed to the host test runner; +- an in-memory gateway registry mapping a unique callback path to a phone + number and phone API key; +- callback records for integration assertions. + +For `KEY_MESSAGE_ID`, the emulator: + +1. fetches `/v1/messages/outstanding` using the registered phone API key; +2. posts the existing `SENT` event; +3. posts the existing `DELIVERED` event; +4. records the fetched message and final adapter action for test assertions. + +For `KEY_HEARTBEAT_ID`, the emulator posts `/v1/heartbeats` for the registered +phone and records the heartbeat wake-up. A control endpoint also instructs the +emulator to submit an incoming message through `/v1/messages/receive`. + +The integration stack generates a throwaway CA and server certificate whose SAN +contains `adapter-emulator`, mounts the server certificate into the emulator, +and makes the CA available to the API's Go trust store through `SSL_CERT_FILE`. +Docker DNS resolves the private emulator hostname through the standard Go HTTP +transport; no private-host allowlist is configured. + +Add end-to-end tests for: + +- **Outgoing:** URL-backed phone callback -> outstanding fetch -> sent event -> + delivered event -> final delivered API status. +- **Incoming:** emulator control request -> existing receive-message API -> + final received API status and matching owner/contact/content. +- **Heartbeat:** internal `phone.heartbeat.missed` CloudEvent -> URL callback -> + emulator heartbeat POST -> heartbeat visible through the user API. +- Callback payload keys, callback processing, and phone API-key scoping. + +Run: + +```bash +cd api +go test ./... +``` + +After annotation changes, regenerate Swagger: + +```bash +cd api +swag init --requiredByDefault --parseDependency --parseInternal +``` + +Run the Docker integration suite: + +```bash +cd tests +bash generate-firebase-credentials.sh +bash generate-adapter-certificates.sh +docker compose up -d --build --wait +docker compose wait seed +go test -v -timeout 300s ./... +docker compose down -v +``` + +## Rollout + +The feature is backward compatible and requires no data migration. Deploy the +backend before configuring URL tokens. + +Operational monitoring should distinguish Firebase and HTTP notification +delivery. Initial rollout should watch: + +- HTTP callback success and retry rates; +- terminal failures by status class; +- callback latency; +- transport failures; +- message expiration after a successful HTTP wake-up; +- repeated callback processing observed by test adapters. + +Rollback is code-only: existing Android tokens continue to be valid, while +URL-backed phones stop receiving wake-ups if the feature is rolled back. + +## Out of Scope + +- WhatsApp provider integration or any adapter implementation. +- Provider credentials, sessions, QR-code login, templates, or media mapping. +- New polling/list-outstanding APIs. +- A new adapter acknowledgement endpoint. +- Signed callbacks, HMAC, JWT, or mutual TLS. +- Separate transport or callback URL database columns. +- Changes to message scheduling, backpressure, expiration, or retry-count + algorithms. +- Web UI for configuring adapters. +- Android application changes. +- General-purpose outbound webhook refactoring. +- Destination-specific DNS/IP filtering or allowlists. diff --git a/tests/README.md b/tests/README.md index df91dee2..35c144b6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,205 +1,225 @@ # Integration Tests -End-to-end integration tests for the httpSMS API. These tests validate the complete SMS lifecycle by running the full application stack in Docker alongside a phone emulator service. +End-to-end tests for the httpSMS API. The suite runs the API and its data +stores in Docker, keeps the existing Firebase/WireMock coverage, and adds a +standard-library-only HTTPS adapter emulator for URL-backed phone gateways. ## Architecture -``` -┌──────────────┐ HTTP ┌──────────────┐ -│ Test Runner │─────────────▶│ API (Go) │ -│ (Go test) │ │ Port 8000 │ -└──────────────┘ └──────┬───────┘ - │ - FCM Push │ Events - (HTTP) │ (HTTP) - ▼ - ┌──────────────┐ - │ Emulator │ - │ (Fiber v3) │ - │ Port 9090 │ - └──────────────┘ - │ - ┌──────┴───────┐ - │ CockroachDB │ │ Redis │ - │ Port 26257 │ │ Port 6379 │ - └──────────────┘ └─────────────┘ +```text + ┌──────────────────┐ + │ API (Go) │ + │ Port 8000 │ + └───────┬──────────┘ + │ + FCM HTTP │ HTTPS callbacks + ┌──────────────────┐ │ ┌──────────────────────┐ + │ WireMock │◀──────┘ │ Adapter emulator │ + │ Port 8080 │ │ HTTPS callback :9091│ + └──────────────────┘ │ HTTP control :9092│ + └──────────┬───────────┘ + │ phone API calls + └──────────▶ API + +┌──────────────────┐ HTTP ┌────────────────────────────┐ +│ Test runner │───────────────────▶│ API, WireMock, and adapter │ +│ Go test on host │ │ control endpoints │ +└──────────────────┘ └────────────────────────────┘ + +Data stores: CockroachDB, Redis, and MongoDB. ``` ### Components -| Component | Description | -| --------------- | -------------------------------------------------------- | -| **API** | The httpSMS Go API server running in Docker | -| **Emulator** | A Fiber v3 Go service that simulates an Android phone | -| **CockroachDB** | Database for the API (single-node, insecure mode) | -| **Redis** | Cache and queue backend | -| **Seed** | One-shot container that seeds test data into CockroachDB | -| **Test Runner** | Go test binary that runs on the host machine | - -### How It Works - -1. **Send SMS flow**: Test sends `POST /v1/messages/send` → API pushes FCM notification to emulator → Emulator calls `GET /v1/messages/outstanding` → Emulator fires `SENT` and `DELIVERED` events → Test polls `GET /v1/messages/{id}` until status is `delivered` - -2. **Receive SMS flow**: Test sends `POST /v1/messages/receive` (as the phone) → API stores message → Test verifies via `GET /v1/messages/{id}` - -### FCM Redirect - -The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect all FCM HTTP requests to the emulator instead of Google's servers. The emulator serves: - -- `/token` — Fake OAuth2 token endpoint (Firebase SDK requests tokens before sending) -- `/v1/projects/:project/messages:send` — Fake FCM push endpoint +| Component | Description | +| --- | --- | +| **API** | The httpSMS Go API server | +| **WireMock** | Existing fake Firebase and webhook endpoints | +| **Adapter emulator** | HTTPS URL-backed phone gateway with an HTTP-only host control API | +| **CockroachDB** | Relational database for API data | +| **Redis** | Cache and local event queue backend | +| **MongoDB** | Heartbeat and contact backend | +| **Seed** | One-shot container that inserts integration users and API keys | +| **Test runner** | Go tests running on the host | + +### Gateway Flows + +1. **Existing FCM flow:** the API sends Firebase-compatible requests to + WireMock. Existing tests fetch outstanding messages and submit phone events + without changing their transport. +2. **URL-backed outgoing flow:** the API posts an FCM-compatible envelope to + `https://adapter-emulator:9091/notifications/{gatewayID}`. The adapter uses + its registered phone API key to fetch the outstanding message and post + `SENT` followed by `DELIVERED`. +3. **URL-backed incoming flow:** the test calls the adapter control API on + host port `9092`; the adapter posts `/v1/messages/receive` as the registered + phone. +4. **Heartbeat wake-up:** the test dispatches `phone.heartbeat.missed` through + `/v1/events`. The API sends an HTTPS callback containing + `KEY_HEARTBEAT_ID`, and the adapter posts `/v1/heartbeats`. + +The HTTPS endpoint uses a two-day throwaway CA and server certificate with the +DNS SAN `adapter-emulator`. The API container trusts only that generated CA via +`SSL_CERT_FILE`; HTTPS verification is never bypassed. The notification sender +uses the standard OpenTelemetry-instrumented Go HTTP transport. ## Test Coverage -- [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → emulator responds with SENT/DELIVERED events → message reaches `delivered` status -- [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint -- [x] **Message thread unread count E2E** — Incoming SMS and missed calls increment the unread count, the existing thread update endpoint clears it, and outbound activity preserves the count -- [x] **Unarchive Thread on Receive E2E** — Archived thread returns to the inbox on inbound message when the phone's `unarchive_thread` setting is enabled, and stays archived when disabled -- [x] **Contacts E2E** — JSON CRUD, search and pagination totals, CSV import normalization, and contact details attached to message threads +- [x] Existing encrypted send/receive phone scenarios through WireMock +- [x] Existing rate-limit, webhook, contacts, bulk, and thread scenarios +- [x] URL-backed outgoing message reaches `delivered` +- [x] URL-backed incoming message reaches `received` +- [x] URL-backed heartbeat callback stores a heartbeat +- [x] Adapter callback notification IDs are deduplicated in memory +- [x] HTTPS certificate trust is exercised ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) with Docker Compose -- [Go 1.22+](https://go.dev/dl/) -- [jq](https://jqlang.github.io/jq/download/) (for Firebase credentials generation) -- [OpenSSL](https://www.openssl.org/) (for RSA key generation) +- [Go 1.25+](https://go.dev/dl/) +- [jq](https://jqlang.github.io/jq/download/) +- [OpenSSL](https://www.openssl.org/) + +On Windows, the scripts can be run with Git Bash, for example +`C:\Program Files\Git\bin\bash.exe`. ## Running Locally -### 1. Generate Firebase Credentials +### 1. Generate throwaway credentials and certificates -The integration tests use a fake Firebase service account. Generate it with: +Run both scripts before starting Docker: ```bash cd tests -bash generate-firebase-credentials.sh -``` - -This creates `firebase-credentials.json` with a throwaway RSA key (the emulator doesn't validate tokens). - -### 2. Set Environment Variable - -```bash +bash generate-firebase-credentials.sh firebase-credentials.json +bash generate-adapter-certificates.sh certs export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) ``` -### 3. Start the Stack - -```bash -docker compose up -d --build --wait -``` +The generated Firebase credential and the complete `certs/` directory are +ignored by Git. -This starts CockroachDB, Redis, the API, and the emulator. The `--wait` flag blocks until all health checks pass. - -### 4. Wait for Seeding +### 2. Start the stack and wait for seeding ```bash +docker compose up -d --build --wait docker compose wait seed sleep 2 ``` -The seed container inserts test users, phones, and API keys into CockroachDB after the API has run its GORM migrations. - -### 5. Run Tests +### 3. Run the complete suite ```bash -go test -v -timeout 120s ./... +go test -v -timeout 300s ./... ``` -### 6. Tear Down +### 4. Tear down ```bash docker compose down -v ``` -The `-v` flag removes volumes (database data) for a clean slate next run. - -### One-Liner +### One-liner ```bash cd tests && \ - bash generate-firebase-credentials.sh && \ + bash generate-firebase-credentials.sh firebase-credentials.json && \ + bash generate-adapter-certificates.sh certs && \ export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) && \ docker compose up -d --build --wait && \ docker compose wait seed && \ sleep 2 && \ - go test -v -timeout 120s ./... ; \ + go test -v -timeout 300s ./... ; \ docker compose down -v ``` ## CI/CD -Integration tests run automatically via GitHub Actions (`.github/workflows/integration-test.yml`): - -- **Trigger**: Push to `main` or pull request targeting `main` -- **Flow**: Generates credentials → Starts Docker stack → Seeds DB → Runs tests → Collects logs on failure → Tears down -- **Gate**: Deployment should only proceed if integration tests pass +`.github/workflows/api.yml` generates both the fake Firebase credential and +the adapter CA/server certificate before building the Compose stack. The +workflow runs API handler integration tests and this complete host-side suite, +collects service logs on failure, and always tears the stack down. ## Test Data -| Entity | Value | -| -------------- | -------------------------------------- | -| User API Key | `test-user-api-key` | -| Phone API Key | `pk_test-phone-api-key` | -| Phone Number | `+18005550199` | -| Contact Number | `+18005550100` | -| User ID | `test-user-id` | -| Phone ID | `a1b2c3d4-e5f6-7890-abcd-ef1234567890` | +| Entity | Value | +| --- | --- | +| User API key | `test-user-api-key` | +| System API key | `system-user-api-key` | +| User ID | `test-user-id` | +| System user ID | `system-user-id` | -See [`seed.sql`](./seed.sql) for the complete seed data. +Adapter tests create a unique gateway UUID, phone number, phone API key, and +callback path per test. See [`seed.sql`](./seed.sql) for shared seed data. ## Project Structure -``` +```text tests/ -├── docker-compose.yml # Full stack orchestration -├── seed.sql # Database seed data -├── .env.test # API environment variables -├── generate-firebase-credentials.sh # Generates fake Firebase credentials -├── go.mod # Test runner Go module -├── go.sum -├── helpers_test.go # Test utilities (HTTP client, polling) -├── integration_test.go # E2E test cases -└── emulator/ # Phone emulator service - ├── Dockerfile - ├── go.mod - ├── go.sum - ├── main.go # Fiber v3 entry point - ├── emulator.go # Emulator struct and config - ├── token_handler.go # Fake OAuth2 token endpoint - ├── fcm_handler.go # Fake FCM push receiver - └── events.go # Event firing logic (SENT/DELIVERED) +├── adapter-emulator/ +│ ├── Dockerfile +│ ├── go.mod +│ ├── main.go +│ ├── emulator.go +│ ├── api_client.go +│ ├── notification_handler.go +│ ├── control_handler.go +│ └── emulator_test.go +├── wiremock/ +│ └── mappings/ +├── adapter_integration_test.go +├── integration_test.go +├── helpers_test.go +├── docker-compose.yml +├── .env.test +├── seed.sql +├── generate-firebase-credentials.sh +├── generate-adapter-certificates.sh +├── go.mod +└── go.sum ``` ## Troubleshooting -### API fails to start +### API or adapter fails to start + +```bash +docker compose logs --tail 200 api adapter-emulator +``` + +Confirm `tests/certs/ca.pem`, `server.pem`, and `server-key.pem` exist. TLS +errors should be fixed by regenerating certificates; do not disable HTTPS +verification. -Check the API logs: +### URL-backed outgoing message times out ```bash -docker compose logs api +docker compose logs --tail 200 api adapter-emulator ``` -Common issues: +Adapter logs should show callback receipt, the outstanding-message fetch, +`SENT`, and `DELIVERED`. Confirm `SSL_CERT_FILE=/adapter-certs/ca.pem` is +present in the API container. -- `FIREBASE_CREDENTIALS` env var not set or malformed -- CockroachDB not ready (increase `start_period` in healthcheck) +### URL-backed incoming message times out -### Tests timeout waiting for `delivered` status +Adapter logs should show the control request followed by a call to +`/v1/messages/receive`. The gateway registration contains the per-test phone +number and phone API key. -Check the emulator logs: +### Heartbeat callback times out -```bash -docker compose logs emulator -``` +API logs should show `phone.heartbeat.missed`. Adapter logs should show +`KEY_HEARTBEAT_ID` followed by a successful heartbeat POST. -The emulator should show: +### Existing FCM scenario times out -1. `[FCM]` — Receiving the push notification -2. `[EVENTS]` — Fetching outstanding messages and firing events +```bash +docker compose logs --tail 200 api wiremock +``` -If no `[FCM]` entries appear, the API isn't reaching the emulator (check `FCM_ENDPOINT` in `.env.test`). +Keep `FCM_ENDPOINT=http://wiremock:8080`; the adapter service does not replace +or weaken the WireMock phone tests. ### Seed container fails @@ -207,11 +227,5 @@ If no `[FCM]` entries appear, the API isn't reaching the emulator (check `FCM_EN docker compose logs seed ``` -If you see "relation does not exist" errors, the API hasn't finished GORM migrations yet. Increase the API's `start_period` in `docker-compose.yml`. - -## Adding New Tests - -1. Add test functions to `integration_test.go` (or create new `*_test.go` files) -2. Use `doRequest()` helper for authenticated HTTP calls -3. Use `pollMessageStatus()` to wait for async state changes -4. Update the test coverage checklist in this README +If a relation does not exist, inspect API migration/startup logs before +increasing health-check timing. diff --git a/tests/adapter-emulator/Dockerfile b/tests/adapter-emulator/Dockerfile new file mode 100644 index 00000000..d752fcaa --- /dev/null +++ b/tests/adapter-emulator/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/adapter-emulator . + +FROM alpine:3.22 +RUN adduser -D -u 10001 app +USER app +COPY --from=build /out/adapter-emulator /usr/local/bin/adapter-emulator +ENTRYPOINT ["adapter-emulator"] diff --git a/tests/adapter-emulator/api_client.go b/tests/adapter-emulator/api_client.go new file mode 100644 index 00000000..17ca2031 --- /dev/null +++ b/tests/adapter-emulator/api_client.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "time" +) + +const maxAPIErrorBodyBytes = 4 * 1024 + +func (instance *emulator) fetchOutstanding( + ctx context.Context, + registeredGateway gateway, + messageID string, +) (map[string]any, error) { + endpoint, err := url.Parse(instance.apiBaseURL + "/v1/messages/outstanding") + if err != nil { + return nil, fmt.Errorf("build outstanding message URL: %w", err) + } + query := endpoint.Query() + query.Set("message_id", messageID) + endpoint.RawQuery = query.Encode() + + log.Printf("[ADAPTER] fetching outstanding message %s", messageID) + var response struct { + Data map[string]any `json:"data"` + } + if err := instance.doAPIRequest(ctx, registeredGateway, http.MethodGet, endpoint.String(), nil, &response); err != nil { + return nil, fmt.Errorf("fetch outstanding message %s: %w", messageID, err) + } + return response.Data, nil +} + +func (instance *emulator) fireMessageEvent( + ctx context.Context, + registeredGateway gateway, + messageID string, + eventName string, +) error { + payload := map[string]any{ + "event_name": eventName, + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + } + endpoint := fmt.Sprintf("%s/v1/messages/%s/events", instance.apiBaseURL, url.PathEscape(messageID)) + log.Printf("[ADAPTER] posting %s for message %s", eventName, messageID) + if err := instance.doAPIRequest(ctx, registeredGateway, http.MethodPost, endpoint, payload, nil); err != nil { + return fmt.Errorf("post %s event for message %s: %w", eventName, messageID, err) + } + return nil +} + +func (instance *emulator) receiveMessage( + ctx context.Context, + registeredGateway gateway, + request incomingMessageRequest, +) (map[string]any, error) { + payload := map[string]any{ + "from": request.Contact, + "to": registeredGateway.PhoneNumber, + "content": request.Content, + "encrypted": request.Encrypted, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + } + log.Printf("[ADAPTER] posting incoming message for gateway phone %s", registeredGateway.PhoneNumber) + + var response struct { + Data map[string]any `json:"data"` + } + if err := instance.doAPIRequest( + ctx, + registeredGateway, + http.MethodPost, + instance.apiBaseURL+"/v1/messages/receive", + payload, + &response, + ); err != nil { + return nil, fmt.Errorf("post incoming message: %w", err) + } + return response.Data, nil +} + +func (instance *emulator) storeHeartbeat(ctx context.Context, registeredGateway gateway) error { + payload := map[string]any{ + "phone_numbers": []string{registeredGateway.PhoneNumber}, + "charging": true, + } + log.Printf("[ADAPTER] posting heartbeat for %s", registeredGateway.PhoneNumber) + if err := instance.doAPIRequest( + ctx, + registeredGateway, + http.MethodPost, + instance.apiBaseURL+"/v1/heartbeats", + payload, + nil, + ); err != nil { + return fmt.Errorf("post heartbeat for %s: %w", registeredGateway.PhoneNumber, err) + } + return nil +} + +func (instance *emulator) doAPIRequest( + ctx context.Context, + registeredGateway gateway, + method string, + endpoint string, + payload any, + result any, +) error { + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encode request body: %w", err) + } + body = bytes.NewReader(encoded) + } + + request, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + request.Header.Set("x-api-key", registeredGateway.PhoneAPIKey) + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := instance.client.Do(request) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + errorBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxAPIErrorBodyBytes)) + if readErr != nil { + return fmt.Errorf("unexpected status %d and read error body: %w", response.StatusCode, readErr) + } + return fmt.Errorf("unexpected status %d: %s", response.StatusCode, string(errorBody)) + } + if result == nil || response.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, response.Body) + return nil + } + if err := json.NewDecoder(response.Body).Decode(result); err != nil { + return fmt.Errorf("decode response body: %w", err) + } + return nil +} diff --git a/tests/adapter-emulator/control_handler.go b/tests/adapter-emulator/control_handler.go new file mode 100644 index 00000000..cfe06689 --- /dev/null +++ b/tests/adapter-emulator/control_handler.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const maxControlBodyBytes = 1024 * 1024 + +type gatewayRegistration struct { + PhoneNumber string `json:"phone_number"` + PhoneAPIKey string `json:"phone_api_key"` +} + +type incomingMessageRequest struct { + Contact string `json:"contact"` + Content string `json:"content"` + Encrypted bool `json:"encrypted"` +} + +func (instance *emulator) controlHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("PUT /test/gateways/{gatewayID}", instance.handleGatewayRegistration) + mux.HandleFunc("POST /test/gateways/{gatewayID}/incoming", instance.handleIncomingMessage) + mux.HandleFunc("GET /test/gateways/{gatewayID}/notifications", instance.handleNotificationRecords) + mux.HandleFunc("GET /health", instance.handleHealth) + return mux +} + +func (instance *emulator) handleGatewayRegistration(writer http.ResponseWriter, request *http.Request) { + var registration gatewayRegistration + if err := decodeControlJSON(writer, request, ®istration); err != nil { + writeControlError(writer, http.StatusBadRequest, err) + return + } + registration.PhoneNumber = strings.TrimSpace(registration.PhoneNumber) + registration.PhoneAPIKey = strings.TrimSpace(registration.PhoneAPIKey) + if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" { + writeControlError(writer, http.StatusBadRequest, errors.New("phone_number and phone_api_key are required")) + return + } + + instance.registerGateway(request.PathValue("gatewayID"), registration) + writer.WriteHeader(http.StatusNoContent) +} + +func (instance *emulator) handleIncomingMessage(writer http.ResponseWriter, request *http.Request) { + registeredGateway, ok := instance.loadGateway(request.PathValue("gatewayID")) + if !ok { + writeControlError(writer, http.StatusNotFound, errors.New("unknown gateway")) + return + } + + var incoming incomingMessageRequest + if err := decodeControlJSON(writer, request, &incoming); err != nil { + writeControlError(writer, http.StatusBadRequest, err) + return + } + incoming.Contact = strings.TrimSpace(incoming.Contact) + if incoming.Contact == "" { + writeControlError(writer, http.StatusBadRequest, errors.New("contact is required")) + return + } + + message, err := instance.receiveMessage(request.Context(), registeredGateway, incoming) + if err != nil { + writeControlError(writer, http.StatusBadGateway, err) + return + } + writeControlJSON(writer, http.StatusOK, map[string]any{"data": message}) +} + +func (instance *emulator) handleNotificationRecords(writer http.ResponseWriter, request *http.Request) { + gatewayID := request.PathValue("gatewayID") + if _, ok := instance.loadGateway(gatewayID); !ok { + writeControlError(writer, http.StatusNotFound, errors.New("unknown gateway")) + return + } + records := instance.listGatewayRecords(gatewayID) + if messageID := strings.TrimSpace(request.URL.Query().Get("message_id")); messageID != "" { + filtered := make([]notificationRecord, 0, len(records)) + for _, record := range records { + if record.MessageID == messageID { + filtered = append(filtered, record) + } + } + records = filtered + } + writeControlJSON(writer, http.StatusOK, map[string]any{ + "data": records, + }) +} + +func (*emulator) handleHealth(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte("ok\n")) +} + +func decodeControlJSON(writer http.ResponseWriter, request *http.Request, result any) error { + request.Body = http.MaxBytesReader(writer, request.Body, maxControlBodyBytes) + decoder := json.NewDecoder(request.Body) + if err := decoder.Decode(result); err != nil { + return fmt.Errorf("decode request body: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("request body must contain one JSON value") + } + return nil +} + +func writeControlError(writer http.ResponseWriter, status int, err error) { + writeControlJSON(writer, status, map[string]any{"error": err.Error()}) +} + +func writeControlJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(payload) +} diff --git a/tests/adapter-emulator/emulator.go b/tests/adapter-emulator/emulator.go new file mode 100644 index 00000000..13d0568d --- /dev/null +++ b/tests/adapter-emulator/emulator.go @@ -0,0 +1,119 @@ +package main + +import ( + "net/http" + "strings" + "sync" +) + +type gateway struct { + PhoneNumber string + PhoneAPIKey string +} + +type notificationRecord struct { + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` +} + +type emulator struct { + apiBaseURL string + client *http.Client + mu sync.RWMutex + gateways map[string]gateway + records []*notificationRecord +} + +func newEmulator(apiBaseURL string, client *http.Client) *emulator { + return &emulator{ + apiBaseURL: strings.TrimRight(apiBaseURL, "/"), + client: client, + gateways: make(map[string]gateway), + } +} + +func (instance *emulator) registerGateway(gatewayID string, registration gatewayRegistration) { + instance.mu.Lock() + defer instance.mu.Unlock() + + instance.gateways[gatewayID] = gateway{ + PhoneNumber: registration.PhoneNumber, + PhoneAPIKey: registration.PhoneAPIKey, + } +} + +func (instance *emulator) loadGateway(gatewayID string) (gateway, bool) { + instance.mu.RLock() + defer instance.mu.RUnlock() + + registeredGateway, ok := instance.gateways[gatewayID] + return registeredGateway, ok +} + +func (instance *emulator) recordNotification( + gatewayID string, + data map[string]string, + kind string, + messageID string, +) *notificationRecord { + instance.mu.Lock() + defer instance.mu.Unlock() + + record := ¬ificationRecord{ + GatewayID: gatewayID, + Data: copyStringMap(data), + MessageID: messageID, + Kind: kind, + } + instance.records = append(instance.records, record) + + return record +} + +func (instance *emulator) markNotificationProcessed(record *notificationRecord) { + instance.mu.Lock() + defer instance.mu.Unlock() + + record.Processed = true + record.Error = "" +} + +func (instance *emulator) markNotificationFailed(record *notificationRecord, err error) { + instance.mu.Lock() + defer instance.mu.Unlock() + + record.Processed = false + record.Error = err.Error() +} + +func (instance *emulator) listGatewayRecords(gatewayID string) []notificationRecord { + instance.mu.RLock() + defer instance.mu.RUnlock() + + records := make([]notificationRecord, 0) + for _, record := range instance.records { + if record.GatewayID == gatewayID { + records = append(records, *copyNotificationRecord(record)) + } + } + + return records +} + +func copyNotificationRecord(record *notificationRecord) *notificationRecord { + copied := *record + copied.Data = copyStringMap(record.Data) + return &copied +} + +func copyStringMap(values map[string]string) map[string]string { + copied := make(map[string]string, len(values)) + for key, value := range values { + copied[key] = value + } + return copied +} diff --git a/tests/adapter-emulator/emulator_test.go b/tests/adapter-emulator/emulator_test.go new file mode 100644 index 00000000..b8be3c2b --- /dev/null +++ b/tests/adapter-emulator/emulator_test.go @@ -0,0 +1,448 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" +) + +func TestRecordNotificationCopiesRecords(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + }) + + record := instance.recordNotification( + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-1"}, + "message", + "message-1", + ) + instance.markNotificationProcessed(record) + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + if !records[0].Processed { + t.Fatal("processed state was not retained") + } + + records[0].Data["KEY_MESSAGE_ID"] = "mutated" + fresh := instance.listGatewayRecords("gateway-1") + if fresh[0].Data["KEY_MESSAGE_ID"] != "message-1" { + t.Fatalf("record list returned mutable state: %#v", fresh[0]) + } +} + +func TestNotificationHandlerProcessesMessage(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var outstandingCalls int + var events []string + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("x-api-key") != "phone-key" { + t.Errorf("x-api-key = %q, want phone-key", request.Header.Get("x-api-key")) + } + + switch { + case request.Method == http.MethodGet && request.URL.Path == "/v1/messages/outstanding": + mu.Lock() + outstandingCalls++ + mu.Unlock() + if request.URL.Query().Get("message_id") != "message-1" { + t.Errorf("message_id = %q, want message-1", request.URL.Query().Get("message_id")) + } + writeJSON(writer, http.StatusOK, map[string]any{ + "data": map[string]any{"id": "message-1"}, + }) + case request.Method == http.MethodPost && request.URL.Path == "/v1/messages/message-1/events": + var payload struct { + EventName string `json:"event_name"` + Timestamp string `json:"timestamp"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("decode event: %v", err) + } + if payload.Timestamp == "" { + t.Error("event timestamp is empty") + } + mu.Lock() + events = append(events, payload.EventName) + mu.Unlock() + writeJSON(writer, http.StatusOK, map[string]any{"data": map[string]any{"id": "message-1"}}) + default: + http.NotFound(writer, request) + } + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + }) + + body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(body), + ) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("callback status = %d, want 204: %s", response.Code, response.Body.String()) + } + + mu.Lock() + defer mu.Unlock() + if outstandingCalls != 1 { + t.Fatalf("outstanding calls = %d, want 1", outstandingCalls) + } + if !reflect.DeepEqual(events, []string{"SENT", "DELIVERED"}) { + t.Fatalf("events = %#v, want SENT then DELIVERED", events) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + record := records[0] + if record.Kind != "message" || record.MessageID != "message-1" || !record.Processed { + t.Fatalf("unexpected message record: %#v", record) + } +} + +func TestNotificationHandlerStoresHeartbeat(t *testing.T) { + t.Parallel() + + var heartbeatPayload map[string]any + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/v1/heartbeats" { + http.NotFound(writer, request) + return + } + if request.Header.Get("x-api-key") != "phone-key" { + t.Errorf("x-api-key = %q, want phone-key", request.Header.Get("x-api-key")) + } + if err := json.NewDecoder(request.Body).Decode(&heartbeatPayload); err != nil { + t.Errorf("decode heartbeat: %v", err) + } + writeJSON(writer, http.StatusCreated, map[string]any{"data": []any{}}) + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"})), + ) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("callback status = %d, want 204: %s", response.Code, response.Body.String()) + } + + if !reflect.DeepEqual(heartbeatPayload["phone_numbers"], []any{"+18005550199"}) { + t.Fatalf("phone_numbers = %#v, want gateway phone", heartbeatPayload["phone_numbers"]) + } + if heartbeatPayload["charging"] != true { + t.Fatalf("charging = %#v, want true", heartbeatPayload["charging"]) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 || records[0].Kind != "heartbeat" || !records[0].Processed { + t.Fatalf("unexpected heartbeat records: %#v", records) + } +} + +func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { + t.Parallel() + + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Error(writer, "outstanding unavailable", http.StatusServiceUnavailable) + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusInternalServerError { + t.Fatalf("callback status = %d, want 500: %s", response.Code, response.Body.String()) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + if records[0].Processed { + t.Fatal("failed notification was marked processed") + } + if !strings.Contains(records[0].Error, "fetch outstanding message") { + t.Fatalf("record error = %q, want fetch context", records[0].Error) + } +} + +func TestNotificationHandlerProcessesRetryAfterFailure(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var outstandingCalls int + var events []string + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/v1/messages/outstanding": + mu.Lock() + outstandingCalls++ + firstCall := outstandingCalls == 1 + mu.Unlock() + if firstCall { + http.Error(writer, "outstanding unavailable", http.StatusServiceUnavailable) + return + } + writeJSON(writer, http.StatusOK, map[string]any{ + "data": map[string]any{"id": "message-1"}, + }) + case request.Method == http.MethodPost && request.URL.Path == "/v1/messages/message-1/events": + var payload struct { + EventName string `json:"event_name"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("decode event: %v", err) + } + mu.Lock() + events = append(events, payload.EventName) + mu.Unlock() + writeJSON(writer, http.StatusOK, map[string]any{"data": map[string]any{"id": "message-1"}}) + default: + http.NotFound(writer, request) + } + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + }) + handler := instance.notificationHandler() + body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) + + firstRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + firstResponse := httptest.NewRecorder() + handler.ServeHTTP(firstResponse, firstRequest) + if firstResponse.Code != http.StatusInternalServerError { + t.Fatalf("first callback status = %d, want 500: %s", firstResponse.Code, firstResponse.Body.String()) + } + secondRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + secondResponse := httptest.NewRecorder() + handler.ServeHTTP(secondResponse, secondRequest) + if secondResponse.Code != http.StatusNoContent { + t.Fatalf("retry callback status = %d, want 204: %s", secondResponse.Code, secondResponse.Body.String()) + } + + mu.Lock() + defer mu.Unlock() + if outstandingCalls != 2 { + t.Fatalf("outstanding calls = %d, want 2", outstandingCalls) + } + if !reflect.DeepEqual(events, []string{"SENT", "DELIVERED"}) { + t.Fatalf("events = %#v, want SENT then DELIVERED", events) + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 2 { + t.Fatalf("record count = %d, want 2", len(records)) + } + if records[0].Processed || records[0].Error == "" { + t.Fatalf("unexpected failed record: %#v", records[0]) + } + if !records[1].Processed || records[1].Error != "" { + t.Fatalf("unexpected successful retry record: %#v", records[1]) + } +} + +func TestControlHandlerRegistersGatewayAndReceivesIncomingMessage(t *testing.T) { + t.Parallel() + + var receivePayload map[string]any + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/v1/messages/receive" { + http.NotFound(writer, request) + return + } + if request.Header.Get("x-api-key") != "phone-key" { + t.Errorf("x-api-key = %q, want phone-key", request.Header.Get("x-api-key")) + } + if err := json.NewDecoder(request.Body).Decode(&receivePayload); err != nil { + t.Errorf("decode receive payload: %v", err) + } + writeJSON(writer, http.StatusOK, map[string]any{ + "data": map[string]any{"id": "message-1"}, + }) + })) + defer api.Close() + + instance := newEmulator(api.URL, api.Client()) + handler := instance.controlHandler() + + registration := performJSONRequest(t, handler, http.MethodPut, "/test/gateways/gateway-1", map[string]any{ + "phone_number": "+18005550199", + "phone_api_key": "phone-key", + }) + if registration.Code != http.StatusNoContent { + t.Fatalf("registration status = %d, want 204: %s", registration.Code, registration.Body.String()) + } + + incoming := performJSONRequest(t, handler, http.MethodPost, "/test/gateways/gateway-1/incoming", map[string]any{ + "contact": "+18005550100", + "content": "hello", + "encrypted": true, + }) + if incoming.Code != http.StatusOK { + t.Fatalf("incoming status = %d, want 200: %s", incoming.Code, incoming.Body.String()) + } + + if receivePayload["to"] != "+18005550199" || + receivePayload["from"] != "+18005550100" || + receivePayload["content"] != "hello" || + receivePayload["encrypted"] != true || + receivePayload["sim"] != "SIM1" || + receivePayload["timestamp"] == "" { + t.Fatalf("unexpected receive payload: %#v", receivePayload) + } + + var incomingResponse struct { + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(incoming.Body).Decode(&incomingResponse); err != nil { + t.Fatalf("decode incoming response: %v", err) + } + if incomingResponse.Data["id"] != "message-1" { + t.Fatalf("incoming message id = %#v, want message-1", incomingResponse.Data["id"]) + } + + health := httptest.NewRecorder() + handler.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/health", nil)) + if health.Code != http.StatusOK { + t.Fatalf("health status = %d, want 200", health.Code) + } + + records := httptest.NewRecorder() + handler.ServeHTTP(records, httptest.NewRequest(http.MethodGet, "/test/gateways/gateway-1/notifications", nil)) + if records.Code != http.StatusOK { + t.Fatalf("records status = %d, want 200", records.Code) + } +} + +func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + }) + instance.recordNotification( + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-1"}, + "message", + "message-1", + ) + instance.recordNotification( + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-2"}, + "message", + "message-2", + ) + + response := httptest.NewRecorder() + instance.controlHandler().ServeHTTP( + response, + httptest.NewRequest( + http.MethodGet, + "/test/gateways/gateway-1/notifications?message_id=message-2", + nil, + ), + ) + if response.Code != http.StatusOK { + t.Fatalf("records status = %d, want 200", response.Code) + } + + var payload struct { + Data []notificationRecord `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode records: %v", err) + } + if len(payload.Data) != 1 || payload.Data[0].MessageID != "message-2" { + t.Fatalf("filtered records = %#v, want message-2 only", payload.Data) + } +} + +func callbackBody(t *testing.T, data map[string]string) []byte { + t.Helper() + + body, err := json.Marshal(map[string]any{ + "message": map[string]any{ + "token": "https://adapter-emulator:9091/notifications/gateway-1", + "data": data, + }, + }) + if err != nil { + t.Fatalf("marshal callback: %v", err) + } + return body +} + +func performJSONRequest( + t *testing.T, + handler http.Handler, + method string, + target string, + payload map[string]any, +) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + request := httptest.NewRequest(method, target, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func writeJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(payload) +} diff --git a/tests/adapter-emulator/go.mod b/tests/adapter-emulator/go.mod new file mode 100644 index 00000000..399833be --- /dev/null +++ b/tests/adapter-emulator/go.mod @@ -0,0 +1,3 @@ +module github.com/NdoleStudio/httpsms/tests/adapter-emulator + +go 1.25.0 diff --git a/tests/adapter-emulator/main.go b/tests/adapter-emulator/main.go new file mode 100644 index 00000000..c8d93f50 --- /dev/null +++ b/tests/adapter-emulator/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "crypto/tls" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + callbackAddress = ":9091" + controlAddress = ":9092" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + apiBaseURL := environmentOrDefault("API_BASE_URL", "http://api:8000") + tlsCertificate := environmentOrDefault("ADAPTER_TLS_CERT", "/certs/server.pem") + tlsKey := environmentOrDefault("ADAPTER_TLS_KEY", "/certs/server-key.pem") + + instance := newEmulator(apiBaseURL, &http.Client{Timeout: 15 * time.Second}) + callbackServer := newHTTPServer(callbackAddress, instance.notificationHandler()) + callbackServer.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} + controlServer := newHTTPServer(controlAddress, instance.controlHandler()) + + serverErrors := make(chan error, 2) + go func() { + log.Printf("[ADAPTER] HTTPS callback server listening on %s", callbackAddress) + serverErrors <- callbackServer.ListenAndServeTLS(tlsCertificate, tlsKey) + }() + go func() { + log.Printf("[ADAPTER] HTTP control server listening on %s", controlAddress) + serverErrors <- controlServer.ListenAndServe() + }() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + + var serveErr error + select { + case <-signals: + log.Printf("[ADAPTER] shutdown signal received") + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + serveErr = err + } + } + + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + shutdownErr := errors.Join( + callbackServer.Shutdown(shutdownContext), + controlServer.Shutdown(shutdownContext), + ) + return errors.Join(serveErr, shutdownErr) +} + +func newHTTPServer(address string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: address, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } +} + +func environmentOrDefault(name string, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/tests/adapter-emulator/notification_handler.go b/tests/adapter-emulator/notification_handler.go new file mode 100644 index 00000000..e4108eea --- /dev/null +++ b/tests/adapter-emulator/notification_handler.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" +) + +const maxCallbackBodyBytes = 1024 * 1024 + +type callbackEnvelope struct { + Message struct { + Token string `json:"token"` + Data map[string]string `json:"data"` + } `json:"message"` +} + +func (instance *emulator) notificationHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /notifications/{gatewayID}", instance.handleNotification) + return mux +} + +func (instance *emulator) handleNotification(writer http.ResponseWriter, request *http.Request) { + gatewayID := request.PathValue("gatewayID") + registeredGateway, ok := instance.loadGateway(gatewayID) + if !ok { + http.Error(writer, "unknown gateway", http.StatusNotFound) + return + } + + request.Body = http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes) + var envelope callbackEnvelope + if err := json.NewDecoder(request.Body).Decode(&envelope); err != nil { + http.Error(writer, "invalid callback payload", http.StatusBadRequest) + return + } + + kind, messageID, validationErr := notificationKind(envelope.Message.Data) + record := instance.recordNotification( + gatewayID, + envelope.Message.Data, + kind, + messageID, + ) + log.Printf( + "[ADAPTER] callback gateway=%s data=%v", + gatewayID, + envelope.Message.Data, + ) + if validationErr != nil { + instance.markNotificationFailed(record, validationErr) + http.Error(writer, validationErr.Error(), http.StatusBadRequest) + return + } + + var processingErr error + switch kind { + case "message": + _, processingErr = instance.fetchOutstanding(request.Context(), registeredGateway, messageID) + if processingErr == nil { + processingErr = instance.fireMessageEvent(request.Context(), registeredGateway, messageID, "SENT") + } + if processingErr == nil { + processingErr = instance.fireMessageEvent(request.Context(), registeredGateway, messageID, "DELIVERED") + } + case "heartbeat": + processingErr = instance.storeHeartbeat(request.Context(), registeredGateway) + } + if processingErr != nil { + instance.markNotificationFailed(record, processingErr) + log.Printf("[ADAPTER] notification failed: %v", processingErr) + http.Error(writer, "notification processing failed", http.StatusInternalServerError) + return + } + + instance.markNotificationProcessed(record) + log.Printf("[ADAPTER] notification processed as %s", kind) + writer.WriteHeader(http.StatusNoContent) +} + +func notificationKind(data map[string]string) (kind string, messageID string, err error) { + messageID = strings.TrimSpace(data["KEY_MESSAGE_ID"]) + heartbeatID := strings.TrimSpace(data["KEY_HEARTBEAT_ID"]) + + switch { + case messageID != "" && heartbeatID == "": + return "message", messageID, nil + case heartbeatID != "" && messageID == "": + return "heartbeat", "", nil + default: + return "", "", fmt.Errorf("unsupported notification data") + } +} diff --git a/tests/adapter_integration_test.go b/tests/adapter_integration_test.go new file mode 100644 index 00000000..6dea41f1 --- /dev/null +++ b/tests/adapter_integration_test.go @@ -0,0 +1,91 @@ +package tests + +import ( + "context" + "net/http" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdapterGatewayOutgoingMessage(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + contact := randomPhoneNumber() + content := "Adapter outgoing " + randomEncryptionKey() + + response, httpResponse, err := newAPIClient().Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contact, + Content: content, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, httpResponse.HTTPResponse.StatusCode) + + messageID := response.Data.ID.String() + message := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) + + assert.Equal(t, phone.PhoneNumber, message.Owner) + assert.Equal(t, contact, message.Contact) + assert.Equal(t, content, message.Content) + records := waitForAdapterMessageRecords(t, phone.GatewayID, messageID, 30*time.Second) + require.Len(t, records, 1) + assert.Equal(t, "message", records[0].Kind) + assert.True(t, records[0].Processed) + assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"]) +} + +func TestAdapterGatewayIncomingMessage(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + contact := randomPhoneNumber() + content := "Adapter incoming " + randomEncryptionKey() + + messageID := triggerAdapterIncoming(ctx, t, phone, contact, content) + message := pollMessageStatus(ctx, t, messageID, "received", 15*time.Second) + + assert.Equal(t, phone.PhoneNumber, message.Owner) + assert.Equal(t, contact, message.Contact) + assert.Equal(t, content, message.Content) + assert.Equal(t, "received", message.Status) +} + +func TestAdapterGatewayHeartbeatWakeUp(t *testing.T) { + ctx := context.Background() + phone := setupAdapterPhone(ctx, t, 60) + monitorID := uuid.NewString() + + dispatchInternalEvent(ctx, t, map[string]any{ + "specversion": "1.0", + "id": uuid.NewString(), + "source": "/tests/adapter-emulator", + "type": "phone.heartbeat.missed", + "time": time.Now().UTC().Format(time.RFC3339), + "datacontenttype": "application/json", + "data": map[string]any{ + "phone_id": phone.PhoneID, + "user_id": "test-user-id", + "last_heartbeat_timestamp": time.Now().UTC().Add(-20 * time.Minute).Format(time.RFC3339), + "timestamp": time.Now().UTC().Format(time.RFC3339), + "monitor_id": monitorID, + "owner": phone.PhoneNumber, + }, + }) + + record := waitForAdapterHeartbeatRecord(t, phone.GatewayID, 30*time.Second) + assert.Equal(t, "heartbeat", record.Kind) + assert.NotEmpty(t, record.Data["KEY_HEARTBEAT_ID"]) + + heartbeats, response, err := newAPIClient().Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{ + Owner: phone.PhoneNumber, + Limit: 1, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + require.NotEmpty(t, heartbeats.Data) + assert.Equal(t, phone.PhoneNumber, heartbeats.Data[0].Owner) +} diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 3d82d47c..ca03a313 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -76,6 +76,23 @@ services: timeout: 5s retries: 10 + adapter-emulator: + build: + context: ./adapter-emulator + ports: + - "9092:9092" + environment: + API_BASE_URL: http://api:8000 + ADAPTER_TLS_CERT: /certs/server.pem + ADAPTER_TLS_KEY: /certs/server-key.pem + volumes: + - ./certs:/certs:ro + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9092/health"] + interval: 5s + timeout: 5s + retries: 10 + api: build: context: ../api @@ -90,10 +107,15 @@ services: condition: service_healthy mongodb: condition: service_healthy + adapter-emulator: + condition: service_healthy env_file: - .env.test environment: FIREBASE_CREDENTIALS: "${FIREBASE_CREDENTIALS}" + SSL_CERT_FILE: /adapter-certs/ca.pem + volumes: + - ./certs/ca.pem:/adapter-certs/ca.pem:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 5s diff --git a/tests/generate-adapter-certificates.sh b/tests/generate-adapter-certificates.sh new file mode 100644 index 00000000..c42eb805 --- /dev/null +++ b/tests/generate-adapter-certificates.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +export MSYS2_ARG_CONV_EXCL="/CN=" + +output_dir="${1:-certs}" +mkdir -p "$output_dir" + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$output_dir/ca-key.pem" \ + -out "$output_dir/ca.pem" \ + -days 2 \ + -subj "/CN=httpSMS integration adapter CA" + +openssl req -newkey rsa:2048 -nodes \ + -keyout "$output_dir/server-key.pem" \ + -out "$output_dir/server.csr" \ + -subj "/CN=adapter-emulator" + +cat >"$output_dir/server.ext" <<'EOF' +subjectAltName=DNS:adapter-emulator +extendedKeyUsage=serverAuth +EOF + +openssl x509 -req \ + -in "$output_dir/server.csr" \ + -CA "$output_dir/ca.pem" \ + -CAkey "$output_dir/ca-key.pem" \ + -CAcreateserial \ + -out "$output_dir/server.pem" \ + -days 2 \ + -extfile "$output_dir/server.ext" + +# The emulator runs as an unprivileged container user and must read this +# bind-mounted throwaway key. +chmod 0644 "$output_dir/server-key.pem" diff --git a/tests/helpers_test.go b/tests/helpers_test.go index dfbf2885..a0c84c41 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -10,6 +10,7 @@ import ( "math/big" "mime/multipart" "net/http" + "net/url" "strings" "testing" "time" @@ -26,7 +27,9 @@ const ( apiBaseURL = "http://localhost:8000" wiremockURL = "http://localhost:8080" wiremockWebhookURL = "http://wiremock.local:8080" // reachable from API container, passes URL validation (needs a dot) + adapterControlURL = "http://localhost:9092" userAPIKey = "test-user-api-key" + systemAPIKey = "system-user-api-key" ) type testPhone struct { @@ -35,6 +38,21 @@ type testPhone struct { FcmToken string } +type adapterTestPhone struct { + testPhone + PhoneID string + GatewayID string +} + +type notificationRecord struct { + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` +} + func newAPIClient() *httpsms.Client { return httpsms.New( httpsms.WithBaseURL(apiBaseURL), @@ -119,6 +137,233 @@ func setupPhone(ctx context.Context, t *testing.T, messagesPerMinute uint) testP } } +func setupAdapterPhone(ctx context.Context, t *testing.T, messagesPerMinute uint) adapterTestPhone { + t.Helper() + + gatewayID := uuid.NewString() + phoneNumber := randomPhoneNumber() + client := newAPIClient() + + apiKeyResponse, response, err := client.PhoneAPIKeys.Store(ctx, &httpsms.PhoneAPIKeyStoreParams{ + Name: "adapter-test-key-" + uuid.NewString(), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone api key store failed") + + phoneAPIKey := apiKeyResponse.Data.APIKey + require.NotEmpty(t, phoneAPIKey) + + registrationBody, err := json.Marshal(map[string]any{ + "phone_number": phoneNumber, + "phone_api_key": phoneAPIKey, + }) + require.NoError(t, err) + registrationRequest, err := http.NewRequestWithContext( + ctx, + http.MethodPut, + fmt.Sprintf("%s/test/gateways/%s", adapterControlURL, gatewayID), + bytes.NewReader(registrationBody), + ) + require.NoError(t, err) + registrationRequest.Header.Set("Content-Type", "application/json") + registrationResponse, err := http.DefaultClient.Do(registrationRequest) + require.NoError(t, err) + registrationResponseBody, err := io.ReadAll(registrationResponse.Body) + registrationResponse.Body.Close() + require.NoError(t, err) + require.Equal( + t, + http.StatusNoContent, + registrationResponse.StatusCode, + "adapter gateway registration failed: %s", + string(registrationResponseBody), + ) + + callbackURL := fmt.Sprintf("https://adapter-emulator:9091/notifications/%s", gatewayID) + phoneResponse, response, err := client.Phones.Upsert(ctx, &httpsms.PhoneUpsertParams{ + PhoneNumber: phoneNumber, + FcmToken: callbackURL, + MessagesPerMinute: messagesPerMinute, + MaxSendAttempts: 2, + MessageExpirationSeconds: 600, + SIM: "SIM1", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone upsert failed") + require.NotEmpty(t, phoneResponse.Data.ID) + + phoneClient := newPhoneClient(phoneAPIKey) + _, response, err = phoneClient.Phones.UpsertFCMToken(ctx, &httpsms.PhoneFCMTokenParams{ + PhoneNumber: phoneNumber, + FcmToken: callbackURL, + SIM: "SIM1", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "adapter callback bind failed") + + waitForPhoneAuthorization(ctx, t, phoneAPIKey, phoneNumber, 20*time.Second) + + return adapterTestPhone{ + testPhone: testPhone{ + PhoneNumber: phoneNumber, + PhoneAPIKey: phoneAPIKey, + FcmToken: callbackURL, + }, + PhoneID: phoneResponse.Data.ID, + GatewayID: gatewayID, + } +} + +func dispatchInternalEvent(ctx context.Context, t *testing.T, event map[string]any) { + t.Helper() + + body, err := json.Marshal(event) + require.NoError(t, err) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/v1/events", bytes.NewReader(body)) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("x-api-key", systemAPIKey) + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode, "event dispatch failed: %s", string(responseBody)) +} + +func waitForAdapterMessageRecords( + t *testing.T, + gatewayID string, + messageID string, + timeout time.Duration, +) []notificationRecord { + t.Helper() + + deadline := time.Now().Add(timeout) + var records []notificationRecord + var lastErr error + for time.Now().Before(deadline) { + records, lastErr = fetchAdapterNotificationRecords(gatewayID, messageID) + if lastErr == nil && len(records) > 0 && adapterRecordsProcessed(records) { + return records + } + time.Sleep(500 * time.Millisecond) + } + + require.NoError(t, lastErr) + require.NotEmpty(t, records, "adapter message record for %s was not available within %v", messageID, timeout) + require.True(t, adapterRecordsProcessed(records), "adapter message records were not processed: %#v", records) + return records +} + +func waitForAdapterHeartbeatRecord( + t *testing.T, + gatewayID string, + timeout time.Duration, +) notificationRecord { + t.Helper() + + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + records, err := fetchAdapterNotificationRecords(gatewayID, "") + lastErr = err + if err == nil { + for _, record := range records { + if record.Kind == "heartbeat" && record.Processed { + return record + } + } + } + time.Sleep(500 * time.Millisecond) + } + + require.NoError(t, lastErr) + t.Fatalf("processed adapter heartbeat record was not available within %v", timeout) + return notificationRecord{} +} + +func triggerAdapterIncoming( + ctx context.Context, + t *testing.T, + phone adapterTestPhone, + contact string, + content string, +) string { + t.Helper() + + body, err := json.Marshal(map[string]any{ + "contact": contact, + "content": content, + "encrypted": false, + }) + require.NoError(t, err) + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + fmt.Sprintf("%s/test/gateways/%s/incoming", adapterControlURL, phone.GatewayID), + bytes.NewReader(body), + ) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + defer response.Body.Close() + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode, "adapter incoming trigger failed: %s", string(responseBody)) + + var result struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(responseBody, &result)) + require.NotEmpty(t, result.Data.ID) + return result.Data.ID +} + +func fetchAdapterNotificationRecords(gatewayID string, messageID string) ([]notificationRecord, error) { + endpoint := fmt.Sprintf("%s/test/gateways/%s/notifications", adapterControlURL, gatewayID) + if messageID != "" { + endpoint += "?message_id=" + url.QueryEscape(messageID) + } + + response, err := (&http.Client{Timeout: 5 * time.Second}).Get(endpoint) + if err != nil { + return nil, fmt.Errorf("fetch adapter notification records: %w", err) + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read adapter notification records: %w", err) + } + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch adapter notification records: status %d: %s", response.StatusCode, string(responseBody)) + } + + var result struct { + Data []notificationRecord `json:"data"` + } + if err := json.Unmarshal(responseBody, &result); err != nil { + return nil, fmt.Errorf("decode adapter notification records: %w", err) + } + return result.Data, nil +} + +func adapterRecordsProcessed(records []notificationRecord) bool { + for _, record := range records { + if !record.Processed { + return false + } + } + return true +} + func waitForPhoneAuthorization( ctx context.Context, t *testing.T,