From 4a833a82fe47ebabc66d60e33cb2ee3c69d36f5b Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 22:35:12 +0300 Subject: [PATCH 01/22] docs(api): design URL-backed notifications Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...acked-phone-notification-adapter-design.md | 512 ++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-02-url-backed-phone-notification-adapter-design.md 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..5a4e616f --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-url-backed-phone-notification-adapter-design.md @@ -0,0 +1,512 @@ +# URL-backed Phone Notification Adapter + +- Date: 2026-09-02 +- Status: Approved (design) +- Scope: `api/` Go backend. 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: + +- outbound requests must not provide an SSRF path into httpSMS infrastructure; +- callbacks are wake-up hints, not proof that a message was sent; +- callback delivery is at least once and must have an idempotency identity; +- 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 public `https://` URL selects HTTP delivery. Non-URL tokens select + Firebase. URL-like but malformed or unsupported values are rejected. +- Use a transport-neutral notification dispatcher with separate Firebase and + HTTP senders. +- 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 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. +- Restrict production callback destinations to public HTTPS endpoints. +- 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, has no + hostname, or contains embedded user information. + +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-neutral notification + +Add a small internal notification type containing only the data needed by both +transports: + +```go +type GatewayNotification struct { + Token string + Data map[string]string + Priority string + TTL *time.Duration + NotificationID string +} +``` + +`NotificationID` is the persisted `PhoneNotification.ID` for outgoing +messages. Heartbeats have no persisted phone notification, so the service +generates a request UUID for their delivery identity. + +Add a dispatcher that: + +1. uses the phone helper to determine the transport; +2. delegates Firebase tokens to the Firebase sender; +3. delegates URL tokens to the HTTP sender; +4. returns a transport-neutral delivery result string or an error. + +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 sender + +Adapt the existing `FCMClient` behind the transport-neutral sender interface. +It maps `GatewayNotification` to `messaging.Message`: + +- `Data` maps directly to the FCM data payload; +- `Priority` maps to `messaging.AndroidConfig.Priority`; +- `TTL` maps to `messaging.AndroidConfig.TTL`; +- `Token` remains the FCM registration token. + +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. + +Every request includes: + +```text +X-httpSMS-Notification-ID: +Content-Type: application/json +``` + +For an outgoing message, the header value is the persisted phone-notification +ID. Retries of the same HTTP delivery reuse that ID. If the normal message +expiration flow schedules another send attempt, it creates a new +`PhoneNotification` and therefore a new ID. The adapter can distinguish a +duplicate HTTP request from an intentional later send attempt. + +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 and adapter idempotency + +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 then delivers the same notification ID again. + +Adapters must: + +- deduplicate callback requests by `X-httpSMS-Notification-ID`; +- treat callbacks as hints to fetch work, not as message content; +- avoid sending the external message twice for the same notification ID; +- 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 public 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. SSRF protections + +Because any customer can configure the URL, endpoint policy is part of the +feature rather than optional hardening. + +Accepted destinations must: + +- use `https`; +- include a DNS hostname or public IP; +- omit URL user information; +- resolve only to public, globally routable IP addresses. + +Reject destinations resolving to loopback, private, link-local, multicast, +unspecified, carrier-grade NAT, documentation, benchmarking, and other +non-public reserved ranges for both IPv4 and IPv6. + +Apply policy at two points: + +1. **Registration/update validation:** provide an immediate validation error for + unsafe or unresolvable URL tokens. +2. **Connection time:** resolve and validate again, then dial a validated IP + while preserving TLS Server Name Indication and hostname certificate + verification. + +The connection-time check prevents DNS rebinding between phone registration and +notification delivery. A custom `DialContext` or equivalent must ensure the +validated address is the address actually dialed; a check followed by a normal +second DNS lookup is insufficient. + +The HTTP client: + +- does not inherit environment proxy settings; +- refuses redirects rather than following them to an unchecked destination; +- uses the approved per-attempt timeout; +- retains OpenTelemetry instrumentation around the SSRF-safe transport. + +Private or insecure local-development callback exceptions are out of scope. +Tests use injected resolvers, dialers, and HTTP transports rather than weakening +the production endpoint policy. + +### 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 public HTTPS endpoint policy; +- preserve the existing maximum token length; +- return field-level `fcm_token` validation errors for malformed, unsafe, or + unresolvable destinations. + +Opaque FCM token validation remains unchanged. Existing stored Android tokens +require no migration. + +The URL policy should be a reusable component with an injectable resolver so +request validation and connection-time checks apply the same address rules and +remain deterministic in tests. + +Update request and Swagger descriptions to explain that `fcm_token` accepts +either an FCM registration token or a public HTTPS adapter callback URL. +Regenerate Swagger documentation after implementation. + +### 10. Observability and sensitive values + +The callback URL is stored in the existing token field and may contain a +customer-controlled path or query. Treat the complete value as sensitive even +though callback requests are unsigned. + +Logs and traces must not include the full FCM token or URL. Record only: + +- selected transport; +- sanitized destination hostname for HTTP; +- phone ID; +- notification ID or heartbeat delivery ID; +- message ID where already permitted by existing telemetry; +- attempt number; +- response status class; +- success, retry, or terminal failure. + +Errors propagated to handlers and events must not embed full URLs, response +bodies, or DNS result lists. Existing stacktrace propagation and OpenTelemetry +span error behavior remain in use. + +## 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` to build generic + notifications and preserve existing state transitions; +- `api/pkg/services/fcm_client.go` to adapt Firebase to the neutral sender; +- `api/pkg/services/notification_sender.go` for the neutral notification, + sender interface, and dispatcher; +- `api/pkg/services/http_notification_sender.go` for HTTP payload encoding and + delivery; +- `api/pkg/services/notification_endpoint_policy.go` for URL and resolved-IP + validation; +- `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, HTTP client, resolver, and sender + construction; +- phone request annotations and generated Swagger files. + +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 public HTTPS URLs selecting HTTP; +- empty and nil tokens; +- `http`, `ftp`, URL user information, missing host, and malformed URLs being + invalid; +- URL-like invalid values never falling through to Firebase. + +### Endpoint policy tests + +Use an injectable resolver and dialer to cover: + +- public IPv4 and IPv6 acceptance; +- loopback, private, link-local, multicast, unspecified, carrier-grade NAT, and + reserved-range rejection; +- mixed DNS answers being rejected if any candidate is unsafe; +- validation-time and connection-time checks; +- DNS rebinding attempts; +- redirects being refused; +- environment proxy settings being ignored; +- TLS hostname verification remaining enabled. + +### 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 `X-httpSMS-Notification-ID` across transport retries; +- 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. + +Run: + +```bash +cd api +go test ./... +``` + +After annotation changes, regenerate Swagger: + +```bash +cd api +swag init --requiredByDefault --parseDependency --parseInternal +``` + +## 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; +- endpoint-policy rejections; +- message expiration after a successful HTTP wake-up; +- duplicate notification IDs 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. From 7b92f2ba02b78f073c09fa12ef6087c18061bf98 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 22:42:37 +0300 Subject: [PATCH 02/22] docs(api): plan adapter gateway integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...2-url-backed-phone-notification-adapter.md | 1972 +++++++++++++++++ ...acked-phone-notification-adapter-design.md | 80 +- 2 files changed, 2048 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-02-url-backed-phone-notification-adapter.md 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..ad59830b --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-url-backed-phone-notification-adapter.md @@ -0,0 +1,1972 @@ +# 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 a public 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`, then route a transport-neutral `GatewayNotification` through a dispatcher backed by Firebase and HTTP senders. The HTTP path uses a shared endpoint policy at validation and connection time, bounded retries, 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`, `net/netip`, Testify, Docker Compose. + +**Spec:** `docs/superpowers/specs/2026-09-02-url-backed-phone-notification-adapter-design.md` + +## Global Constraints + +- Reuse the existing `Phone.FcmToken` database field and `fcm_token` API field; add no transport or endpoint columns. +- A valid public `https://` URL 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. +- 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. +- Reject redirects, proxies, private destinations, loopback destinations, link-local destinations, and reserved destinations. +- Allow a private destination only when its exact hostname is explicitly + allowlisted by the DI container in `ENV=local`; production never reads the + 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/notification_sender.go` - transport-neutral notification, sender interface, Firebase adapter, and dispatcher. +- `api/pkg/services/notification_sender_test.go` - dispatcher routing and Firebase payload mapping tests. +- `api/pkg/services/http_notification_sender.go` - HTTP request encoding, retry classification, timeout, and sanitized results. +- `api/pkg/services/http_notification_sender_test.go` - payload, retry, idempotency, response, and redaction 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 policy, SSRF-safe HTTP transport/client, senders, dispatcher, and updated validator/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") + } + if endpoint.User != nil { + return "", fmt.Errorf("notification URL must not contain user information") + } + + 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, absent user information, 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 Notification Dispatcher and Firebase Adapter + +**Files:** +- Create: `api/pkg/services/notification_sender.go` +- Create: `api/pkg/services/notification_sender_test.go` +- Modify: `api/pkg/services/fcm_client.go` + +**Interfaces:** +- Consumes: + +```go +func (phone *entities.Phone) NotificationTransport() (entities.NotificationTransport, error) +``` + +- Produces: + +```go +type GatewayNotification struct { + Data map[string]string + Priority string + TTL *time.Duration + NotificationID uuid.UUID +} + +type NotificationSender interface { + Send(ctx context.Context, destination string, notification GatewayNotification) (string, error) +} + +type NotificationDispatcher struct { + fcmSender NotificationSender + httpSender NotificationSender +} + +func NewNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *NotificationDispatcher +func (dispatcher *NotificationDispatcher) Send(ctx context.Context, phone *entities.Phone, notification GatewayNotification) (string, error) + +type FCMNotificationSender struct { + client FCMClient +} + +func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender +func (sender *FCMNotificationSender) Send(ctx context.Context, destination string, notification GatewayNotification) (string, error) +``` + +- [ ] **Step 1: Write failing dispatcher and Firebase mapping tests** + +Create `api/pkg/services/notification_sender_test.go` with a recording sender: + +```go +type recordingNotificationSender struct { + destination string + notification GatewayNotification + result string + err error + calls int +} + +func (sender *recordingNotificationSender) Send(_ context.Context, destination string, notification GatewayNotification) (string, error) { + sender.calls++ + sender.destination = destination + sender.notification = notification + return sender.result, sender.err +} +``` + +Add tests that assert: + +```go +func TestNotificationDispatcherRoutesFCMToken(t *testing.T) { + token := "fcm-token:value" + phone := &entities.Phone{FcmToken: &token} + fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} + httpSender := &recordingNotificationSender{} + dispatcher := NewNotificationDispatcher(fcmSender, httpSender) + notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + + result, err := dispatcher.Send(context.Background(), phone, notification) + + require.NoError(t, err) + assert.Equal(t, "projects/test/messages/1", result) + assert.Equal(t, 1, fcmSender.calls) + assert.Zero(t, httpSender.calls) + assert.Equal(t, token, fcmSender.destination) +} +``` + +Add the equivalent HTTPS routing test and an invalid URL-like token test that +asserts neither sender is called. + +Create a recording `FCMClient` and assert `FCMNotificationSender.Send` maps +destination, data, priority, and TTL to `messaging.Message` without mutation. + +- [ ] **Step 2: Run the sender tests and confirm the API is missing** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestNotificationDispatcher|TestFCMNotificationSender' -count=1 +``` + +Expected: compilation fails because the neutral sender types do not exist. + +- [ ] **Step 3: Implement the neutral notification and dispatcher** + +Create `api/pkg/services/notification_sender.go` with the exact interfaces +above. Implement dispatcher routing: + +```go +func (dispatcher *NotificationDispatcher) Send( + ctx context.Context, + phone *entities.Phone, + notification GatewayNotification, +) (string, error) { + transport, err := phone.NotificationTransport() + if err != nil { + return "", stacktrace.Propagatef(err, "cannot determine notification transport for phone [%s]", phone.ID) + } + + destination := strings.TrimSpace(*phone.FcmToken) + switch transport { + case entities.NotificationTransportFCM: + return dispatcher.fcmSender.Send(ctx, destination, notification) + case entities.NotificationTransportHTTP: + return dispatcher.httpSender.Send(ctx, destination, notification) + default: + return "", stacktrace.NewErrorf("unsupported notification transport [%s]", transport) + } +} +``` + +- [ ] **Step 4: Implement the Firebase adapter** + +In the same file, implement: + +```go +func (sender *FCMNotificationSender) Send( + ctx context.Context, + destination string, + notification GatewayNotification, +) (string, error) { + message := &messaging.Message{ + Token: destination, + Data: notification.Data, + Android: &messaging.AndroidConfig{ + Priority: notification.Priority, + TTL: notification.TTL, + }, + } + + result, err := sender.client.Send(ctx, message) + if err != nil { + return "", stacktrace.Propagatef(err, "cannot send Firebase notification") + } + return result, nil +} +``` + +Update comments in `api/pkg/services/fcm_client.go` to describe `FCMClient` as +the low-level Firebase SDK boundary used by `FCMNotificationSender`. Do not +change `FirebaseFCMClient.Send` or `EmulatorFCMClient.Send`. + +- [ ] **Step 5: Run focused tests** + +Run: + +```bash +cd api +go test ./pkg/services -run 'TestNotificationDispatcher|TestFCMNotificationSender' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +Run: + +```bash +cd api +go-fumpt -w pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/fcm_client.go +git add pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/fcm_client.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 GatewayNotification struct { + Data map[string]string + Priority string + TTL *time.Duration + NotificationID uuid.UUID +} + +func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint *url.URL) ([]netip.Addr, error) +``` + +- Produces: + +```go +type HTTPNotificationSender struct { + logger telemetry.Logger + tracer telemetry.Tracer + client *http.Client + policy *NotificationEndpointPolicy + attempts uint + timeout time.Duration + retryDelay func(context.Context, time.Duration) error +} + +func NewHTTPNotificationSender( + logger telemetry.Logger, + tracer telemetry.Tracer, + client *http.Client, + policy *NotificationEndpointPolicy, +) *HTTPNotificationSender + +func (sender *HTTPNotificationSender) Send( + ctx context.Context, + destination string, + notification GatewayNotification, +) (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 directly in tests with `attempts: 3`, +`timeout: 5*time.Second`, and a `retryDelay` that returns nil immediately. +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")) +assert.Equal(t, notification.NotificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) +``` + +Decode the body 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/`. + +- [ ] **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; +- redirect `302`: one call, error; +- response with a body larger than the discard limit: success without reading + unbounded content. + +Record each request's notification ID and assert it does not change between +attempts. + +- [ ] **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` with private payload +types: + +```go +type httpNotificationRequest struct { + Message httpNotificationMessage `json:"message"` +} + +type httpNotificationMessage struct { + Token string `json:"token"` + Data map[string]string `json:"data,omitempty"` + Android httpNotificationAndroid `json:"android,omitempty"` +} + +type httpNotificationAndroid struct { + Priority string `json:"priority,omitempty"` + TTL string `json:"ttl,omitempty"` +} +``` + +Format a non-nil TTL with `notification.TTL.String()`. Build a new request body +for every attempt so retries never reuse a consumed reader. + +- [ ] **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 +} +``` + +`Send` must: + +1. parse `destination`; +2. call `policy.Validate` before the first attempt; +3. marshal the request body once, then create a fresh reader per request; +4. create a child context with the configured timeout per attempt; +5. set `Content-Type` and `X-httpSMS-Notification-ID`; +6. call `client.Do`; +7. close each response body after copying at most 4 KiB to `io.Discard`; +8. return `http/` for any `2xx`; +9. retry only the approved errors/statuses while attempts remain; +10. return a stacktrace-wrapped error that contains the sanitized hostname but + not the full URL, path, query, or response body. + +Set constructor defaults: + +```go +attempts: 3, +timeout: 5 * time.Second, +retryDelay: func(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +}, +``` + +Do not use the container's retrying HTTP client; retries belong in this sender +so status classification, attempt count, and idempotency are explicit. + +- [ ] **Step 6: Add redaction and heartbeat tests** + +Add a test using: + +```text +https://adapter.example.com/secret/path?token=customer-secret +``` + +Force a terminal error and assert neither `secret/path`, +`customer-secret`, nor the full destination appears in the returned error or +recording logger. Assert `adapter.example.com` may appear. + +Add a heartbeat payload test with `KEY_HEARTBEAT_ID`, high priority, nil TTL, +and a generated notification ID; assert the `ttl` field is omitted. + +- [ ] **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 +func (dispatcher *NotificationDispatcher) Send( + ctx context.Context, + phone *entities.Phone, + notification GatewayNotification, +) (string, error) +``` + +- 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, + notificationDispatcher *NotificationDispatcher, + 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 a +recording notification sender to a real `NotificationDispatcher`. + +Test `Send` with an HTTPS token and assert: + +- `KEY_MESSAGE_ID` equals `params.MessageID.String()`; +- priority is `normal`; +- TTL equals `phone.MessageExpirationDuration()`; +- `NotificationID` equals `params.PhoneNotificationID`; +- 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; +- `NotificationID` is non-zero; +- heartbeat sender errors are logged and return nil, preserving current + heartbeat behavior. + +- [ ] **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`: + +- remove the Firebase `messaging` import; +- replace `messagingClient FCMClient` with + `notificationDispatcher *NotificationDispatcher`; +- change the constructor to the exact signature above; +- change `eventDispatcher` to `NotificationEventDispatcher`. + +For message notifications, call: + +```go +ttl := phone.MessageExpirationDuration() +result, err := service.notificationDispatcher.Send(ctx, phone, GatewayNotification{ + Data: map[string]string{ + "KEY_MESSAGE_ID": params.MessageID.String(), + }, + Priority: "normal", + TTL: &ttl, + NotificationID: params.PhoneNotificationID, +}) +``` + +For heartbeat notifications, call: + +```go +result, err := service.notificationDispatcher.Send(ctx, phone, GatewayNotification{ + Data: map[string]string{ + "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), + }, + Priority: "high", + NotificationID: uuid.New(), +}) +``` + +- [ ] **Step 4: Add transport-aware failure text** + +After a dispatcher error, obtain the phone's transport through +`phone.NotificationTransport()`. + +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|TestNotificationDispatcher|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 NewNotificationEndpointPolicy(resolver HostResolver, allowedPrivateHosts []string) *NotificationEndpointPolicy +func NewNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *NotificationDispatcher +func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender +func NewHTTPNotificationSender(logger telemetry.Logger, tracer telemetry.Tracer, client *http.Client, policy *NotificationEndpointPolicy) *HTTPNotificationSender +``` + +- Produces container factories: + +```go +func (container *Container) NotificationEndpointPolicy() *services.NotificationEndpointPolicy +func (container *Container) NotificationHTTPClient() *http.Client +func (container *Container) NotificationDispatcher() *services.NotificationDispatcher +``` + +- 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, embedded credentials, 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{} + if isLocal() { + allowedPrivateHosts = splitCommaEnv("NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST", "") + } + 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)), + ), + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} +``` + +Do not set a client-wide timeout; the sender creates the approved five-second +context for each attempt. + +Add: + +```go +func (container *Container) NotificationDispatcher() *services.NotificationDispatcher { + return services.NewNotificationDispatcher( + services.NewFCMNotificationSender(container.FCMClient()), + services.NewHTTPNotificationSender( + container.Logger(), + container.Tracer(), + container.NotificationHTTPClient(), + container.NotificationEndpointPolicy(), + ), + ) +} +``` + +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.NotificationDispatcher()` instead of `container.FCMClient()`. + +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; +- no full callback URL is logged. + +- [ ] **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 { + NotificationID string `json:"notification_id"` + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Attempts int `json:"attempts"` + 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 map[string]*notificationRecord +} + +func newEmulator(apiBaseURL string, client *http.Client) *emulator { + return &emulator{ + apiBaseURL: strings.TrimRight(apiBaseURL, "/"), + client: client, + gateways: make(map[string]gateway), + records: make(map[string]*notificationRecord), + } +} +``` + +Add locked methods to register a gateway, load a gateway, begin a notification, +mark it processed/failed, and list copied records for one gateway. +`beginNotification` increments `Attempts` for duplicate IDs and returns +`firstDelivery=false` without processing the message again. + +- [ ] **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. require `X-httpSMS-Notification-ID`; +3. return `404` for unknown gateways; +4. return `400` for missing IDs or unsupported data; +5. record every delivery attempt; +6. return `204` immediately for a duplicate notification ID already being + processed or completed; +7. for `KEY_MESSAGE_ID`, fetch outstanding, fire `SENT`, fire `DELIVERED`, and + mark the record processed with kind `message`; +8. for `KEY_HEARTBEAT_ID`, store a heartbeat and mark the record processed with + kind `heartbeat`; +9. 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 +``` + +Add to `tests/.env.test`: + +```text +NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator +``` + +- [ ] **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"]) + assert.NotEmpty(t, records[0].NotificationID) +} +``` + +The record-list helper queries by message ID because the notification ID is +generated inside the API. Assert one processed record to verify the emulator +does not send the message twice. + +- [ ] **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 dispatcher, 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 index 5a4e616f..a127d213 100644 --- 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 @@ -2,7 +2,8 @@ - Date: 2026-09-02 - Status: Approved (design) -- Scope: `api/` Go backend. Web and Android clients are unchanged. +- Scope: `api/` Go backend plus `tests/` integration infrastructure and API CI. + Web and Android clients are unchanged. ## Problem @@ -49,6 +50,9 @@ requirements: - Do not sign or authenticate callback requests. The payload contains no message content or API credentials. - Restrict production callback destinations to public HTTPS endpoints. +- Permit private callback resolution only for exact hostnames on an explicit + allowlist that the DI container reads when `ENV=local`. This exists for the + Docker integration emulator and is never enabled implicitly. - Preserve all existing phone API-key authorization and message-processing behavior. @@ -335,9 +339,12 @@ The HTTP client: - uses the approved per-attempt timeout; - retains OpenTelemetry instrumentation around the SSRF-safe transport. -Private or insecure local-development callback exceptions are out of scope. -Tests use injected resolvers, dialers, and HTTP transports rather than weakening -the production endpoint policy. +The endpoint policy accepts an optional exact-host private-destination +allowlist. The DI container passes configured values only when `ENV=local`; +production ignores the setting. Allowlisting a hostname permits its private +DNS answers but does not permit HTTP, embedded credentials, redirects, proxy +use, or a different hostname. Unit tests use injected resolvers and dialers. +The Docker integration stack allowlists only `adapter-emulator`. ### 9. Validation and API compatibility @@ -407,6 +414,11 @@ Implementation is expected to touch: - `api/pkg/di/container.go` for dispatcher, HTTP client, resolver, 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. @@ -465,6 +477,52 @@ Cover: - 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 keyed by `X-httpSMS-Notification-ID`. + +For `KEY_MESSAGE_ID`, the emulator: + +1. deduplicates the notification ID; +2. fetches `/v1/messages/outstanding` using the registered phone API key; +3. posts the existing `SENT` event; +4. posts the existing `DELIVERED` event; +5. 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. The API runs with: + +```text +NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator +``` + +Because `.env.test` uses `ENV=local`, the exact hostname can resolve to the +Docker-private emulator address while the full HTTPS, TLS verification, +payload, dispatch, and API callback paths remain exercised. No insecure HTTP +callback exception is added. + +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, notification ID header, unique callback handling, and + phone API-key scoping. + Run: ```bash @@ -479,6 +537,18 @@ 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 @@ -510,3 +580,5 @@ URL-backed phones stop receiving wake-ups if the feature is rolled back. - Web UI for configuring adapters. - Android application changes. - General-purpose outbound webhook refactoring. +- Private callback destinations outside the exact local-only integration-test + allowlist. From ee9127cd07baf154be325fd6cb276deffd185e9b Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 22:48:47 +0300 Subject: [PATCH 03/22] chore: ignore linked worktrees Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8f4caf4b..714d2488 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ SECURITY_AUDIT_REPORT.md .output .agents/ skills-lock.json +.worktrees/ From 72af3bbc67c13a757d19a405f5862f316d13bc9a Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 22:53:56 +0300 Subject: [PATCH 04/22] feat(api): classify phone notification tokens Add Phone.NotificationTransport and Phone.NotificationURL helpers with entity-level validation for FCM tokens and public HTTPS endpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/entities/phone.go | 68 ++++++++++++++++++++++++++++++++++ api/pkg/entities/phone_test.go | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 api/pkg/entities/phone_test.go diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 4f3c33f2..7466af1e 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,16 @@ 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 a public HTTPS endpoint. + NotificationTransportHTTP NotificationTransport = "http" +) + // MessageExpirationDuration returns the message expiration as time.Duration func (phone *Phone) MessageExpirationDuration() time.Duration { return time.Duration(int(phone.MessageExpirationSecondsSanitized())) * time.Second @@ -51,3 +64,58 @@ 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 !strings.Contains(token, "://") { + if strings.Contains(token, "/") { + return "", stacktrace.NewErrorf("invalid notification token [%s]", token) + } + return NotificationTransportFCM, nil + } + + endpoint, err := url.Parse(token) + if err != nil { + return "", stacktrace.Propagatef(err, "invalid notification URL [%s]", token) + } + + if endpoint.Scheme != "https" { + return "", stacktrace.NewErrorf("notification URL must use https") + } + if endpoint.Hostname() == "" { + return "", stacktrace.NewErrorf("notification URL must include a hostname") + } + if endpoint.User != nil { + return "", stacktrace.NewErrorf("notification URL must not contain user information") + } + + 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.Propagatef(err, "cannot parse notification URL") + } + + return endpoint, nil +} diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go new file mode 100644 index 00000000..ab6ecf21 --- /dev/null +++ b/api/pkg/entities/phone_test.go @@ -0,0 +1,66 @@ +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("******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) +} From 7bad65c8cf826728cf57fc87ac9f4b399b89fc47 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 22:58:22 +0300 Subject: [PATCH 05/22] feat(api): classify phone notification tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/entities/phone.go | 11 ++++------- api/pkg/entities/phone_test.go | 9 +++++++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 7466af1e..fd5d7969 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -76,18 +76,15 @@ func (phone *Phone) NotificationTransport() (NotificationTransport, error) { return "", stacktrace.NewErrorf("phone has no notification token") } - if !strings.Contains(token, "://") { - if strings.Contains(token, "/") { - return "", stacktrace.NewErrorf("invalid notification token [%s]", token) - } - return NotificationTransportFCM, nil - } - endpoint, err := url.Parse(token) if err != nil { return "", stacktrace.Propagatef(err, "invalid notification URL [%s]", token) } + if endpoint.Scheme == "" { + return NotificationTransportFCM, nil + } + if endpoint.Scheme != "https" { return "", stacktrace.NewErrorf("notification URL must use https") } diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go index ab6ecf21..690ab1a1 100644 --- a/api/pkg/entities/phone_test.go +++ b/api/pkg/entities/phone_test.go @@ -18,14 +18,19 @@ func TestPhoneNotificationTransport(t *testing.T) { transport NotificationTransport hasError bool }{ - {name: "firebase token", token: stringPointer("fcm-token:value"), transport: NotificationTransportFCM}, + {name: "firebase token", 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 fcm token", token: stringPointer("fcm-token:value"), 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 credentials", token: stringPointer("******adapter.example.com/notify"), hasError: true}, + {name: "embedded credentials", token: stringPointer("https://user@adapter.example.com/notify"), hasError: true}, {name: "malformed url", token: stringPointer("https://[::1"), hasError: true}, } From b5182f0f379b392844765dea38356f10db003b3c Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:00:55 +0300 Subject: [PATCH 06/22] feat(api): tighten phone token classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/entities/phone.go | 19 ++++++++++++++----- api/pkg/entities/phone_test.go | 3 +-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index fd5d7969..95c58207 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -44,6 +44,15 @@ const ( 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 @@ -76,16 +85,16 @@ func (phone *Phone) NotificationTransport() (NotificationTransport, error) { return "", stacktrace.NewErrorf("phone has no notification token") } + if !isNotificationURLCandidate(token) { + return NotificationTransportFCM, nil + } + endpoint, err := url.Parse(token) if err != nil { return "", stacktrace.Propagatef(err, "invalid notification URL [%s]", token) } - if endpoint.Scheme == "" { - return NotificationTransportFCM, nil - } - - if endpoint.Scheme != "https" { + if !strings.EqualFold(endpoint.Scheme, "https") { return "", stacktrace.NewErrorf("notification URL must use https") } if endpoint.Hostname() == "" { diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go index 690ab1a1..444cff3a 100644 --- a/api/pkg/entities/phone_test.go +++ b/api/pkg/entities/phone_test.go @@ -18,14 +18,13 @@ func TestPhoneNotificationTransport(t *testing.T) { transport NotificationTransport hasError bool }{ - {name: "firebase token", token: stringPointer("fcm-token-value"), transport: NotificationTransportFCM}, + {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 fcm token", token: stringPointer("fcm-token:value"), 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}, From b51921da5d6d18ac136de5b53bff9043cc02807a Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:05:08 +0300 Subject: [PATCH 07/22] feat(api): validate adapter endpoints --- .../services/notification_endpoint_policy.go | 145 +++++++++++++++++ .../notification_endpoint_policy_test.go | 151 ++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 api/pkg/services/notification_endpoint_policy.go create mode 100644 api/pkg/services/notification_endpoint_policy_test.go diff --git a/api/pkg/services/notification_endpoint_policy.go b/api/pkg/services/notification_endpoint_policy.go new file mode 100644 index 00000000..deac8cd3 --- /dev/null +++ b/api/pkg/services/notification_endpoint_policy.go @@ -0,0 +1,145 @@ +package services + +import ( + "context" + "net" + "net/netip" + "net/url" + "strings" + + "github.com/NdoleStudio/stacktrace" +) + +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"), +} + +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 { + privateHosts := make(map[string]struct{}, len(allowedPrivateHosts)) + for _, host := range allowedPrivateHosts { + privateHosts[strings.ToLower(host)] = struct{}{} + } + + return &NotificationEndpointPolicy{ + resolver: resolver, + allowedPrivateHosts: privateHosts, + } +} + +func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint *url.URL) ([]netip.Addr, error) { + if endpoint == nil { + return nil, stacktrace.NewError("notification endpoint is required") + } + if !strings.EqualFold(endpoint.Scheme, "https") { + return nil, stacktrace.NewError("notification endpoint must use HTTPS") + } + if endpoint.User != nil { + return nil, stacktrace.NewError("notification endpoint must not contain user information") + } + + host := strings.ToLower(endpoint.Hostname()) + if host == "" { + return nil, stacktrace.NewError("notification endpoint must contain a hostname") + } + if literal, err := netip.ParseAddr(host); err == nil && !isPublicNotificationAddress(literal) { + return nil, stacktrace.NewError("notification endpoint must not use a private IP literal") + } + + addresses, err := policy.resolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, stacktrace.Propagatef(err, "cannot resolve notification endpoint hostname") + } + if len(addresses) == 0 { + return nil, stacktrace.NewError("notification endpoint hostname did not resolve") + } + + _, privateHostAllowed := policy.allowedPrivateHosts[host] + for _, address := range addresses { + if isPublicNotificationAddress(address) { + continue + } + if privateHostAllowed && address.Unmap().IsPrivate() { + continue + } + return nil, stacktrace.NewError("notification endpoint hostname resolved to a non-public address") + } + + return addresses, nil +} + +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) { + return policy.dialValidated(ctx, network, address, dialer.DialContext) + } +} + +func (policy *NotificationEndpointPolicy) dialValidated( + ctx context.Context, + network string, + address string, + dial func(context.Context, string, string) (net.Conn, error), +) (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 := dial(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") +} + +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 +} diff --git a/api/pkg/services/notification_endpoint_policy_test.go b/api/pkg/services/notification_endpoint_policy_test.go new file mode 100644 index 00000000..15c49e24 --- /dev/null +++ b/api/pkg/services/notification_endpoint_policy_test.go @@ -0,0 +1,151 @@ +package services + +import ( + "context" + "errors" + "net" + "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://username:password@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) + }) + } +} + +func TestNotificationEndpointPolicyRejectsRebindingBeforeDial(t *testing.T) { + endpoint, err := url.Parse("https://adapter.example.com:9091/notify") + require.NoError(t, err) + + resolver := &rebindingHostResolver{ + addresses: [][]netip.Addr{ + {netip.MustParseAddr("8.8.8.8")}, + {netip.MustParseAddr("127.0.0.1")}, + }, + } + policy := NewNotificationEndpointPolicy(resolver, nil) + + _, err = policy.Validate(context.Background(), endpoint) + require.NoError(t, err) + + dialed := false + _, err = policy.dialValidated( + context.Background(), + "tcp", + "adapter.example.com:9091", + func(_ context.Context, _, _ string) (net.Conn, error) { + dialed = true + return nil, errors.New("should not dial") + }, + ) + + require.Error(t, err) + assert.False(t, dialed) +} + +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) +} + +func TestNotificationEndpointPolicyRejectsNonExactOrLiteralPrivateHosts(t *testing.T) { + tests := []struct { + name string + rawURL string + addresses []netip.Addr + }{ + {name: "allowlist suffix", rawURL: "https://adapter-emulator.example.com:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("172.20.0.8")}}, + {name: "private IP literal", rawURL: "https://172.20.0.8:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("172.20.0.8")}}, + {name: "non allowlisted host", rawURL: "https://other-emulator:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("172.20.0.8")}}, + {name: "allowlisted loopback", rawURL: "https://adapter-emulator:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, + {name: "allowlisted documentation range", rawURL: "https://adapter-emulator:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("203.0.113.1")}}, + } + + 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, + }, + }, []string{"adapter-emulator"}) + + _, err = policy.Validate(context.Background(), endpoint) + + require.Error(t, err) + }) + } +} + +type rebindingHostResolver struct { + addresses [][]netip.Addr + lookups int +} + +func (resolver *rebindingHostResolver) LookupNetIP(_ context.Context, _ string, _ string) ([]netip.Addr, error) { + addresses := resolver.addresses[resolver.lookups] + resolver.lookups++ + return addresses, nil +} From 56313f922a67a7cda56eb48de58ca87750764e56 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:08:19 +0300 Subject: [PATCH 08/22] refactor(api): dispatch gateway notifications Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/services/fcm_client.go | 2 +- api/pkg/services/notification_sender.go | 93 +++++++++++++++ api/pkg/services/notification_sender_test.go | 115 +++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 api/pkg/services/notification_sender.go create mode 100644 api/pkg/services/notification_sender_test.go diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go index 4e56f316..78f5fb40 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -6,7 +6,7 @@ import ( "firebase.google.com/go/messaging" ) -// FCMClient is the interface for sending Firebase Cloud Messaging notifications. +// FCMClient is the low-level Firebase SDK boundary used by FCMNotificationSender. type FCMClient interface { // Send sends a message via FCM and returns the message name on success. Send(ctx context.Context, message *messaging.Message) (string, error) diff --git a/api/pkg/services/notification_sender.go b/api/pkg/services/notification_sender.go new file mode 100644 index 00000000..c7fc25f9 --- /dev/null +++ b/api/pkg/services/notification_sender.go @@ -0,0 +1,93 @@ +package services + +import ( + "context" + "strings" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" +) + +// GatewayNotification is a transport-neutral notification for a phone gateway. +type GatewayNotification struct { + Data map[string]string + Priority string + TTL *time.Duration + NotificationID uuid.UUID +} + +// NotificationSender delivers a notification to a transport-specific destination. +type NotificationSender interface { + Send(ctx context.Context, destination string, notification GatewayNotification) (string, error) +} + +// NotificationDispatcher routes gateway notifications to the phone's configured transport. +type NotificationDispatcher struct { + fcmSender NotificationSender + httpSender NotificationSender +} + +// NewNotificationDispatcher creates a dispatcher for FCM and HTTP notification transports. +func NewNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *NotificationDispatcher { + return &NotificationDispatcher{ + fcmSender: fcmSender, + httpSender: httpSender, + } +} + +// Send delivers a notification using the phone's configured notification transport. +func (dispatcher *NotificationDispatcher) Send( + ctx context.Context, + phone *entities.Phone, + notification GatewayNotification, +) (string, error) { + transport, err := phone.NotificationTransport() + if err != nil { + return "", stacktrace.Propagatef(err, "cannot determine notification transport for phone [%s]", phone.ID) + } + + destination := strings.TrimSpace(*phone.FcmToken) + switch transport { + case entities.NotificationTransportFCM: + return dispatcher.fcmSender.Send(ctx, destination, notification) + case entities.NotificationTransportHTTP: + return dispatcher.httpSender.Send(ctx, destination, notification) + default: + return "", stacktrace.NewErrorf("unsupported notification transport [%s]", transport) + } +} + +// FCMNotificationSender delivers gateway notifications through Firebase Cloud Messaging. +type FCMNotificationSender struct { + client FCMClient +} + +// NewFCMNotificationSender creates a Firebase notification sender. +func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender { + return &FCMNotificationSender{client: client} +} + +// Send delivers a gateway notification through Firebase Cloud Messaging. +func (sender *FCMNotificationSender) Send( + ctx context.Context, + destination string, + notification GatewayNotification, +) (string, error) { + message := &messaging.Message{ + Token: destination, + Data: notification.Data, + Android: &messaging.AndroidConfig{ + Priority: notification.Priority, + TTL: notification.TTL, + }, + } + + result, err := sender.client.Send(ctx, message) + if err != nil { + return "", stacktrace.Propagatef(err, "cannot send Firebase notification") + } + return result, nil +} diff --git a/api/pkg/services/notification_sender_test.go b/api/pkg/services/notification_sender_test.go new file mode 100644 index 00000000..fa0d74a9 --- /dev/null +++ b/api/pkg/services/notification_sender_test.go @@ -0,0 +1,115 @@ +package services + +import ( + "context" + "testing" + "time" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type recordingNotificationSender struct { + destination string + notification GatewayNotification + result string + err error + calls int +} + +func (sender *recordingNotificationSender) Send(_ context.Context, destination string, notification GatewayNotification) (string, error) { + sender.calls++ + sender.destination = destination + sender.notification = notification + return sender.result, sender.err +} + +func TestNotificationDispatcherRoutesFCMToken(t *testing.T) { + token := "fcm-token:value" + phone := &entities.Phone{FcmToken: &token} + fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} + httpSender := &recordingNotificationSender{} + dispatcher := NewNotificationDispatcher(fcmSender, httpSender) + notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + + result, err := dispatcher.Send(context.Background(), phone, notification) + + require.NoError(t, err) + assert.Equal(t, "projects/test/messages/1", result) + assert.Equal(t, 1, fcmSender.calls) + assert.Zero(t, httpSender.calls) + assert.Equal(t, token, fcmSender.destination) +} + +func TestNotificationDispatcherRoutesHTTPSURL(t *testing.T) { + endpoint := "https://adapter.example.com/notifications/gateway-1" + phone := &entities.Phone{FcmToken: &endpoint} + fcmSender := &recordingNotificationSender{} + httpSender := &recordingNotificationSender{result: "accepted"} + dispatcher := NewNotificationDispatcher(fcmSender, httpSender) + notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + + result, err := dispatcher.Send(context.Background(), phone, notification) + + require.NoError(t, err) + assert.Equal(t, "accepted", result) + assert.Zero(t, fcmSender.calls) + assert.Equal(t, 1, httpSender.calls) + assert.Equal(t, endpoint, httpSender.destination) +} + +func TestNotificationDispatcherRejectsInvalidURLLikeTokenWithoutSending(t *testing.T) { + token := "https://" + phone := &entities.Phone{FcmToken: &token} + fcmSender := &recordingNotificationSender{} + httpSender := &recordingNotificationSender{} + dispatcher := NewNotificationDispatcher(fcmSender, httpSender) + + _, err := dispatcher.Send(context.Background(), phone, GatewayNotification{}) + + require.Error(t, err) + assert.Zero(t, fcmSender.calls) + assert.Zero(t, httpSender.calls) +} + +type recordingFCMClient struct { + message *messaging.Message + result string + err error + calls int +} + +func (client *recordingFCMClient) Send(_ context.Context, message *messaging.Message) (string, error) { + client.calls++ + client.message = message + return client.result, client.err +} + +func TestFCMNotificationSenderMapsGatewayNotification(t *testing.T) { + ttl := 5 * time.Minute + notificationID := uuid.New() + data := map[string]string{"KEY_MESSAGE_ID": uuid.NewString()} + client := &recordingFCMClient{result: "projects/test/messages/1"} + sender := NewFCMNotificationSender(client) + + result, err := sender.Send(context.Background(), "fcm-token:value", GatewayNotification{ + Data: data, + Priority: "normal", + TTL: &ttl, + NotificationID: notificationID, + }) + + require.NoError(t, err) + assert.Equal(t, "projects/test/messages/1", result) + require.Equal(t, 1, client.calls) + require.NotNil(t, client.message) + assert.Equal(t, "fcm-token:value", client.message.Token) + assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": data["KEY_MESSAGE_ID"]}, client.message.Data) + require.NotNil(t, client.message.Android) + assert.Equal(t, "normal", client.message.Android.Priority) + assert.Equal(t, &ttl, client.message.Android.TTL) + assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": data["KEY_MESSAGE_ID"]}, data) +} From 6410ac2e26585f0e83a4729496ba597ca4fa889e Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:14:26 +0300 Subject: [PATCH 09/22] feat(api): send notifications to adapters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/services/http_notification_sender.go | 238 +++++++++++++ .../services/http_notification_sender_test.go | 337 ++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 api/pkg/services/http_notification_sender.go create mode 100644 api/pkg/services/http_notification_sender_test.go diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go new file mode 100644 index 00000000..a0d64929 --- /dev/null +++ b/api/pkg/services/http_notification_sender.go @@ -0,0 +1,238 @@ +package services + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/stacktrace" +) + +const maxNotificationResponseDiscardBytes = 4 * 1024 + +type httpNotificationRequest struct { + Message httpNotificationMessage `json:"message"` +} + +type httpNotificationMessage struct { + Token string `json:"token"` + Data map[string]string `json:"data,omitempty"` + Android httpNotificationAndroid `json:"android,omitempty"` +} + +type httpNotificationAndroid struct { + Priority string `json:"priority,omitempty"` + TTL string `json:"ttl,omitempty"` +} + +// HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. +type HTTPNotificationSender struct { + logger telemetry.Logger + tracer telemetry.Tracer + client *http.Client + policy *NotificationEndpointPolicy + attempts uint + timeout time.Duration + retryDelay func(context.Context, time.Duration) error +} + +// NewHTTPNotificationSender creates an SSRF-safe HTTP notification sender. +func NewHTTPNotificationSender( + logger telemetry.Logger, + tracer telemetry.Tracer, + client *http.Client, + policy *NotificationEndpointPolicy, +) *HTTPNotificationSender { + return &HTTPNotificationSender{ + logger: logger, + tracer: tracer, + client: newNotificationHTTPClient(client, policy), + policy: policy, + attempts: 3, + timeout: 5 * time.Second, + retryDelay: func(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + }, + } +} + +// Send delivers a notification to an HTTPS adapter. A successful response only accepts wake-up delivery. +func (sender *HTTPNotificationSender) Send( + ctx context.Context, + destination string, + notification GatewayNotification, +) (string, error) { + endpoint, err := url.Parse(destination) + if err != nil { + return "", sender.notificationError("", "cannot parse notification endpoint") + } + hostname := endpoint.Hostname() + if sender.policy == nil { + return "", sender.notificationError(hostname, "notification endpoint policy is required") + } + if _, err = sender.policy.Validate(ctx, endpoint); err != nil { + return "", sender.notificationError(hostname, "cannot validate notification endpoint") + } + + payload := httpNotificationRequest{ + Message: httpNotificationMessage{ + Token: destination, + Data: notification.Data, + Android: httpNotificationAndroid{ + Priority: notification.Priority, + }, + }, + } + if notification.TTL != nil { + payload.Message.Android.TTL = notification.TTL.String() + } + body, err := json.Marshal(payload) + if err != nil { + return "", sender.notificationError(hostname, "cannot encode notification") + } + + if sender.attempts == 0 { + return "", sender.notificationError(hostname, "notification sender has no attempts configured") + } + + for attempt := uint(1); attempt <= sender.attempts; attempt++ { + requestCtx, cancel := context.WithTimeout(ctx, sender.timeout) + request, requestErr := http.NewRequestWithContext( + requestCtx, + http.MethodPost, + endpoint.String(), + bytes.NewReader(body), + ) + if requestErr == nil { + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-httpSMS-Notification-ID", notification.NotificationID.String()) + requestErr = sender.sendAttempt(request) + } + cancel() + + if requestErr == nil { + return "http/" + notification.NotificationID.String(), nil + } + if ctx.Err() != nil { + return "", sender.notificationError(hostname, "notification request cancelled") + } + if attempt == sender.attempts || !isRetryableNotificationError(requestErr) { + return "", sender.notificationError(hostname, "notification request failed") + } + if sender.retryDelay(ctx, notificationRetryDelay(attempt)) != nil { + return "", sender.notificationError(hostname, "notification retry cancelled") + } + } + + return "", sender.notificationError(hostname, "notification request failed") +} + +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 (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 newNotificationHTTPClient(client *http.Client, policy *NotificationEndpointPolicy) *http.Client { + if client == nil { + client = &http.Client{} + } + + configured := *client + configured.Timeout = 0 + configured.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + transport, ok := configured.Transport.(*http.Transport) + if !ok { + transport = http.DefaultTransport.(*http.Transport) + } + transport = transport.Clone() + transport.Proxy = nil + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } else { + transport.TLSClientConfig = transport.TLSClientConfig.Clone() + } + transport.TLSClientConfig.InsecureSkipVerify = false + if policy != nil { + transport.DialContext = policy.DialContext(&net.Dialer{}) + } + configured.Transport = transport + + return &configured +} + +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 +} + +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) +} + +func isRetryableNotificationError(err error) bool { + _, isRetryableStatus := err.(retryableNotificationStatusError) + return !isTerminalNotificationStatusError(err) && (isRetryableStatus || err != nil) +} + +func isTerminalNotificationStatusError(err error) bool { + _, ok := err.(terminalNotificationStatusError) + return ok +} 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..68c56af3 --- /dev/null +++ b/api/pkg/services/http_notification_sender_test.go @@ -0,0 +1,337 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/netip" + "strings" + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "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) { + notificationID := uuid.New() + ttl := 5 * time.Minute + notification := GatewayNotification{ + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + Priority: "high", + TTL: &ttl, + NotificationID: notificationID, + } + 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")) + assert.Equal(t, notification.NotificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) + + 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, "5m0s", payload.Message.Android.TTL) + + return response(http.StatusNoContent, http.NoBody), nil + })) + + result, err := sender.Send(context.Background(), "https://adapter.example.com/notify", notification) + + require.NoError(t, err) + assert.Equal(t, "http/"+notificationID.String(), 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, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var notificationIDs []string + calls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + notificationIDs = append(notificationIDs, request.Header.Get("X-httpSMS-Notification-ID")) + outcome := test.outcomes[calls] + calls++ + if outcome.err != nil { + return nil, outcome.err + } + return response(outcome.statusCode, http.NoBody), nil + })) + notificationID := uuid.New() + + _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ + NotificationID: notificationID, + }) + + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, test.wantCalls, calls) + assert.Equal(t, makeNotificationIDs(notificationID.String(), test.wantCalls), notificationIDs) + }) + } +} + +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(), "https://adapter.example.com/notify", GatewayNotification{ + NotificationID: uuid.New(), + }) + + require.NoError(t, err) + assert.Equal(t, int64(4096), body.read) + assert.True(t, body.closed) +} + +func TestHTTPNotificationSenderRedactsDestinationSecrets(t *testing.T) { + logger := &httpNotificationRecordingLogger{} + sender := newHTTPNotificationSenderWithLogger(t, logger, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusBadRequest, io.NopCloser(bytes.NewBufferString("customer-secret"))), nil + })) + destination := "https://adapter.example.com/secret/path?token=customer-secret" + + _, err := sender.Send(context.Background(), destination, GatewayNotification{NotificationID: uuid.New()}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "adapter.example.com") + for _, secret := range []string{"secret/path", "customer-secret", destination} { + assert.NotContains(t, err.Error(), secret) + assert.NotContains(t, strings.Join(logger.errors, "\n"), secret) + } +} + +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(), "https://adapter.example.com/notify", GatewayNotification{ + Data: map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"}, + Priority: "high", + NotificationID: uuid.New(), + }) + + require.NoError(t, err) +} + +func TestHTTPNotificationSenderConfiguresSecureHTTPClient(t *testing.T) { + policy := newHTTPNotificationPolicy() + sender := NewHTTPNotificationSender(nil, nil, &http.Client{Timeout: time.Minute}, policy) + + transport, ok := sender.client.Transport.(*http.Transport) + require.True(t, ok) + assert.Zero(t, sender.client.Timeout) + assert.Nil(t, transport.Proxy) + assert.NotNil(t, transport.DialContext) + assert.NotNil(t, sender.client.CheckRedirect) + require.NotNil(t, transport.TLSClientConfig) + assert.False(t, transport.TLSClientConfig.InsecureSkipVerify) +} + +func TestHTTPNotificationSenderReplacesCustomTransportWithSafeTransport(t *testing.T) { + sender := NewHTTPNotificationSender(nil, nil, &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("must not be used") + }), + }, newHTTPNotificationPolicy()) + + _, ok := sender.client.Transport.(*http.Transport) + + assert.True(t, ok) +} + +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 &HTTPNotificationSender{ + logger: logger, + client: &http.Client{Transport: transport}, + policy: newHTTPNotificationPolicy(), + attempts: 3, + timeout: 5 * time.Second, + retryDelay: func(context.Context, time.Duration) error { return nil }, + } +} + +func newHTTPNotificationPolicy() *NotificationEndpointPolicy { + return NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, + }, + }, nil) +} + +func response(statusCode int, body io.ReadCloser) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: body, + Header: make(http.Header), + } +} + +func makeNotificationIDs(notificationID string, length int) []string { + notificationIDs := make([]string, length) + for index := range notificationIDs { + notificationIDs[index] = notificationID + } + return notificationIDs +} From 7c3f1582188089e585183f0cc57648b4a83bbd28 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:17:59 +0300 Subject: [PATCH 10/22] fix(api): harden notification TLS transport Clear TLS dial hooks and ServerName so policy validation and hostname verification cannot be bypassed. Restrict retries to standard 5xx statuses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/services/http_notification_sender.go | 5 ++- .../services/http_notification_sender_test.go | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index a0d64929..e6000dce 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -187,12 +187,15 @@ func newNotificationHTTPClient(client *http.Client, policy *NotificationEndpoint } transport = transport.Clone() transport.Proxy = nil + transport.DialTLS = nil + transport.DialTLSContext = nil if transport.TLSClientConfig == nil { transport.TLSClientConfig = &tls.Config{} } else { transport.TLSClientConfig = transport.TLSClientConfig.Clone() } transport.TLSClientConfig.InsecureSkipVerify = false + transport.TLSClientConfig.ServerName = "" if policy != nil { transport.DialContext = policy.DialContext(&net.Dialer{}) } @@ -204,7 +207,7 @@ func newNotificationHTTPClient(client *http.Client, policy *NotificationEndpoint func isRetryableNotificationStatus(statusCode int) bool { return statusCode == http.StatusRequestTimeout || statusCode == http.StatusTooManyRequests || - statusCode >= http.StatusInternalServerError + (statusCode >= http.StatusInternalServerError && statusCode < 600) } func notificationRetryDelay(attempt uint) time.Duration { diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 68c56af3..26a7535b 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -3,9 +3,11 @@ package services import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "io" + "net" "net/http" "net/netip" "strings" @@ -129,6 +131,12 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { wantCalls: 1, wantErr: true, }, + { + name: "nonstandard 6xx response fails immediately", + outcomes: []roundTripOutcome{{statusCode: 600}}, + wantCalls: 1, + wantErr: true, + }, } for _, test := range tests { @@ -226,6 +234,33 @@ func TestHTTPNotificationSenderConfiguresSecureHTTPClient(t *testing.T) { assert.False(t, transport.TLSClientConfig.InsecureSkipVerify) } +func TestHTTPNotificationSenderClearsCustomTLSDialersAndServerName(t *testing.T) { + sender := NewHTTPNotificationSender(nil, nil, &http.Client{ + Transport: &http.Transport{ + DialTLS: func(string, string) (net.Conn, error) { + return nil, errors.New("unsafe TLS dialer must not be used") + }, + DialTLSContext: func(context.Context, string, string) (net.Conn, error) { + return nil, errors.New("unsafe TLS context dialer must not be used") + }, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: "attacker.example.com", + }, + }, + }, newHTTPNotificationPolicy()) + + transport, ok := sender.client.Transport.(*http.Transport) + + require.True(t, ok) + assert.Nil(t, transport.DialTLS) + assert.Nil(t, transport.DialTLSContext) + require.NotNil(t, transport.DialContext) + require.NotNil(t, transport.TLSClientConfig) + assert.False(t, transport.TLSClientConfig.InsecureSkipVerify) + assert.Empty(t, transport.TLSClientConfig.ServerName) +} + func TestHTTPNotificationSenderReplacesCustomTransportWithSafeTransport(t *testing.T) { sender := NewHTTPNotificationSender(nil, nil, &http.Client{ Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { From aa7549a65502d076945777631a487e8137b42819 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:22:33 +0300 Subject: [PATCH 11/22] feat(api): route phone gateway wake-ups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/entities/phone_notification.go | 2 +- .../services/phone_notification_service.go | 84 ++++-- .../phone_notification_service_test.go | 258 ++++++++++++++++++ 3 files changed, 318 insertions(+), 26 deletions(-) create mode 100644 api/pkg/services/phone_notification_service_test.go 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/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 0d4d8d20..59e02aae 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -4,12 +4,12 @@ import ( "context" "errors" "fmt" + "strings" "time" "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" @@ -26,24 +26,30 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - messagingClient FCMClient - eventDispatcher *EventDispatcher + notificationDispatcher *NotificationDispatcher + eventDispatcher NotificationEventDispatcher +} + +// NotificationEventDispatcher dispatches phone gateway notification events. +type NotificationEventDispatcher interface { + Dispatch(ctx context.Context, event cloudevents.Event) error + DispatchWithTimeout(ctx context.Context, event cloudevents.Event, timeout time.Duration) (string, error) } // NewNotificationService creates a new PhoneNotificationService func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - messagingClient FCMClient, + notificationDispatcher *NotificationDispatcher, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, - dispatcher *EventDispatcher, + dispatcher NotificationEventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, - messagingClient: messagingClient, + notificationDispatcher: notificationDispatcher, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, @@ -77,7 +83,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 +94,28 @@ 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.notificationDispatcher.Send(ctx, phone, GatewayNotification{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - Token: *phone.FcmToken, + Priority: "high", + NotificationID: uuid.New(), }) 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( + redactNotificationToken(err, *phone.FcmToken), + "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 +134,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,32 +151,57 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.messagingClient.Send(ctx, &messaging.Message{ + result, err := service.notificationDispatcher.Send(ctx, phone, GatewayNotification{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, - Android: &messaging.AndroidConfig{ - Priority: "normal", - TTL: &ttl, - }, - Token: *phone.FcmToken, + Priority: "normal", + TTL: &ttl, + NotificationID: params.PhoneNotificationID, }) if err != nil { - ctxLogger.Warn(stacktrace.Propagatef( - err, + transport, transportErr := phone.NotificationTransport() + if transportErr != nil { + ctxLogger.Warn(stacktrace.Propagatef( + redactNotificationToken(transportErr, *phone.FcmToken), + "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) + } - "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", + ctxLogger.Warn(stacktrace.Propagatef( + redactNotificationToken(err, *phone.FcmToken), + "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 redactNotificationToken(err error, token string) error { + token = strings.TrimSpace(token) + if token == "" { + return err + } + return errors.New(strings.ReplaceAll(err.Error(), token, "[redacted]")) +} + // 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..f84607c3 --- /dev/null +++ b/api/pkg/services/phone_notification_service_test.go @@ -0,0 +1,258 @@ +package services + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "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 phoneNotificationEventDispatcher struct { + events []cloudevents.Event +} + +func (dispatcher *phoneNotificationEventDispatcher) Dispatch(_ context.Context, event cloudevents.Event) error { + dispatcher.events = append(dispatcher.events, event) + return nil +} + +func (dispatcher *phoneNotificationEventDispatcher) DispatchWithTimeout( + _ context.Context, + event cloudevents.Event, + _ time.Duration, +) (string, error) { + dispatcher.events = append(dispatcher.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{}) {} + +func TestPhoneNotificationServiceSendUsesHTTPSGatewayNotification(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ + ID: uuid.New(), + UserID: "user-1", + FcmToken: &endpoint, + PhoneNumber: "+18005550199", + MessageExpirationSeconds: 90, + } + httpSender := &recordingNotificationSender{result: "http/notification-1"} + eventDispatcher := &phoneNotificationEventDispatcher{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest(phone, notificationRepository, eventDispatcher, &recordingNotificationSender{}, httpSender) + 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)) + + assert.Equal(t, params.MessageID.String(), httpSender.notification.Data["KEY_MESSAGE_ID"]) + assert.Equal(t, "normal", httpSender.notification.Priority) + require.NotNil(t, httpSender.notification.TTL) + assert.Equal(t, phone.MessageExpirationDuration(), *httpSender.notification.TTL) + assert.Equal(t, params.PhoneNotificationID, httpSender.notification.NotificationID) + require.Len(t, eventDispatcher.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationSent, eventDispatcher.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"} + eventDispatcher := &phoneNotificationEventDispatcher{} + notificationRepository := &phoneNotificationRepository{} + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventDispatcher, + &recordingNotificationSender{}, + &recordingNotificationSender{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, eventDispatcher.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationFailed, eventDispatcher.events[0].Type()) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventDispatcher.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"} + eventDispatcher := &phoneNotificationEventDispatcher{} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + eventDispatcher, + &recordingNotificationSender{err: errors.New("firebase unavailable")}, + &recordingNotificationSender{}, + ) + 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, eventDispatcher.events, 1) + var payload events.MessageNotificationFailedPayload + require.NoError(t, eventDispatcher.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 TestPhoneNotificationServiceSendDoesNotLogNotificationToken(t *testing.T) { + endpoint := "https://adapter.example.com/private-token" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} + logger := &phoneNotificationLogger{} + service := NewNotificationService( + logger, + telemetry.NewOtelLogger("test", logger), + NewNotificationDispatcher( + &recordingNotificationSender{}, + &recordingNotificationSender{err: errors.New("POST " + endpoint + " failed")}, + ), + &phoneNotificationPhoneRepository{phone: phone}, + &phoneNotificationRepository{}, + nil, + &phoneNotificationEventDispatcher{}, + ) + + require.NoError(t, service.Send(context.Background(), &PhoneNotificationSendParams{ + UserID: phone.UserID, + PhoneID: phone.ID, + PhoneNotificationID: uuid.New(), + Source: "test", + MessageID: uuid.New(), + })) + + for _, warning := range logger.warnings { + assert.False(t, strings.Contains(warning, endpoint), "warning exposes notification token: %s", warning) + } +} + +func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSGatewayNotification(t *testing.T) { + endpoint := "https://adapter.example.com/notify" + phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} + httpSender := &recordingNotificationSender{err: errors.New("adapter unavailable")} + service := newPhoneNotificationServiceForTest( + phone, + &phoneNotificationRepository{}, + &phoneNotificationEventDispatcher{}, + &recordingNotificationSender{}, + httpSender, + ) + + err := service.SendHeartbeatFCM(context.Background(), &events.PhoneHeartbeatMissedPayload{ + UserID: phone.UserID, + PhoneID: phone.ID, + MonitorID: uuid.New(), + }) + + require.NoError(t, err) + heartbeatID := httpSender.notification.Data["KEY_HEARTBEAT_ID"] + _, err = time.Parse(time.RFC3339, heartbeatID) + require.NoError(t, err) + assert.Equal(t, "high", httpSender.notification.Priority) + assert.Nil(t, httpSender.notification.TTL) + assert.NotEqual(t, uuid.Nil, httpSender.notification.NotificationID) +} + +func newPhoneNotificationServiceForTest( + phone *entities.Phone, + notificationRepository repositories.PhoneNotificationRepository, + eventDispatcher NotificationEventDispatcher, + fcmSender NotificationSender, + httpSender NotificationSender, +) *PhoneNotificationService { + logger := &phoneNotificationLogger{} + return NewNotificationService( + logger, + telemetry.NewOtelLogger("test", logger), + NewNotificationDispatcher(fcmSender, httpSender), + &phoneNotificationPhoneRepository{phone: phone}, + notificationRepository, + nil, + eventDispatcher, + ) +} From 967da4c73a334777d44d542278e753a6c606dc6b Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:28:43 +0300 Subject: [PATCH 12/22] feat(api): enable URL-backed phone gateways Validate adapter URLs with the same cached endpoint policy used by the secure notification dialer. Ignore private-host allowlists outside local environments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/docs/docs.go | 10 +- api/docs/swagger.json | 10 +- api/docs/swagger.yaml | 17 +- api/pkg/di/container.go | 90 +++++++-- api/pkg/handlers/phone_handler.go | 4 +- api/pkg/requests/phone_fcm_token_request.go | 3 +- api/pkg/requests/phone_update_request.go | 3 +- .../services/phone_notification_service.go | 2 +- api/pkg/validators/phone_handler_validator.go | 48 ++++- .../phone_handler_validator_test.go | 175 ++++++++++++++++++ 10 files changed, 327 insertions(+), 35 deletions(-) create mode 100644 api/pkg/validators/phone_handler_validator_test.go diff --git a/api/docs/docs.go b/api/docs/docs.go index 96cf0e55..5c297f5f 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,8 +4947,9 @@ 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....." + "example": "https://adapter.example.com/notifications" }, "phone_number": { "type": "string", @@ -4975,8 +4976,9 @@ 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....." + "example": "https://adapter.example.com/notifications" }, "max_send_attempts": { "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 4926b5e5..72f6d357 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,8 +4944,9 @@ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + "example": "https://adapter.example.com/notifications" }, "phone_number": { "type": "string", @@ -4972,8 +4973,9 @@ ], "properties": { "fcm_token": { + "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", - "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." + "example": "https://adapter.example.com/notifications" }, "max_send_attempts": { "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 9cfefbf5..d35ac3e5 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -971,7 +971,9 @@ definitions: requests.PhoneFCMToken: properties: fcm_token: - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... + description: FcmToken is either a Firebase registration token or a public + HTTPS adapter callback URL. + example: https://adapter.example.com/notifications type: string phone_number: example: '[+18005550199]' @@ -989,7 +991,9 @@ definitions: requests.PhoneUpsert: properties: fcm_token: - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... + description: FcmToken is either a Firebase registration token or a public + HTTPS adapter callback URL. + example: https://adapter.example.com/notifications type: string max_send_attempts: description: MaxSendAttempts is the number of attempts when sending an SMS @@ -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..ec9bbb9a 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "fmt" "log" + "net" "net/http" "os" "strconv" @@ -84,20 +85,21 @@ import ( // Container is used to resolve services at runtime type Container struct { - projectID string - db *gorm.DB - dedicatedDB *gorm.DB - mongoDB *mongoDriver.Database - version string - app *fiber.App - eventDispatcher *services.EventDispatcher - logger telemetry.Logger - attachmentRepository repositories.AttachmentRepository - contactService *services.ContactService - userRistrettoCache *ristretto.Cache[string, entities.AuthContext] - phoneRistrettoCache *ristretto.Cache[string, *entities.Phone] - contactRistrettoCache *ristretto.Cache[string, services.ContactCacheEntry] - inMemoryCache cache.Cache + projectID string + db *gorm.DB + dedicatedDB *gorm.DB + mongoDB *mongoDriver.Database + version string + app *fiber.App + eventDispatcher *services.EventDispatcher + logger telemetry.Logger + attachmentRepository repositories.AttachmentRepository + contactService *services.ContactService + userRistrettoCache *ristretto.Cache[string, entities.AuthContext] + phoneRistrettoCache *ristretto.Cache[string, *entities.Phone] + contactRistrettoCache *ristretto.Cache[string, services.ContactCacheEntry] + inMemoryCache cache.Cache + notificationEndpointPolicy *services.NotificationEndpointPolicy } // NewLiteContainer creates a Container without any routes or listeners @@ -565,6 +567,63 @@ func (container *Container) FCMClient() services.FCMClient { return services.NewFirebaseFCMClient(messagingClient) } +// NotificationEndpointPolicy creates the shared notification endpoint validation policy. +func (container *Container) NotificationEndpointPolicy() *services.NotificationEndpointPolicy { + if container.notificationEndpointPolicy != nil { + return container.notificationEndpointPolicy + } + + allowedPrivateHosts := []string{} + if isLocal() { + allowedPrivateHosts = splitCommaEnv("NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST", "") + } + container.notificationEndpointPolicy = services.NewNotificationEndpointPolicy( + net.DefaultResolver, + allowedPrivateHosts, + ) + return container.notificationEndpointPolicy +} + +// NotificationHTTPClient creates the SSRF-safe HTTP client for phone notification adapters. +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)), + ), + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// NotificationDispatcher creates notification senders for Firebase and HTTP gateways. +func (container *Container) NotificationDispatcher() *services.NotificationDispatcher { + return services.NewNotificationDispatcher( + services.NewFCMNotificationSender(container.FCMClient()), + services.NewHTTPNotificationSender( + container.Logger(), + container.Tracer(), + container.NotificationHTTPClient(), + container.NotificationEndpointPolicy(), + ), + ) +} + // FirebaseCredentials returns firebase credentials as bytes. func (container *Container) FirebaseCredentials() []byte { container.logger.Debug("creating firebase credentials") @@ -732,6 +791,7 @@ func (container *Container) PhoneHandlerValidator() (validator *validators.Phone container.Logger(), container.Tracer(), container.MessageSendScheduleService(), + container.NotificationEndpointPolicy(), ) } @@ -1715,7 +1775,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi return services.NewNotificationService( container.Logger(), container.Tracer(), - container.FCMClient(), + container.NotificationDispatcher(), container.PhoneRepository(), container.PhoneNotificationRepository(), container.MessageSendScheduleRepository(), 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/requests/phone_fcm_token_request.go b/api/pkg/requests/phone_fcm_token_request.go index dde935b5..a8ad4310 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:"https://adapter.example.com/notifications"` // 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..0c167210 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -25,7 +25,8 @@ 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 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:"https://adapter.example.com/notifications"` 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/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 59e02aae..b2b2e622 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -18,7 +18,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 diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go index e9d4274e..1e262685 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -20,6 +20,7 @@ type PhoneHandlerValidator struct { logger telemetry.Logger tracer telemetry.Tracer scheduleService *services.MessageSendScheduleService + endpointPolicy *services.NotificationEndpointPolicy } // NewPhoneHandlerValidator creates a new handlers.PhoneHandler validator @@ -27,11 +28,13 @@ func NewPhoneHandlerValidator( logger telemetry.Logger, tracer telemetry.Tracer, scheduleService *services.MessageSendScheduleService, + endpointPolicy *services.NotificationEndpointPolicy, ) (v *PhoneHandlerValidator) { return &PhoneHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, scheduleService: scheduleService, + endpointPolicy: endpointPolicy, } } @@ -103,6 +106,11 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user return result } + validator.validateNotificationToken(ctx, 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 { @@ -114,7 +122,7 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user } // ValidateFCMToken validates requests.PhoneFCMToken -func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, request requests.PhoneFCMToken) url.Values { +func (validator *PhoneHandlerValidator) ValidateFCMToken(ctx context.Context, request requests.PhoneFCMToken) url.Values { v := govalidator.New(govalidator.Options{ Data: &request, Rules: govalidator.MapData{ @@ -133,7 +141,43 @@ func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, requ }, }) - return v.ValidateStruct() + result := v.ValidateStruct() + if len(result) > 0 { + return result + } + + validator.validateNotificationToken(ctx, request.FcmToken, result) + return result +} + +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") + } } // 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..5fcfe00c --- /dev/null +++ b/api/pkg/validators/phone_handler_validator_test.go @@ -0,0 +1,175 @@ +package validators + +import ( + "context" + "errors" + "net/netip" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/stretchr/testify/assert" +) + +type phoneValidatorStaticHostResolver struct { + addresses map[string][]netip.Addr + err error +} + +func (resolver *phoneValidatorStaticHostResolver) LookupNetIP( + _ context.Context, + _ string, + host string, +) ([]netip.Addr, error) { + if resolver.err != nil { + return nil, resolver.err + } + return resolver.addresses[host], nil +} + +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) +} + +func TestPhoneHandlerValidatorAcceptsPublicHTTPSNotificationURLOnUpsert(t *testing.T) { + validator := newPhoneHandlerValidatorWithAddresses(map[string][]netip.Addr{ + "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, + }) + + 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 TestPhoneHandlerValidatorRejectsUnsafeNotificationURLs(t *testing.T) { + tests := []struct { + name string + token string + addresses []netip.Addr + }{ + { + name: "insecure HTTP", + token: "http://adapter.example.com/notify", + addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "loopback resolution", + token: "https://adapter.example.com/notify", + addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}, + }, + { + name: "private resolution", + token: "https://adapter.example.com/notify", + addresses: []netip.Addr{netip.MustParseAddr("10.0.0.5")}, + }, + { + name: "mixed public and private resolution", + token: "https://adapter.example.com/notify", + addresses: []netip.Addr{ + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("10.0.0.5"), + }, + }, + { + name: "embedded credentials", + token: "https://user@adapter.example.com/notify", + addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + 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) { + validator := newPhoneHandlerValidatorWithAddresses(map[string][]netip.Addr{ + "adapter.example.com": test.addresses, + }) + + validationErrors := validationPath.validate(validator, test.token) + + assert.NotEmpty(t, validationErrors["fcm_token"]) + }) + } + }) + } +} + +func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationTokenWithoutResolution(t *testing.T) { + logger := &contactValidatorNoopLogger{} + validator := NewPhoneHandlerValidator( + logger, + telemetry.NewOtelLogger("test", logger), + nil, + services.NewNotificationEndpointPolicy(&phoneValidatorStaticHostResolver{ + err: errors.New("resolver must not be called for Firebase tokens"), + }, nil), + ) + + errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ + PhoneNumber: "+18005550199", + FcmToken: "opaque-firebase-registration-token", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + +func newPhoneHandlerValidatorWithAddresses(addresses map[string][]netip.Addr) *PhoneHandlerValidator { + logger := &contactValidatorNoopLogger{} + return NewPhoneHandlerValidator( + logger, + telemetry.NewOtelLogger("test", logger), + nil, + services.NewNotificationEndpointPolicy(&phoneValidatorStaticHostResolver{ + addresses: addresses, + }, nil), + ) +} From 20383407d8fe01bdb4e4c1fd0b0d628cc24b66e0 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:36:17 +0300 Subject: [PATCH 13/22] fix(api): preserve secured telemetry transport Keep trusted HTTP middleware around the endpoint-policy transport so notification delivery retains telemetry and connection hardening. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/di/container_test.go | 36 +++++++++++++++++++ api/pkg/services/http_notification_sender.go | 4 +++ .../services/http_notification_sender_test.go | 17 +++++---- 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 api/pkg/di/container_test.go diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go new file mode 100644 index 00000000..e6982c1d --- /dev/null +++ b/api/pkg/di/container_test.go @@ -0,0 +1,36 @@ +package di + +import ( + "crypto/tls" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotificationDispatcherRetainsTelemetryWrappedSecureHTTPTransport(t *testing.T) { + t.Setenv("ENV", "local") + t.Setenv("FCM_ENDPOINT", "http://localhost") + + dispatcher := NewLiteContainer().NotificationDispatcher() + httpSender := reflect.ValueOf(dispatcher).Elem().FieldByName("httpSender").Elem().Elem() + client := httpSender.FieldByName("client").Elem() + roundTripper := client.FieldByName("Transport").Elem() + + require.Equal(t, "*otelroundtripper.otelRoundTripper", roundTripper.Type().String()) + + parent := roundTripper.Elem().FieldByName("parent").Elem() + require.Equal(t, "*http.Transport", parent.Type().String()) + + transport := parent.Elem() + assert.True(t, transport.FieldByName("Proxy").IsNil()) + assert.False(t, transport.FieldByName("DialContext").IsNil()) + assert.True(t, transport.FieldByName("DialTLS").IsNil()) + assert.True(t, transport.FieldByName("DialTLSContext").IsNil()) + + tlsConfig := transport.FieldByName("TLSClientConfig").Elem() + assert.False(t, tlsConfig.FieldByName("InsecureSkipVerify").Bool()) + assert.Empty(t, tlsConfig.FieldByName("ServerName").String()) + assert.Equal(t, uint64(tls.VersionTLS12), tlsConfig.FieldByName("MinVersion").Uint()) +} diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index e6000dce..4472ccee 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -183,6 +183,10 @@ func newNotificationHTTPClient(client *http.Client, policy *NotificationEndpoint transport, ok := configured.Transport.(*http.Transport) if !ok { + if configured.Transport != nil { + // Preserve middleware around a transport secured before wrapping. + return &configured + } transport = http.DefaultTransport.(*http.Transport) } transport = transport.Clone() diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 26a7535b..5c5f94e4 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -261,16 +261,13 @@ func TestHTTPNotificationSenderClearsCustomTLSDialersAndServerName(t *testing.T) assert.Empty(t, transport.TLSClientConfig.ServerName) } -func TestHTTPNotificationSenderReplacesCustomTransportWithSafeTransport(t *testing.T) { +func TestHTTPNotificationSenderPreservesWrappedTransport(t *testing.T) { + transport := &wrappedNotificationRoundTripper{} sender := NewHTTPNotificationSender(nil, nil, &http.Client{ - Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return nil, errors.New("must not be used") - }), + Transport: transport, }, newHTTPNotificationPolicy()) - _, ok := sender.client.Transport.(*http.Transport) - - assert.True(t, ok) + assert.Same(t, transport, sender.client.Transport) } type roundTripOutcome struct { @@ -284,6 +281,12 @@ type boundedReadCloser struct { closed bool } +type wrappedNotificationRoundTripper struct{} + +func (*wrappedNotificationRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("must not be used") +} + func (reader *boundedReadCloser) Read(buffer []byte) (int, error) { if reader.remaining == 0 { return 0, io.EOF From f1a739cb438cf73faa4624be7875fa0af6ffe0f0 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 2 Sep 2026 23:42:23 +0300 Subject: [PATCH 14/22] fix(api): restrict trusted HTTP transports Only service-created marked transports may preserve middleware. Opaque caller transports are replaced with policy-hardened transports so they cannot bypass SSRF dialing and TLS controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/di/container.go | 23 +++--- api/pkg/di/container_test.go | 7 +- api/pkg/services/http_notification_sender.go | 67 ++++++++++++++-- .../services/http_notification_sender_test.go | 78 +++++++++++++++++-- 4 files changed, 151 insertions(+), 24 deletions(-) diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index ec9bbb9a..b2dce455 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -588,11 +588,6 @@ func (container *Container) NotificationEndpointPolicy() *services.NotificationE 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, @@ -600,10 +595,20 @@ func (container *Container) NotificationHTTPClient() *http.Client { } return &http.Client{ - Transport: otelroundtripper.New( - otelroundtripper.WithName("phone_notification_http"), - otelroundtripper.WithParent(transport), - otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), + Transport: services.NewNotificationHTTPTransport( + policy, + transport, + &net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }, + func(parent http.RoundTripper) http.RoundTripper { + return otelroundtripper.New( + otelroundtripper.WithName("phone_notification_http"), + otelroundtripper.WithParent(parent), + otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), + ) + }, ), CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go index e6982c1d..dcaeb2e3 100644 --- a/api/pkg/di/container_test.go +++ b/api/pkg/di/container_test.go @@ -9,15 +9,18 @@ import ( "github.com/stretchr/testify/require" ) -func TestNotificationDispatcherRetainsTelemetryWrappedSecureHTTPTransport(t *testing.T) { +func TestNotificationDispatcherTrustsServiceCreatedTelemetryWrapperWithSecuredParent(t *testing.T) { t.Setenv("ENV", "local") t.Setenv("FCM_ENDPOINT", "http://localhost") dispatcher := NewLiteContainer().NotificationDispatcher() httpSender := reflect.ValueOf(dispatcher).Elem().FieldByName("httpSender").Elem().Elem() client := httpSender.FieldByName("client").Elem() - roundTripper := client.FieldByName("Transport").Elem() + trustedTransport := client.FieldByName("Transport").Elem() + require.Equal(t, "*services.notificationHTTPTransport", trustedTransport.Type().String()) + + roundTripper := trustedTransport.Elem().FieldByName("roundTripper").Elem() require.Equal(t, "*otelroundtripper.otelRoundTripper", roundTripper.Type().String()) parent := roundTripper.Elem().FieldByName("parent").Elem() diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index 4472ccee..a645f40e 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -17,6 +17,21 @@ import ( const maxNotificationResponseDiscardBytes = 4 * 1024 +type trustedNotificationHTTPTransport interface { + http.RoundTripper + trustedNotificationHTTPTransport() +} + +type notificationHTTPTransport struct { + roundTripper http.RoundTripper +} + +func (transport *notificationHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.roundTripper.RoundTrip(request) +} + +func (*notificationHTTPTransport) trustedNotificationHTTPTransport() {} + type httpNotificationRequest struct { Message httpNotificationMessage `json:"message"` } @@ -70,6 +85,22 @@ func NewHTTPNotificationSender( } } +// NewNotificationHTTPTransport secures a base transport before applying optional middleware. +func NewNotificationHTTPTransport( + policy *NotificationEndpointPolicy, + transport *http.Transport, + dialer *net.Dialer, + wrap func(http.RoundTripper) http.RoundTripper, +) http.RoundTripper { + secured := secureNotificationHTTPTransport(transport, policy, dialer) + var roundTripper http.RoundTripper = secured + if wrap != nil { + roundTripper = wrap(secured) + } + + return ¬ificationHTTPTransport{roundTripper: roundTripper} +} + // Send delivers a notification to an HTTPS adapter. A successful response only accepts wake-up delivery. func (sender *HTTPNotificationSender) Send( ctx context.Context, @@ -181,12 +212,25 @@ func newNotificationHTTPClient(client *http.Client, policy *NotificationEndpoint return http.ErrUseLastResponse } + if _, ok := configured.Transport.(trustedNotificationHTTPTransport); ok { + return &configured + } + transport, ok := configured.Transport.(*http.Transport) if !ok { - if configured.Transport != nil { - // Preserve middleware around a transport secured before wrapping. - return &configured - } + transport = http.DefaultTransport.(*http.Transport) + } + configured.Transport = secureNotificationHTTPTransport(transport, policy, &net.Dialer{}) + + return &configured +} + +func secureNotificationHTTPTransport( + transport *http.Transport, + policy *NotificationEndpointPolicy, + dialer *net.Dialer, +) *http.Transport { + if transport == nil { transport = http.DefaultTransport.(*http.Transport) } transport = transport.Clone() @@ -200,12 +244,21 @@ func newNotificationHTTPClient(client *http.Client, policy *NotificationEndpoint } transport.TLSClientConfig.InsecureSkipVerify = false transport.TLSClientConfig.ServerName = "" + if transport.TLSClientConfig.MinVersion < tls.VersionTLS12 { + transport.TLSClientConfig.MinVersion = tls.VersionTLS12 + } + if transport.TLSClientConfig.MaxVersion != 0 && transport.TLSClientConfig.MaxVersion < tls.VersionTLS12 { + transport.TLSClientConfig.MaxVersion = tls.VersionTLS12 + } if policy != nil { - transport.DialContext = policy.DialContext(&net.Dialer{}) + if dialer == nil { + dialer = &net.Dialer{} + } + configuredDialer := *dialer + transport.DialContext = policy.DialContext(&configuredDialer) } - configured.Transport = transport - return &configured + return transport } func isRetryableNotificationStatus(statusCode int) bool { diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 5c5f94e4..bc467ebe 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -261,13 +261,76 @@ func TestHTTPNotificationSenderClearsCustomTLSDialersAndServerName(t *testing.T) assert.Empty(t, transport.TLSClientConfig.ServerName) } -func TestHTTPNotificationSenderPreservesWrappedTransport(t *testing.T) { - transport := &wrappedNotificationRoundTripper{} +func TestHTTPNotificationSenderRejectsOpaqueTransportAndUsesPolicyDialer(t *testing.T) { + opaque := &wrappedNotificationRoundTripper{} + policy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + "private.example.com": {netip.MustParseAddr("127.0.0.1")}, + }, + }, nil) sender := NewHTTPNotificationSender(nil, nil, &http.Client{ - Transport: transport, - }, newHTTPNotificationPolicy()) + Transport: opaque, + }, policy) + + transport, ok := sender.client.Transport.(*http.Transport) + require.True(t, ok) + + _, err := transport.DialContext(context.Background(), "tcp", "private.example.com:443") + + require.Error(t, err) + assert.Zero(t, opaque.calls) +} + +func TestHTTPNotificationSenderTrustsServiceCreatedWrapperWithSecuredParent(t *testing.T) { + policy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + "private.example.com": {netip.MustParseAddr("127.0.0.1")}, + }, + }, nil) + unsafeDialCalls := 0 + var parent http.RoundTripper + transport := NewNotificationHTTPTransport( + policy, + &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: func(context.Context, string, string) (net.Conn, error) { + unsafeDialCalls++ + return nil, errors.New("unsafe dialer must not be used") + }, + DialTLS: func(string, string) (net.Conn, error) { + return nil, errors.New("unsafe TLS dialer must not be used") + }, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS10, + ServerName: "attacker.example.com", + }, + }, + &net.Dialer{Timeout: time.Second}, + func(securedParent http.RoundTripper) http.RoundTripper { + parent = securedParent + return &wrappedNotificationRoundTripper{} + }, + ) + + sender := NewHTTPNotificationSender(nil, nil, &http.Client{Transport: transport}, policy) assert.Same(t, transport, sender.client.Transport) + securedParent, ok := parent.(*http.Transport) + require.True(t, ok) + assert.Nil(t, securedParent.Proxy) + assert.NotNil(t, securedParent.DialContext) + assert.Nil(t, securedParent.DialTLS) + assert.Nil(t, securedParent.DialTLSContext) + require.NotNil(t, securedParent.TLSClientConfig) + assert.False(t, securedParent.TLSClientConfig.InsecureSkipVerify) + assert.Empty(t, securedParent.TLSClientConfig.ServerName) + assert.Equal(t, uint16(tls.VersionTLS12), securedParent.TLSClientConfig.MinVersion) + + _, err := securedParent.DialContext(context.Background(), "tcp", "private.example.com:443") + + require.Error(t, err) + assert.Zero(t, unsafeDialCalls) } type roundTripOutcome struct { @@ -281,9 +344,12 @@ type boundedReadCloser struct { closed bool } -type wrappedNotificationRoundTripper struct{} +type wrappedNotificationRoundTripper struct { + calls int +} -func (*wrappedNotificationRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { +func (transport *wrappedNotificationRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + transport.calls++ return nil, errors.New("must not be used") } From cb1ea343e09342dc3b0b85df31ad743316811502 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 07:26:51 +0300 Subject: [PATCH 15/22] test(api): cover URL-backed phone gateways Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- .github/workflows/api.yml | 3 + .gitignore | 1 + tests/.env.test | 1 + tests/README.md | 279 +++++----- tests/adapter-emulator/Dockerfile | 12 + tests/adapter-emulator/api_client.go | 155 ++++++ tests/adapter-emulator/control_handler.go | 124 +++++ tests/adapter-emulator/emulator.go | 142 ++++++ tests/adapter-emulator/emulator_test.go | 477 ++++++++++++++++++ tests/adapter-emulator/go.mod | 3 + tests/adapter-emulator/main.go | 85 ++++ .../adapter-emulator/notification_handler.go | 109 ++++ tests/adapter_integration_test.go | 92 ++++ tests/docker-compose.yml | 22 + tests/generate-adapter-certificates.sh | 36 ++ tests/helpers_test.go | 247 +++++++++ 16 files changed, 1656 insertions(+), 132 deletions(-) create mode 100644 tests/adapter-emulator/Dockerfile create mode 100644 tests/adapter-emulator/api_client.go create mode 100644 tests/adapter-emulator/control_handler.go create mode 100644 tests/adapter-emulator/emulator.go create mode 100644 tests/adapter-emulator/emulator_test.go create mode 100644 tests/adapter-emulator/go.mod create mode 100644 tests/adapter-emulator/main.go create mode 100644 tests/adapter-emulator/notification_handler.go create mode 100644 tests/adapter_integration_test.go create mode 100644 tests/generate-adapter-certificates.sh 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 714d2488..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 diff --git a/tests/.env.test b/tests/.env.test index c4c1e3ef..5bfdb6c8 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -8,6 +8,7 @@ EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events EVENTS_QUEUE_USER_API_KEY=system-user-api-key EVENTS_QUEUE_USER_ID=system-user-id FCM_ENDPOINT=http://wiremock:8080 +NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator DATABASE_URL=postgresql://root@cockroachdb:26257/httpsms?sslmode=disable DATABASE_URL_DEDICATED=postgresql://root@cockroachdb:26257/httpsms?sslmode=disable DATABASE_MIGRATION_CONSTRAINT_FIX=1 diff --git a/tests/README.md b/tests/README.md index df91dee2..e2eef517 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,205 +1,226 @@ # 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. Local SSRF policy allows +the exact private hostname `adapter-emulator`. ## 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 and exact private-host allowlist are 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 +`NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator` and +`SSL_CERT_FILE=/adapter-certs/ca.pem` are 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 +228,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..badba9ed --- /dev/null +++ b/tests/adapter-emulator/emulator.go @@ -0,0 +1,142 @@ +package main + +import ( + "net/http" + "sort" + "strings" + "sync" +) + +type gateway struct { + PhoneNumber string + PhoneAPIKey string +} + +type notificationRecord struct { + NotificationID string `json:"notification_id"` + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Attempts int `json:"attempts"` + 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 map[string]*notificationRecord +} + +func newEmulator(apiBaseURL string, client *http.Client) *emulator { + return &emulator{ + apiBaseURL: strings.TrimRight(apiBaseURL, "/"), + client: client, + gateways: make(map[string]gateway), + records: make(map[string]*notificationRecord), + } +} + +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) beginNotification( + notificationID string, + gatewayID string, + data map[string]string, + kind string, + messageID string, +) (*notificationRecord, bool) { + instance.mu.Lock() + defer instance.mu.Unlock() + + if record, ok := instance.records[notificationID]; ok { + record.Attempts++ + if record.Processed || record.Error == "" { + return copyNotificationRecord(record), false + } + record.Error = "" + return copyNotificationRecord(record), true + } + + record := ¬ificationRecord{ + NotificationID: notificationID, + GatewayID: gatewayID, + Data: copyStringMap(data), + MessageID: messageID, + Kind: kind, + Attempts: 1, + } + instance.records[notificationID] = record + + return copyNotificationRecord(record), true +} + +func (instance *emulator) markNotificationProcessed(notificationID string) { + instance.mu.Lock() + defer instance.mu.Unlock() + + if record, ok := instance.records[notificationID]; ok { + record.Processed = true + record.Error = "" + } +} + +func (instance *emulator) markNotificationFailed(notificationID string, err error) { + instance.mu.Lock() + defer instance.mu.Unlock() + + if record, ok := instance.records[notificationID]; ok { + 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)) + } + } + sort.Slice(records, func(left int, right int) bool { + return records[left].NotificationID < records[right].NotificationID + }) + + 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..d1348edf --- /dev/null +++ b/tests/adapter-emulator/emulator_test.go @@ -0,0 +1,477 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" +) + +func TestBeginNotificationDeduplicatesAndCopiesRecords(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + }) + + record, firstDelivery := instance.beginNotification( + "notification-1", + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-1"}, + "message", + "message-1", + ) + if !firstDelivery { + t.Fatal("first delivery was treated as a duplicate") + } + if record.Attempts != 1 { + t.Fatalf("first delivery attempts = %d, want 1", record.Attempts) + } + + instance.markNotificationProcessed("notification-1") + _, firstDelivery = instance.beginNotification( + "notification-1", + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-1"}, + "message", + "message-1", + ) + if firstDelivery { + t.Fatal("duplicate delivery was treated as the first delivery") + } + + records := instance.listGatewayRecords("gateway-1") + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + if records[0].Attempts != 2 { + t.Fatalf("duplicate attempts = %d, want 2", records[0].Attempts) + } + if !records[0].Processed { + t.Fatal("processed state was not retained") + } + + records[0].Data["KEY_MESSAGE_ID"] = "mutated" + records[0].Attempts = 99 + fresh := instance.listGatewayRecords("gateway-1") + if fresh[0].Data["KEY_MESSAGE_ID"] != "message-1" || fresh[0].Attempts != 2 { + t.Fatalf("record list returned mutable state: %#v", fresh[0]) + } +} + +func TestNotificationHandlerProcessesMessageOnce(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"}) + for range 2 { + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(body), + ) + request.Header.Set("X-httpSMS-Notification-ID", "notification-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()) + } + } + + 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 || record.Attempts != 2 { + 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"})), + ) + request.Header.Set("X-httpSMS-Notification-ID", "notification-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"})), + ) + request.Header.Set("X-httpSMS-Notification-ID", "notification-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 TestNotificationHandlerRetriesFailedDeliveryWithSameID(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)) + firstRequest.Header.Set("X-httpSMS-Notification-ID", "notification-1") + 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)) + secondRequest.Header.Set("X-httpSMS-Notification-ID", "notification-1") + 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) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + if records[0].Attempts != 2 || !records[0].Processed || records[0].Error != "" { + t.Fatalf("unexpected retried record: %#v", records[0]) + } +} + +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.beginNotification( + "notification-1", + "gateway-1", + map[string]string{"KEY_MESSAGE_ID": "message-1"}, + "message", + "message-1", + ) + instance.beginNotification( + "notification-2", + "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..e8c74a9b --- /dev/null +++ b/tests/adapter-emulator/notification_handler.go @@ -0,0 +1,109 @@ +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 + } + + notificationID := strings.TrimSpace(request.Header.Get("X-httpSMS-Notification-ID")) + if notificationID == "" { + http.Error(writer, "missing X-httpSMS-Notification-ID", http.StatusBadRequest) + 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) + _, firstDelivery := instance.beginNotification( + notificationID, + gatewayID, + envelope.Message.Data, + kind, + messageID, + ) + log.Printf( + "[ADAPTER] callback notification=%s gateway=%s data=%v should_process=%t", + notificationID, + gatewayID, + envelope.Message.Data, + firstDelivery, + ) + if !firstDelivery { + writer.WriteHeader(http.StatusNoContent) + return + } + if validationErr != nil { + instance.markNotificationFailed(notificationID, 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(notificationID, processingErr) + log.Printf("[ADAPTER] notification %s failed: %v", notificationID, processingErr) + http.Error(writer, "notification processing failed", http.StatusInternalServerError) + return + } + + instance.markNotificationProcessed(notificationID) + log.Printf("[ADAPTER] notification %s processed as %s", notificationID, 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..11907059 --- /dev/null +++ b/tests/adapter_integration_test.go @@ -0,0 +1,92 @@ +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"]) + assert.NotEmpty(t, records[0].NotificationID) +} + +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..45be9ddd 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,23 @@ type testPhone struct { FcmToken string } +type adapterTestPhone struct { + testPhone + PhoneID string + GatewayID string +} + +type notificationRecord struct { + NotificationID string `json:"notification_id"` + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Attempts int `json:"attempts"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` +} + func newAPIClient() *httpsms.Client { return httpsms.New( httpsms.WithBaseURL(apiBaseURL), @@ -119,6 +139,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, From 5e069ac1eb7828b023584c117d87fa02ab687b7a Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 07:53:53 +0300 Subject: [PATCH 16/22] fix(api): harden adapter notifications Enforce transport provenance and per-attempt endpoint checks to close policy bypass and DNS timeout gaps. Redact callback tokens from logs and telemetry. Encode TTLs with protobuf JSON duration syntax. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/di/container.go | 13 +- api/pkg/di/container_test.go | 10 +- api/pkg/entities/phone.go | 4 +- api/pkg/entities/phone_test.go | 11 + api/pkg/handlers/phone_handler.go | 32 +- api/pkg/handlers/phone_handler_log_test.go | 17 + .../http_request_logger_middleware.go | 3 +- .../http_request_logger_middleware_test.go | 59 ++++ api/pkg/services/http_notification_sender.go | 230 +++++++++++--- .../services/http_notification_sender_test.go | 291 +++++++++++++++++- .../services/notification_endpoint_policy.go | 85 ++++- .../notification_endpoint_policy_test.go | 59 ++++ api/pkg/telemetry/gorm_logger.go | 8 + api/pkg/telemetry/gorm_logger_test.go | 24 ++ api/pkg/telemetry/redaction.go | 77 +++++ api/pkg/telemetry/redaction_test.go | 35 +++ 16 files changed, 867 insertions(+), 91 deletions(-) create mode 100644 api/pkg/handlers/phone_handler_log_test.go create mode 100644 api/pkg/middlewares/http_request_logger_middleware_test.go create mode 100644 api/pkg/telemetry/gorm_logger_test.go create mode 100644 api/pkg/telemetry/redaction.go create mode 100644 api/pkg/telemetry/redaction_test.go diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index b2dce455..0550e856 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -274,7 +274,7 @@ func (container *Container) DedicatedDB() (db *gorm.DB) { container.logger.Fatal(err) } - if err = db.Use(tracing.NewPlugin()); err != nil { + if err = db.Use(tracing.NewPlugin(tracing.WithoutQueryVariables())); err != nil { container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } @@ -332,7 +332,7 @@ func (container *Container) DBWithoutMigration() (db *gorm.DB) { } container.db = db - if err = db.Use(tracing.NewPlugin()); err != nil { + if err = db.Use(tracing.NewPlugin(tracing.WithoutQueryVariables())); err != nil { container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } return container.db @@ -357,7 +357,7 @@ func (container *Container) DB() (db *gorm.DB) { } container.db = db - if err = db.Use(tracing.NewPlugin()); err != nil { + if err = db.Use(tracing.NewPlugin(tracing.WithoutQueryVariables())); err != nil { container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } @@ -602,13 +602,6 @@ func (container *Container) NotificationHTTPClient() *http.Client { Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, }, - func(parent http.RoundTripper) http.RoundTripper { - return otelroundtripper.New( - otelroundtripper.WithName("phone_notification_http"), - otelroundtripper.WithParent(parent), - otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), - ) - }, ), CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go index dcaeb2e3..c784d4ed 100644 --- a/api/pkg/di/container_test.go +++ b/api/pkg/di/container_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestNotificationDispatcherTrustsServiceCreatedTelemetryWrapperWithSecuredParent(t *testing.T) { +func TestNotificationDispatcherUsesSecuredTransportAndSafeTelemetry(t *testing.T) { t.Setenv("ENV", "local") t.Setenv("FCM_ENDPOINT", "http://localhost") @@ -20,10 +20,7 @@ func TestNotificationDispatcherTrustsServiceCreatedTelemetryWrapperWithSecuredPa require.Equal(t, "*services.notificationHTTPTransport", trustedTransport.Type().String()) - roundTripper := trustedTransport.Elem().FieldByName("roundTripper").Elem() - require.Equal(t, "*otelroundtripper.otelRoundTripper", roundTripper.Type().String()) - - parent := roundTripper.Elem().FieldByName("parent").Elem() + parent := trustedTransport.Elem().FieldByName("secured") require.Equal(t, "*http.Transport", parent.Type().String()) transport := parent.Elem() @@ -36,4 +33,7 @@ func TestNotificationDispatcherTrustsServiceCreatedTelemetryWrapperWithSecuredPa assert.False(t, tlsConfig.FieldByName("InsecureSkipVerify").Bool()) assert.Empty(t, tlsConfig.FieldByName("ServerName").String()) assert.Equal(t, uint64(tls.VersionTLS12), tlsConfig.FieldByName("MinVersion").Uint()) + + attemptRecorder := httpSender.FieldByName("attemptRecorder").Elem() + assert.Equal(t, "*services.otelNotificationHTTPAttemptRecorder", attemptRecorder.Type().String()) } diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 95c58207..f1fafabb 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -91,7 +91,7 @@ func (phone *Phone) NotificationTransport() (NotificationTransport, error) { endpoint, err := url.Parse(token) if err != nil { - return "", stacktrace.Propagatef(err, "invalid notification URL [%s]", token) + return "", stacktrace.NewError("invalid notification URL") } if !strings.EqualFold(endpoint.Scheme, "https") { @@ -120,7 +120,7 @@ func (phone *Phone) NotificationURL() (*url.URL, error) { endpoint, err := url.Parse(strings.TrimSpace(*phone.FcmToken)) if err != nil { - return nil, stacktrace.Propagatef(err, "cannot parse notification URL") + return nil, stacktrace.NewError("cannot parse notification URL") } return endpoint, nil diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go index 444cff3a..d90bd85c 100644 --- a/api/pkg/entities/phone_test.go +++ b/api/pkg/entities/phone_test.go @@ -68,3 +68,14 @@ func TestPhoneNotificationURLRejectsFCMToken(t *testing.T) { 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 c81ef4dd..60e7e94e 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -114,18 +114,26 @@ func (h *PhoneHandler) Upsert(c fiber.Ctx) error { var request requests.PhoneUpsert if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot unmarshal phone update request into %T", request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateUpsert(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf( + "validation errors [%s], while updating phone request [%s]", + spew.Sdump(errors), + redactedPhoneRequestBody(c.Body()), + )) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } phone, err := h.service.Upsert(ctx, request.ToUpsertParams(h.userFromContext(c), c.OriginalURL(), c.Body())) if err != nil { - ctxLogger.Error(stacktrace.Propagatef(err, "cannot update phones with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef( + err, + "cannot update phone with request [%s]", + redactedPhoneRequestBody(c.Body()), + )) return h.responseInternalServerError(c) } @@ -192,20 +200,32 @@ func (h *PhoneHandler) UpsertFCMToken(c fiber.Ctx) error { var request requests.PhoneFCMToken if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot unmarshal phone token update request into %T", request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateFCMToken(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) + ctxLogger.Warn(stacktrace.NewErrorf( + "validation errors [%s], while updating phone token request [%s]", + spew.Sdump(errors), + redactedPhoneRequestBody(c.Body()), + )) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } phone, err := h.service.UpsertFCMToken(ctx, request.ToPhoneFCMTokenParams(h.userFromContext(c), c.OriginalURL())) if err != nil { - ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete phones with params [%+#v]", request)) + ctxLogger.Error(stacktrace.Propagatef( + err, + "cannot update phone token with request [%s]", + redactedPhoneRequestBody(c.Body()), + )) return h.responseInternalServerError(c) } return h.responseOK(c, "FCM token updated successfully", phone) } + +func redactedPhoneRequestBody(body []byte) string { + return telemetry.RedactJSONFields(body, "fcm_token") +} diff --git a/api/pkg/handlers/phone_handler_log_test.go b/api/pkg/handlers/phone_handler_log_test.go new file mode 100644 index 00000000..2e24f80f --- /dev/null +++ b/api/pkg/handlers/phone_handler_log_test.go @@ -0,0 +1,17 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRedactedPhoneRequestBodyDoesNotExposeFCMToken(t *testing.T) { + body := []byte(`{"phone_number":"+18005550199","fcm_token":"https://adapter.example.com/secret?token=customer-secret"}`) + + redacted := redactedPhoneRequestBody(body) + + assert.Contains(t, redacted, "[redacted]") + assert.NotContains(t, redacted, "adapter.example.com") + assert.NotContains(t, redacted, "customer-secret") +} diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go index e8de42e2..d3470953 100644 --- a/api/pkg/middlewares/http_request_logger_middleware.go +++ b/api/pkg/middlewares/http_request_logger_middleware.go @@ -24,7 +24,8 @@ 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()))) + body := telemetry.RedactJSONFields(c.Request().Body(), "fcm_token") + ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, body)) } return response diff --git a/api/pkg/middlewares/http_request_logger_middleware_test.go b/api/pkg/middlewares/http_request_logger_middleware_test.go new file mode 100644 index 00000000..5ad039a0 --- /dev/null +++ b/api/pkg/middlewares/http_request_logger_middleware_test.go @@ -0,0 +1,59 @@ +package middlewares + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +func TestHTTPRequestLoggerRedactsFCMTokenFromFailedRequestBody(t *testing.T) { + logger := &requestLoggerRecordingLogger{} + app := fiber.New() + app.Use(HTTPRequestLogger(telemetry.NewOtelLogger("test", logger), logger)) + app.Put("/v1/phones", func(c fiber.Ctx) error { + return c.SendStatus(http.StatusUnprocessableEntity) + }) + body := `{"phone_number":"+18005550199","fcm_token":"https://adapter.example.com/secret?token=customer-secret"}` + request := httptest.NewRequest(http.MethodPut, "/v1/phones", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + + response, err := app.Test(request) + + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + logged := strings.Join(logger.warnings, "\n") + assert.Contains(t, logged, "[redacted]") + assert.NotContains(t, logged, "adapter.example.com") + assert.NotContains(t, logged, "customer-secret") +} + +type requestLoggerRecordingLogger struct { + warnings []string +} + +func (logger *requestLoggerRecordingLogger) Error(error) {} +func (logger *requestLoggerRecordingLogger) WithService(string) telemetry.Logger { + return logger +} +func (logger *requestLoggerRecordingLogger) WithString(string, string) telemetry.Logger { + return logger +} +func (logger *requestLoggerRecordingLogger) WithSpan(trace.SpanContext) telemetry.Logger { + return logger +} +func (logger *requestLoggerRecordingLogger) Trace(string) {} +func (logger *requestLoggerRecordingLogger) Info(string) {} +func (logger *requestLoggerRecordingLogger) Warn(err error) { + logger.warnings = append(logger.warnings, err.Error()) +} +func (logger *requestLoggerRecordingLogger) Debug(string) {} +func (logger *requestLoggerRecordingLogger) Fatal(error) {} +func (logger *requestLoggerRecordingLogger) Printf(string, ...interface{}) {} diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index a645f40e..1d51cab3 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -5,33 +5,36 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" + "fmt" "io" "net" "net/http" "net/url" + "strconv" "time" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + "google.golang.org/protobuf/types/known/durationpb" ) const maxNotificationResponseDiscardBytes = 4 * 1024 -type trustedNotificationHTTPTransport interface { - http.RoundTripper - trustedNotificationHTTPTransport() -} - type notificationHTTPTransport struct { - roundTripper http.RoundTripper + secured *http.Transport + policy *NotificationEndpointPolicy } func (transport *notificationHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) { - return transport.roundTripper.RoundTrip(request) + return transport.secured.RoundTrip(request) } -func (*notificationHTTPTransport) trustedNotificationHTTPTransport() {} - type httpNotificationRequest struct { Message httpNotificationMessage `json:"message"` } @@ -49,13 +52,14 @@ type httpNotificationAndroid struct { // HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. type HTTPNotificationSender struct { - logger telemetry.Logger - tracer telemetry.Tracer - client *http.Client - policy *NotificationEndpointPolicy - attempts uint - timeout time.Duration - retryDelay func(context.Context, time.Duration) error + logger telemetry.Logger + tracer telemetry.Tracer + client *http.Client + policy *NotificationEndpointPolicy + attempts uint + timeout time.Duration + retryDelay func(context.Context, time.Duration) error + attemptRecorder notificationHTTPAttemptRecorder } // NewHTTPNotificationSender creates an SSRF-safe HTTP notification sender. @@ -66,12 +70,13 @@ func NewHTTPNotificationSender( policy *NotificationEndpointPolicy, ) *HTTPNotificationSender { return &HTTPNotificationSender{ - logger: logger, - tracer: tracer, - client: newNotificationHTTPClient(client, policy), - policy: policy, - attempts: 3, - timeout: 5 * time.Second, + logger: logger, + tracer: tracer, + client: newNotificationHTTPClient(client, policy), + policy: policy, + attempts: 3, + timeout: 5 * time.Second, + attemptRecorder: newNotificationHTTPAttemptRecorder(tracer), retryDelay: func(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() @@ -85,20 +90,21 @@ func NewHTTPNotificationSender( } } -// NewNotificationHTTPTransport secures a base transport before applying optional middleware. +// NewNotificationHTTPTransport creates a transport that always routes through a policy-secured parent. func NewNotificationHTTPTransport( policy *NotificationEndpointPolicy, transport *http.Transport, dialer *net.Dialer, - wrap func(http.RoundTripper) http.RoundTripper, ) http.RoundTripper { - secured := secureNotificationHTTPTransport(transport, policy, dialer) - var roundTripper http.RoundTripper = secured - if wrap != nil { - roundTripper = wrap(secured) + if policy == nil { + panic("notification endpoint policy is required") } + secured := secureNotificationHTTPTransport(transport, policy, dialer) - return ¬ificationHTTPTransport{roundTripper: roundTripper} + return ¬ificationHTTPTransport{ + secured: secured, + policy: policy, + } } // Send delivers a notification to an HTTPS adapter. A successful response only accepts wake-up delivery. @@ -115,9 +121,6 @@ func (sender *HTTPNotificationSender) Send( if sender.policy == nil { return "", sender.notificationError(hostname, "notification endpoint policy is required") } - if _, err = sender.policy.Validate(ctx, endpoint); err != nil { - return "", sender.notificationError(hostname, "cannot validate notification endpoint") - } payload := httpNotificationRequest{ Message: httpNotificationMessage{ @@ -129,7 +132,7 @@ func (sender *HTTPNotificationSender) Send( }, } if notification.TTL != nil { - payload.Message.Android.TTL = notification.TTL.String() + payload.Message.Android.TTL = formatProtobufDuration(*notification.TTL) } body, err := json.Marshal(payload) if err != nil { @@ -142,17 +145,32 @@ func (sender *HTTPNotificationSender) Send( for attempt := uint(1); attempt <= sender.attempts; attempt++ { requestCtx, cancel := context.WithTimeout(ctx, sender.timeout) - request, requestErr := http.NewRequestWithContext( - requestCtx, - http.MethodPost, - endpoint.String(), - bytes.NewReader(body), - ) + attemptCtx := requestCtx + finishAttempt := func(int, error) {} + if sender.attemptRecorder != nil { + attemptCtx, finishAttempt = sender.attemptRecorder.Start(attemptCtx, attempt) + } + + _, requestErr := sender.policy.Validate(attemptCtx, endpoint) + statusCode := 0 if requestErr == nil { + var request *http.Request + request, requestErr = http.NewRequestWithContext( + attemptCtx, + http.MethodPost, + endpoint.String(), + bytes.NewReader(body), + ) + if requestErr != nil { + finishAttempt(statusCode, requestErr) + cancel() + return "", sender.notificationError(hostname, "cannot create notification request") + } request.Header.Set("Content-Type", "application/json") request.Header.Set("X-httpSMS-Notification-ID", notification.NotificationID.String()) - requestErr = sender.sendAttempt(request) + statusCode, requestErr = sender.sendAttempt(request) } + finishAttempt(statusCode, requestErr) cancel() if requestErr == nil { @@ -172,22 +190,26 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError(hostname, "notification request failed") } -func (sender *HTTPNotificationSender) sendAttempt(request *http.Request) error { +func (sender *HTTPNotificationSender) sendAttempt(request *http.Request) (int, error) { + otel.GetTextMapPropagator().Inject(request.Context(), propagation.HeaderCarrier(request.Header)) + response, err := sender.client.Do(request) if err != nil { - return err + return 0, 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 + return response.StatusCode, nil } if isRetryableNotificationStatus(response.StatusCode) { - return retryableNotificationStatusError{statusCode: response.StatusCode} + err = retryableNotificationStatusError{statusCode: response.StatusCode} + return response.StatusCode, err } - return terminalNotificationStatusError{statusCode: response.StatusCode} + err = terminalNotificationStatusError{statusCode: response.StatusCode} + return response.StatusCode, err } func (sender *HTTPNotificationSender) notificationError(hostname string, message string) error { @@ -212,7 +234,10 @@ func newNotificationHTTPClient(client *http.Client, policy *NotificationEndpoint return http.ErrUseLastResponse } - if _, ok := configured.Transport.(trustedNotificationHTTPTransport); ok { + if trusted, ok := configured.Transport.(*notificationHTTPTransport); ok && + policy != nil && + trusted.policy == policy && + trusted.secured != nil { return &configured } @@ -256,6 +281,10 @@ func secureNotificationHTTPTransport( } configuredDialer := *dialer transport.DialContext = policy.DialContext(&configuredDialer) + } else { + transport.DialContext = func(context.Context, string, string) (net.Conn, error) { + return nil, stacktrace.NewError("notification endpoint policy is required") + } } return transport @@ -288,11 +317,112 @@ func (error terminalNotificationStatusError) Error() string { } func isRetryableNotificationError(err error) bool { - _, isRetryableStatus := err.(retryableNotificationStatusError) - return !isTerminalNotificationStatusError(err) && (isRetryableStatus || err != nil) + if err == nil || isTerminalNotificationStatusError(err) || isNotificationEndpointPolicyViolation(err) { + return false + } + return true } func isTerminalNotificationStatusError(err error) bool { - _, ok := err.(terminalNotificationStatusError) - return ok + var statusError terminalNotificationStatusError + return errors.As(err, &statusError) +} + +func formatProtobufDuration(value time.Duration) string { + duration := durationpb.New(value) + seconds := duration.Seconds + nanoseconds := int64(duration.Nanos) + sign := "" + if seconds < 0 || nanoseconds < 0 { + sign = "-" + seconds = -seconds + nanoseconds = -nanoseconds + } + + result := sign + strconv.FormatInt(seconds, 10) + if nanoseconds == 0 { + return result + "s" + } + + fraction := fmt.Sprintf("%09d", nanoseconds) + switch { + case nanoseconds%1_000_000 == 0: + fraction = fraction[:3] + case nanoseconds%1_000 == 0: + fraction = fraction[:6] + } + + return result + "." + fraction + "s" +} + +type notificationHTTPAttemptRecorder interface { + Start(context.Context, uint) (context.Context, func(int, error)) +} + +type otelNotificationHTTPAttemptRecorder struct { + tracer telemetry.Tracer + attemptCounter metric.Int64Counter + durationSeconds metric.Float64Histogram +} + +func newNotificationHTTPAttemptRecorder(tracer telemetry.Tracer) notificationHTTPAttemptRecorder { + if tracer == nil { + return nil + } + + meter := otel.GetMeterProvider().Meter("github.com/NdoleStudio/httpsms/pkg/services") + attemptCounter, _ := meter.Int64Counter("httpsms.notification.http.attempts") + durationSeconds, _ := meter.Float64Histogram("httpsms.notification.http.attempt.duration") + + return &otelNotificationHTTPAttemptRecorder{ + tracer: tracer, + attemptCounter: attemptCounter, + durationSeconds: durationSeconds, + } +} + +func (recorder *otelNotificationHTTPAttemptRecorder) Start( + ctx context.Context, + attempt uint, +) (context.Context, func(int, error)) { + ctx, span := recorder.tracer.Start(ctx, "phone_notification_http") + span.SetAttributes( + attribute.String("notification.transport", "http"), + attribute.Int("notification.attempt", int(attempt)), + ) + startedAt := time.Now() + + return ctx, func(statusCode int, err error) { + statusClass := notificationHTTPStatusClass(statusCode, err) + attributes := []attribute.KeyValue{ + attribute.String("notification.transport", "http"), + attribute.Int("notification.attempt", int(attempt)), + attribute.String("notification.status_class", statusClass), + } + span.SetAttributes(attributes...) + if err != nil { + span.SetStatus(codes.Error, "notification HTTP attempt failed") + } else { + span.SetStatus(codes.Ok, "") + } + + options := metric.WithAttributes(attributes...) + if recorder.attemptCounter != nil { + recorder.attemptCounter.Add(ctx, 1, options) + } + if recorder.durationSeconds != nil { + recorder.durationSeconds.Record(ctx, time.Since(startedAt).Seconds(), options) + } + span.End() + } +} + +func notificationHTTPStatusClass(statusCode int, err error) string { + if err != nil && statusCode == 0 { + return "transport_error" + } + if statusCode < 100 { + return "unknown" + } + return fmt.Sprintf("%dxx", statusCode/100) } diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index bc467ebe..101ccfa1 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/netip" "strings" + "sync" "testing" "time" @@ -18,6 +19,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/trace" ) @@ -40,7 +43,7 @@ type httpNotificationPayload struct { func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { notificationID := uuid.New() - ttl := 5 * time.Minute + ttl := 10 * time.Minute notification := GatewayNotification{ Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, Priority: "high", @@ -58,7 +61,7 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { 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, "5m0s", payload.Message.Android.TTL) + assert.Equal(t, "600s", payload.Message.Android.TTL) return response(http.StatusNoContent, http.NoBody), nil })) @@ -69,6 +72,26 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { assert.Equal(t, "http/"+notificationID.String(), result) } +func TestFormatProtobufDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + {name: "whole seconds", duration: 10 * time.Minute, expected: "600s"}, + {name: "milliseconds", duration: 1500 * time.Millisecond, expected: "1.500s"}, + {name: "microseconds", duration: time.Second + 234567*time.Microsecond, expected: "1.234567s"}, + {name: "nanoseconds", duration: time.Second + 234567890*time.Nanosecond, expected: "1.234567890s"}, + {name: "negative subsecond", duration: -500 * time.Millisecond, expected: "-0.500s"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, formatProtobufDuration(test.duration)) + }) + } +} + func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { tests := []struct { name string @@ -234,6 +257,131 @@ func TestHTTPNotificationSenderConfiguresSecureHTTPClient(t *testing.T) { assert.False(t, transport.TLSClientConfig.InsecureSkipVerify) } +func TestHTTPNotificationSenderRetriesTransientDNSFailures(t *testing.T) { + resolver := &sequenceHostResolver{outcomes: []hostResolverOutcome{ + {err: errors.New("temporary resolver failure")}, + {addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}, + }} + httpCalls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + httpCalls++ + return response(http.StatusNoContent, http.NoBody), nil + })) + sender.policy = NewNotificationEndpointPolicy(resolver, nil) + + _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ + NotificationID: uuid.New(), + }) + + require.NoError(t, err) + assert.Equal(t, 2, resolver.callCount()) + assert.Equal(t, 1, httpCalls) +} + +func TestHTTPNotificationSenderExhaustsTransientDNSFailures(t *testing.T) { + resolver := &sequenceHostResolver{outcomes: []hostResolverOutcome{ + {err: errors.New("temporary resolver failure 1")}, + {err: errors.New("temporary resolver failure 2")}, + {err: errors.New("temporary resolver failure 3")}, + }} + httpCalls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + httpCalls++ + return response(http.StatusNoContent, http.NoBody), nil + })) + sender.policy = NewNotificationEndpointPolicy(resolver, nil) + + _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ + NotificationID: uuid.New(), + }) + + require.Error(t, err) + assert.Equal(t, 3, resolver.callCount()) + assert.Zero(t, httpCalls) +} + +func TestHTTPNotificationSenderBoundsDNSResolutionByAttemptTimeout(t *testing.T) { + resolver := &blockingHostResolver{} + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + })) + sender.policy = NewNotificationEndpointPolicy(resolver, nil) + sender.timeout = 10 * time.Millisecond + + _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ + NotificationID: uuid.New(), + }) + + require.Error(t, err) + assert.Equal(t, 3, resolver.callCount()) +} + +func TestHTTPNotificationSenderStopsDNSRetriesWhenParentContextIsCancelled(t *testing.T) { + resolver := &blockingHostResolver{} + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + })) + sender.policy = NewNotificationEndpointPolicy(resolver, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := sender.Send(ctx, "https://adapter.example.com/notify", GatewayNotification{ + NotificationID: uuid.New(), + }) + + require.Error(t, err) + assert.Equal(t, 1, resolver.callCount()) +} + +func TestHTTPNotificationSenderDoesNotRetryTerminalEndpointPolicyFailures(t *testing.T) { + tests := []struct { + name string + endpoint string + addresses []netip.Addr + }{ + {name: "insecure scheme", endpoint: "http://adapter.example.com/notify"}, + {name: "embedded user information", endpoint: "https://user@adapter.example.com/notify"}, + {name: "private resolution", endpoint: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver := &countingHostResolver{addresses: test.addresses} + httpCalls := 0 + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + httpCalls++ + return response(http.StatusNoContent, http.NoBody), nil + })) + sender.policy = NewNotificationEndpointPolicy(resolver, nil) + + _, err := sender.Send(context.Background(), test.endpoint, GatewayNotification{ + NotificationID: uuid.New(), + }) + + require.Error(t, err) + assert.LessOrEqual(t, resolver.callCount(), 1) + assert.Zero(t, httpCalls) + }) + } +} + +func TestHTTPNotificationSenderDoesNotRetryDialTimePolicyViolation(t *testing.T) { + resolver := &sequenceHostResolver{outcomes: []hostResolverOutcome{ + {addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}, + {addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, + }} + policy := NewNotificationEndpointPolicy(resolver, nil) + sender := NewHTTPNotificationSender(nil, nil, &http.Client{}, policy) + sender.retryDelay = func(context.Context, time.Duration) error { return nil } + + _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ + NotificationID: uuid.New(), + }) + + require.Error(t, err) + assert.Equal(t, 2, resolver.callCount()) +} + func TestHTTPNotificationSenderClearsCustomTLSDialersAndServerName(t *testing.T) { sender := NewHTTPNotificationSender(nil, nil, &http.Client{ Transport: &http.Transport{ @@ -281,14 +429,13 @@ func TestHTTPNotificationSenderRejectsOpaqueTransportAndUsesPolicyDialer(t *test assert.Zero(t, opaque.calls) } -func TestHTTPNotificationSenderTrustsServiceCreatedWrapperWithSecuredParent(t *testing.T) { +func TestHTTPNotificationSenderTrustsServiceCreatedTransportWithSamePolicy(t *testing.T) { policy := NewNotificationEndpointPolicy(&staticHostResolver{ addresses: map[string][]netip.Addr{ "private.example.com": {netip.MustParseAddr("127.0.0.1")}, }, }, nil) unsafeDialCalls := 0 - var parent http.RoundTripper transport := NewNotificationHTTPTransport( policy, &http.Transport{ @@ -307,17 +454,15 @@ func TestHTTPNotificationSenderTrustsServiceCreatedWrapperWithSecuredParent(t *t }, }, &net.Dialer{Timeout: time.Second}, - func(securedParent http.RoundTripper) http.RoundTripper { - parent = securedParent - return &wrappedNotificationRoundTripper{} - }, ) sender := NewHTTPNotificationSender(nil, nil, &http.Client{Transport: transport}, policy) assert.Same(t, transport, sender.client.Transport) - securedParent, ok := parent.(*http.Transport) + trustedTransport, ok := transport.(*notificationHTTPTransport) require.True(t, ok) + assert.Same(t, policy, trustedTransport.policy) + securedParent := trustedTransport.secured assert.Nil(t, securedParent.Proxy) assert.NotNil(t, securedParent.DialContext) assert.Nil(t, securedParent.DialTLS) @@ -333,6 +478,71 @@ func TestHTTPNotificationSenderTrustsServiceCreatedWrapperWithSecuredParent(t *t assert.Zero(t, unsafeDialCalls) } +func TestNewNotificationHTTPTransportRejectsNilPolicy(t *testing.T) { + assert.Panics(t, func() { + NewNotificationHTTPTransport(nil, &http.Transport{}, &net.Dialer{}) + }) +} + +func TestHTTPNotificationSenderRejectsTransportCreatedForDifferentPolicy(t *testing.T) { + firstPolicy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, + }, + }, nil) + secondPolicy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + "adapter.example.com": {netip.MustParseAddr("1.1.1.1")}, + }, + }, nil) + transport := NewNotificationHTTPTransport(firstPolicy, &http.Transport{}, &net.Dialer{}) + + sender := NewHTTPNotificationSender(nil, nil, &http.Client{Transport: transport}, secondPolicy) + + assert.NotSame(t, transport, sender.client.Transport) + _, ok := sender.client.Transport.(*http.Transport) + assert.True(t, ok) +} + +func TestHTTPNotificationSenderTelemetryDoesNotExportCallbackURL(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + t.Cleanup(func() { + require.NoError(t, provider.Shutdown(context.Background())) + }) + ctx, parent := provider.Tracer("test").Start(context.Background(), "parent") + logger := &httpNotificationRecordingLogger{} + tracer := telemetry.NewOtelLogger("test", logger) + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusNoContent, http.NoBody), nil + })) + sender.attemptRecorder = newNotificationHTTPAttemptRecorder(tracer) + destination := "https://adapter.example.com/secret/path?token=customer-secret" + + _, err := sender.Send(ctx, destination, GatewayNotification{NotificationID: uuid.New()}) + parent.End() + + require.NoError(t, err) + var exported string + for _, span := range spanRecorder.Ended() { + exported += span.Name() + span.Status().Description + for _, attribute := range span.Attributes() { + exported += string(attribute.Key) + attribute.Value.Emit() + } + for _, event := range span.Events() { + exported += event.Name + for _, attribute := range event.Attributes { + exported += string(attribute.Key) + attribute.Value.Emit() + } + } + } + assert.Contains(t, exported, "notification.transporthttp") + assert.Contains(t, exported, "notification.status_class2xx") + for _, secret := range []string{destination, "secret/path", "customer-secret"} { + assert.NotContains(t, exported, secret) + } +} + type roundTripOutcome struct { statusCode int err error @@ -353,6 +563,69 @@ func (transport *wrappedNotificationRoundTripper) RoundTrip(*http.Request) (*htt return nil, errors.New("must not be used") } +type hostResolverOutcome struct { + addresses []netip.Addr + err error +} + +type sequenceHostResolver struct { + mu sync.Mutex + outcomes []hostResolverOutcome + calls int +} + +func (resolver *sequenceHostResolver) LookupNetIP(_ context.Context, _ string, _ string) ([]netip.Addr, error) { + resolver.mu.Lock() + defer resolver.mu.Unlock() + outcome := resolver.outcomes[resolver.calls] + resolver.calls++ + return outcome.addresses, outcome.err +} + +func (resolver *sequenceHostResolver) callCount() int { + resolver.mu.Lock() + defer resolver.mu.Unlock() + return resolver.calls +} + +type blockingHostResolver struct { + mu sync.Mutex + calls int +} + +func (resolver *blockingHostResolver) LookupNetIP(ctx context.Context, _ string, _ string) ([]netip.Addr, error) { + resolver.mu.Lock() + resolver.calls++ + resolver.mu.Unlock() + <-ctx.Done() + return nil, ctx.Err() +} + +func (resolver *blockingHostResolver) callCount() int { + resolver.mu.Lock() + defer resolver.mu.Unlock() + return resolver.calls +} + +type countingHostResolver struct { + mu sync.Mutex + addresses []netip.Addr + calls int +} + +func (resolver *countingHostResolver) LookupNetIP(_ context.Context, _ string, _ string) ([]netip.Addr, error) { + resolver.mu.Lock() + defer resolver.mu.Unlock() + resolver.calls++ + return resolver.addresses, nil +} + +func (resolver *countingHostResolver) callCount() int { + resolver.mu.Lock() + defer resolver.mu.Unlock() + return resolver.calls +} + func (reader *boundedReadCloser) Read(buffer []byte) (int, error) { if reader.remaining == 0 { return 0, io.EOF diff --git a/api/pkg/services/notification_endpoint_policy.go b/api/pkg/services/notification_endpoint_policy.go index deac8cd3..9b36bc68 100644 --- a/api/pkg/services/notification_endpoint_policy.go +++ b/api/pkg/services/notification_endpoint_policy.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "net" "net/netip" "net/url" @@ -28,12 +29,29 @@ var blockedNotificationPrefixes = []netip.Prefix{ netip.MustParsePrefix("::/128"), netip.MustParsePrefix("::1/128"), netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("100:0:0:1::/64"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("2001::/23"), + netip.MustParsePrefix("2001:2::/48"), netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("3fff::/20"), + netip.MustParsePrefix("5f00::/16"), netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("ff00::/8"), } +var globallyReachableNotificationPrefixExceptions = []netip.Prefix{ + netip.MustParsePrefix("2001::/32"), + netip.MustParsePrefix("2001:1::1/128"), + netip.MustParsePrefix("2001:1::2/128"), + netip.MustParsePrefix("2001:1::3/128"), + netip.MustParsePrefix("2001:3::/32"), + netip.MustParsePrefix("2001:4:112::/48"), + netip.MustParsePrefix("2001:20::/28"), + netip.MustParsePrefix("2001:30::/28"), +} + type HostResolver interface { LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) } @@ -56,30 +74,35 @@ func NewNotificationEndpointPolicy(resolver HostResolver, allowedPrivateHosts [] } func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint *url.URL) ([]netip.Addr, error) { + if policy == nil || policy.resolver == nil { + return nil, newNotificationEndpointPolicyViolation("notification endpoint policy is required") + } if endpoint == nil { - return nil, stacktrace.NewError("notification endpoint is required") + return nil, newNotificationEndpointPolicyViolation("notification endpoint is required") } if !strings.EqualFold(endpoint.Scheme, "https") { - return nil, stacktrace.NewError("notification endpoint must use HTTPS") + return nil, newNotificationEndpointPolicyViolation("notification endpoint must use HTTPS") } if endpoint.User != nil { - return nil, stacktrace.NewError("notification endpoint must not contain user information") + return nil, newNotificationEndpointPolicyViolation("notification endpoint must not contain user information") } host := strings.ToLower(endpoint.Hostname()) if host == "" { - return nil, stacktrace.NewError("notification endpoint must contain a hostname") + return nil, newNotificationEndpointPolicyViolation("notification endpoint must contain a hostname") } if literal, err := netip.ParseAddr(host); err == nil && !isPublicNotificationAddress(literal) { - return nil, stacktrace.NewError("notification endpoint must not use a private IP literal") + return nil, newNotificationEndpointPolicyViolation("notification endpoint must not use a non-public IP literal") } addresses, err := policy.resolver.LookupNetIP(ctx, "ip", host) if err != nil { - return nil, stacktrace.Propagatef(err, "cannot resolve notification endpoint hostname") + return nil, newNotificationEndpointResolutionFailure(err) } if len(addresses) == 0 { - return nil, stacktrace.NewError("notification endpoint hostname did not resolve") + return nil, newNotificationEndpointResolutionFailure( + stacktrace.NewError("notification endpoint hostname did not resolve"), + ) } _, privateHostAllowed := policy.allowedPrivateHosts[host] @@ -90,7 +113,9 @@ func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint if privateHostAllowed && address.Unmap().IsPrivate() { continue } - return nil, stacktrace.NewError("notification endpoint hostname resolved to a non-public address") + return nil, newNotificationEndpointPolicyViolation( + "notification endpoint hostname resolved to a non-public address", + ) } return addresses, nil @@ -136,6 +161,11 @@ func isPublicNotificationAddress(address netip.Addr) bool { if !address.IsValid() || !address.IsGlobalUnicast() { return false } + for _, prefix := range globallyReachableNotificationPrefixExceptions { + if prefix.Contains(address) { + return true + } + } for _, prefix := range blockedNotificationPrefixes { if prefix.Contains(address) { return false @@ -143,3 +173,42 @@ func isPublicNotificationAddress(address netip.Addr) bool { } return true } + +type notificationEndpointPolicyViolation struct { + message string +} + +func (violation *notificationEndpointPolicyViolation) Error() string { + return violation.message +} + +func newNotificationEndpointPolicyViolation(message string) error { + return stacktrace.Propagatef( + ¬ificationEndpointPolicyViolation{message: message}, + "notification endpoint policy rejected destination", + ) +} + +func isNotificationEndpointPolicyViolation(err error) bool { + var violation *notificationEndpointPolicyViolation + return errors.As(err, &violation) +} + +type notificationEndpointResolutionFailure struct { + cause error +} + +func (failure *notificationEndpointResolutionFailure) Error() string { + return failure.cause.Error() +} + +func (failure *notificationEndpointResolutionFailure) Unwrap() error { + return failure.cause +} + +func newNotificationEndpointResolutionFailure(cause error) error { + return stacktrace.Propagatef( + ¬ificationEndpointResolutionFailure{cause: cause}, + "cannot resolve notification endpoint hostname", + ) +} diff --git a/api/pkg/services/notification_endpoint_policy_test.go b/api/pkg/services/notification_endpoint_policy_test.go index 15c49e24..4bd7b811 100644 --- a/api/pkg/services/notification_endpoint_policy_test.go +++ b/api/pkg/services/notification_endpoint_policy_test.go @@ -64,6 +64,65 @@ func TestNotificationEndpointPolicyValidate(t *testing.T) { } } +func TestNotificationEndpointPolicyRejectsNonPublicSpecialPurposeIPv6(t *testing.T) { + tests := []string{ + "64:ff9b:1::1", + "100:0:0:1::1", + "2001:2::1", + "2001:5::1", + "2001:10::1", + "3fff::1", + "5f00::1", + } + + for _, address := range tests { + t.Run(address, func(t *testing.T) { + endpoint, err := url.Parse("https://adapter.example.com/notify") + require.NoError(t, err) + policy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + endpoint.Hostname(): {netip.MustParseAddr(address)}, + }, + }, nil) + + _, err = policy.Validate(context.Background(), endpoint) + + require.Error(t, err) + }) + } +} + +func TestNotificationEndpointPolicyAllowsGloballyReachableSpecialPurposeIPv6(t *testing.T) { + tests := []string{ + "64:ff9b::0808:0808", + "2001::1", + "2001:1::1", + "2001:1::2", + "2001:1::3", + "2001:3::1", + "2001:4:112::1", + "2001:20::1", + "2001:30::1", + } + + for _, address := range tests { + t.Run(address, func(t *testing.T) { + endpoint, err := url.Parse("https://adapter.example.com/notify") + require.NoError(t, err) + policy := NewNotificationEndpointPolicy(&staticHostResolver{ + addresses: map[string][]netip.Addr{ + endpoint.Hostname(): {netip.MustParseAddr(address)}, + }, + }, nil) + + addresses, err := policy.Validate(context.Background(), endpoint) + + require.NoError(t, err) + assert.Equal(t, []netip.Addr{netip.MustParseAddr(address)}, addresses) + }) + } +} + func TestNotificationEndpointPolicyRejectsRebindingBeforeDial(t *testing.T) { endpoint, err := url.Parse("https://adapter.example.com:9091/notify") require.NoError(t, err) diff --git a/api/pkg/telemetry/gorm_logger.go b/api/pkg/telemetry/gorm_logger.go index 7f9d9517..7da1c34e 100644 --- a/api/pkg/telemetry/gorm_logger.go +++ b/api/pkg/telemetry/gorm_logger.go @@ -6,6 +6,7 @@ import ( "time" "github.com/NdoleStudio/stacktrace" + "gorm.io/gorm" "gorm.io/gorm/logger" ) @@ -14,6 +15,8 @@ type gormLogger struct { logger Logger } +var _ gorm.ParamsFilter = (*gormLogger)(nil) + // NewGormLogger creates a new instance of gormLogger func NewGormLogger(tracer Tracer, logger Logger) logger.Interface { return &gormLogger{ @@ -39,6 +42,11 @@ func (gorm *gormLogger) Error(ctx context.Context, s string, i ...any) { gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Error(fmt.Errorf(s, i...)) } +// ParamsFilter keeps SQL telemetry parameterized so bound values never enter logs. +func (gorm *gormLogger) ParamsFilter(_ context.Context, sql string, _ ...any) (string, []any) { + return sql, nil +} + func (gorm *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { elapsed := time.Since(begin) l := gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).WithString("latency", elapsed.String()) diff --git a/api/pkg/telemetry/gorm_logger_test.go b/api/pkg/telemetry/gorm_logger_test.go new file mode 100644 index 00000000..3776afb8 --- /dev/null +++ b/api/pkg/telemetry/gorm_logger_test.go @@ -0,0 +1,24 @@ +package telemetry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGormLoggerUsesParameterizedQueries(t *testing.T) { + logger := &gormLogger{} + secret := "https://adapter.example.com/secret?token=customer-secret" + + query, params := logger.ParamsFilter( + context.Background(), + `UPDATE "phones" SET "fcm_token"=$1 WHERE "id"=$2`, + secret, + "phone-id", + ) + + assert.Equal(t, `UPDATE "phones" SET "fcm_token"=$1 WHERE "id"=$2`, query) + assert.Empty(t, params) + assert.NotContains(t, query, secret) +} diff --git a/api/pkg/telemetry/redaction.go b/api/pkg/telemetry/redaction.go new file mode 100644 index 00000000..c74a18ac --- /dev/null +++ b/api/pkg/telemetry/redaction.go @@ -0,0 +1,77 @@ +package telemetry + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" +) + +const ( + redactedLogValue = "[redacted]" + omittedRequestBodyValue = "[request body omitted]" +) + +// RedactJSONFields returns a log-safe JSON body with matching field values removed. +func RedactJSONFields(body []byte, fields ...string) string { + if len(body) == 0 { + return "" + } + + var value any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return omittedRequestBodyValue + } + if err := ensureJSONEnd(decoder); err != nil { + return omittedRequestBodyValue + } + + sensitiveFields := make(map[string]struct{}, len(fields)) + for _, field := range fields { + sensitiveFields[strings.ToLower(field)] = struct{}{} + } + if len(sensitiveFields) > 0 { + if _, ok := value.(map[string]any); !ok { + return omittedRequestBodyValue + } + } + redactJSONValue(value, sensitiveFields) + + redacted, err := json.Marshal(value) + if err != nil { + return omittedRequestBodyValue + } + return string(redacted) +} + +func ensureJSONEnd(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if err == io.EOF { + return nil + } + if err == nil { + return errors.New("request body contains multiple JSON values") + } + return err +} + +func redactJSONValue(value any, sensitiveFields map[string]struct{}) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if _, ok := sensitiveFields[strings.ToLower(key)]; ok { + typed[key] = redactedLogValue + continue + } + redactJSONValue(child, sensitiveFields) + } + case []any: + for _, child := range typed { + redactJSONValue(child, sensitiveFields) + } + } +} diff --git a/api/pkg/telemetry/redaction_test.go b/api/pkg/telemetry/redaction_test.go new file mode 100644 index 00000000..2a40a593 --- /dev/null +++ b/api/pkg/telemetry/redaction_test.go @@ -0,0 +1,35 @@ +package telemetry + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRedactJSONFieldsRemovesSensitiveValues(t *testing.T) { + body := []byte(`{"phone_number":"+18005550199","fcm_token":"https://adapter.example.com/secret?token=customer-secret","nested":{"fcm_token":"nested-secret"}}`) + + redacted := RedactJSONFields(body, "fcm_token") + + assert.Contains(t, redacted, `"phone_number":"+18005550199"`) + assert.Equal(t, 2, strings.Count(redacted, "[redacted]")) + assert.NotContains(t, redacted, "adapter.example.com") + assert.NotContains(t, redacted, "customer-secret") + assert.NotContains(t, redacted, "nested-secret") +} + +func TestRedactJSONFieldsFailsClosedForMalformedSensitiveJSON(t *testing.T) { + body := []byte(`{"fcm_token":"customer-secret"`) + + redacted := RedactJSONFields(body, "fcm_token") + + assert.Equal(t, "[request body omitted]", redacted) + assert.NotContains(t, redacted, "customer-secret") +} + +func TestRedactJSONFieldsFailsClosedForMalformedNonSensitiveBody(t *testing.T) { + body := []byte(`not-json`) + + assert.Equal(t, "[request body omitted]", RedactJSONFields(body, "fcm_token")) +} From 1afd083387d42272bc61f55d5954d7988a20ac3f Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 08:33:13 +0300 Subject: [PATCH 17/22] fix(api): relax adapter URL validation Allow standard URL user information while retaining HTTPS and SSRF checks. Construct endpoint policies on demand and share one only within each HTTP sender graph so secured transport identity remains intact. Keep FCM-token examples opaque to preserve the existing API guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/docs/docs.go | 4 +- api/docs/swagger.json | 4 +- api/docs/swagger.yaml | 4 +- api/pkg/di/container.go | 48 +++++++++---------- api/pkg/di/container_test.go | 7 +++ api/pkg/entities/phone.go | 3 -- api/pkg/entities/phone_test.go | 8 +++- api/pkg/requests/phone_fcm_token_request.go | 2 +- api/pkg/requests/phone_update_request.go | 2 +- .../services/http_notification_sender_test.go | 19 +++++++- .../services/notification_endpoint_policy.go | 3 -- .../notification_endpoint_policy_test.go | 4 +- .../phone_handler_validator_test.go | 19 ++++++-- ...2-url-backed-phone-notification-adapter.md | 11 ++--- ...acked-phone-notification-adapter-design.md | 12 ++--- 15 files changed, 88 insertions(+), 62 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 5c297f5f..eb72ac76 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -4949,7 +4949,7 @@ const docTemplate = `{ "fcm_token": { "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", - "example": "https://adapter.example.com/notifications" + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, "phone_number": { "type": "string", @@ -4978,7 +4978,7 @@ const docTemplate = `{ "fcm_token": { "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", - "example": "https://adapter.example.com/notifications" + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, "max_send_attempts": { "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 72f6d357..59c5637b 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -4946,7 +4946,7 @@ "fcm_token": { "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", - "example": "https://adapter.example.com/notifications" + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, "phone_number": { "type": "string", @@ -4975,7 +4975,7 @@ "fcm_token": { "description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.", "type": "string", - "example": "https://adapter.example.com/notifications" + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." }, "max_send_attempts": { "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.", diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index d35ac3e5..6dc24021 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -973,7 +973,7 @@ definitions: fcm_token: description: FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. - example: https://adapter.example.com/notifications + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string phone_number: example: '[+18005550199]' @@ -993,7 +993,7 @@ definitions: fcm_token: description: FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL. - example: https://adapter.example.com/notifications + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd..... type: string max_send_attempts: description: MaxSendAttempts is the number of attempts when sending an SMS diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 0550e856..8f4f5278 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -85,21 +85,20 @@ import ( // Container is used to resolve services at runtime type Container struct { - projectID string - db *gorm.DB - dedicatedDB *gorm.DB - mongoDB *mongoDriver.Database - version string - app *fiber.App - eventDispatcher *services.EventDispatcher - logger telemetry.Logger - attachmentRepository repositories.AttachmentRepository - contactService *services.ContactService - userRistrettoCache *ristretto.Cache[string, entities.AuthContext] - phoneRistrettoCache *ristretto.Cache[string, *entities.Phone] - contactRistrettoCache *ristretto.Cache[string, services.ContactCacheEntry] - inMemoryCache cache.Cache - notificationEndpointPolicy *services.NotificationEndpointPolicy + projectID string + db *gorm.DB + dedicatedDB *gorm.DB + mongoDB *mongoDriver.Database + version string + app *fiber.App + eventDispatcher *services.EventDispatcher + logger telemetry.Logger + attachmentRepository repositories.AttachmentRepository + contactService *services.ContactService + userRistrettoCache *ristretto.Cache[string, entities.AuthContext] + phoneRistrettoCache *ristretto.Cache[string, *entities.Phone] + contactRistrettoCache *ristretto.Cache[string, services.ContactCacheEntry] + inMemoryCache cache.Cache } // NewLiteContainer creates a Container without any routes or listeners @@ -567,26 +566,24 @@ func (container *Container) FCMClient() services.FCMClient { return services.NewFirebaseFCMClient(messagingClient) } -// NotificationEndpointPolicy creates the shared notification endpoint validation policy. +// NotificationEndpointPolicy creates a notification endpoint validation policy. func (container *Container) NotificationEndpointPolicy() *services.NotificationEndpointPolicy { - if container.notificationEndpointPolicy != nil { - return container.notificationEndpointPolicy - } - allowedPrivateHosts := []string{} if isLocal() { allowedPrivateHosts = splitCommaEnv("NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST", "") } - container.notificationEndpointPolicy = services.NewNotificationEndpointPolicy( + return services.NewNotificationEndpointPolicy( net.DefaultResolver, allowedPrivateHosts, ) - return container.notificationEndpointPolicy } // NotificationHTTPClient creates the SSRF-safe HTTP client for phone notification adapters. func (container *Container) NotificationHTTPClient() *http.Client { - policy := container.NotificationEndpointPolicy() + return container.notificationHTTPClient(container.NotificationEndpointPolicy()) +} + +func (container *Container) notificationHTTPClient(policy *services.NotificationEndpointPolicy) *http.Client { transport := &http.Transport{ ForceAttemptHTTP2: true, TLSClientConfig: &tls.Config{ @@ -611,13 +608,14 @@ func (container *Container) NotificationHTTPClient() *http.Client { // NotificationDispatcher creates notification senders for Firebase and HTTP gateways. func (container *Container) NotificationDispatcher() *services.NotificationDispatcher { + policy := container.NotificationEndpointPolicy() return services.NewNotificationDispatcher( services.NewFCMNotificationSender(container.FCMClient()), services.NewHTTPNotificationSender( container.Logger(), container.Tracer(), - container.NotificationHTTPClient(), - container.NotificationEndpointPolicy(), + container.notificationHTTPClient(policy), + policy, ), ) } diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go index c784d4ed..a7d410ad 100644 --- a/api/pkg/di/container_test.go +++ b/api/pkg/di/container_test.go @@ -9,6 +9,13 @@ import ( "github.com/stretchr/testify/require" ) +func TestNotificationEndpointPolicyCreatesNewInstances(t *testing.T) { + t.Setenv("ENV", "local") + container := NewLiteContainer() + + assert.NotSame(t, container.NotificationEndpointPolicy(), container.NotificationEndpointPolicy()) +} + func TestNotificationDispatcherUsesSecuredTransportAndSafeTelemetry(t *testing.T) { t.Setenv("ENV", "local") t.Setenv("FCM_ENDPOINT", "http://localhost") diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index f1fafabb..03dfd3a6 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -100,9 +100,6 @@ func (phone *Phone) NotificationTransport() (NotificationTransport, error) { if endpoint.Hostname() == "" { return "", stacktrace.NewErrorf("notification URL must include a hostname") } - if endpoint.User != nil { - return "", stacktrace.NewErrorf("notification URL must not contain user information") - } return NotificationTransportHTTP, nil } diff --git a/api/pkg/entities/phone_test.go b/api/pkg/entities/phone_test.go index d90bd85c..886d31f6 100644 --- a/api/pkg/entities/phone_test.go +++ b/api/pkg/entities/phone_test.go @@ -29,7 +29,7 @@ func TestPhoneNotificationTransport(t *testing.T) { {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 credentials", token: stringPointer("https://user@adapter.example.com/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}, } @@ -50,12 +50,16 @@ func TestPhoneNotificationTransport(t *testing.T) { } func TestPhoneNotificationURL(t *testing.T) { - phone := &Phone{FcmToken: stringPointer("https://adapter.example.com/notify?tenant=42")} + 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) diff --git a/api/pkg/requests/phone_fcm_token_request.go b/api/pkg/requests/phone_fcm_token_request.go index a8ad4310..bdd3ab1a 100644 --- a/api/pkg/requests/phone_fcm_token_request.go +++ b/api/pkg/requests/phone_fcm_token_request.go @@ -14,7 +14,7 @@ type PhoneFCMToken struct { request PhoneNumber string `json:"phone_number" example:"[+18005550199]"` // 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"` + 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 0c167210..cedd51a0 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -26,7 +26,7 @@ type PhoneUpsert struct { 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:"https://adapter.example.com/notifications"` + 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/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 101ccfa1..de3804a1 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -257,6 +257,24 @@ func TestHTTPNotificationSenderConfiguresSecureHTTPClient(t *testing.T) { assert.False(t, transport.TLSClientConfig.InsecureSkipVerify) } +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 + })) + + _, err := sender.Send( + context.Background(), + "https://adapter-user:adapter-password@adapter.example.com/notify", + GatewayNotification{NotificationID: uuid.New()}, + ) + + require.NoError(t, err) +} + func TestHTTPNotificationSenderRetriesTransientDNSFailures(t *testing.T) { resolver := &sequenceHostResolver{outcomes: []hostResolverOutcome{ {err: errors.New("temporary resolver failure")}, @@ -340,7 +358,6 @@ func TestHTTPNotificationSenderDoesNotRetryTerminalEndpointPolicyFailures(t *tes addresses []netip.Addr }{ {name: "insecure scheme", endpoint: "http://adapter.example.com/notify"}, - {name: "embedded user information", endpoint: "https://user@adapter.example.com/notify"}, {name: "private resolution", endpoint: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, } diff --git a/api/pkg/services/notification_endpoint_policy.go b/api/pkg/services/notification_endpoint_policy.go index 9b36bc68..63641565 100644 --- a/api/pkg/services/notification_endpoint_policy.go +++ b/api/pkg/services/notification_endpoint_policy.go @@ -83,9 +83,6 @@ func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint if !strings.EqualFold(endpoint.Scheme, "https") { return nil, newNotificationEndpointPolicyViolation("notification endpoint must use HTTPS") } - if endpoint.User != nil { - return nil, newNotificationEndpointPolicyViolation("notification endpoint must not contain user information") - } host := strings.ToLower(endpoint.Hostname()) if host == "" { diff --git a/api/pkg/services/notification_endpoint_policy_test.go b/api/pkg/services/notification_endpoint_policy_test.go index 4bd7b811..b525675d 100644 --- a/api/pkg/services/notification_endpoint_policy_test.go +++ b/api/pkg/services/notification_endpoint_policy_test.go @@ -54,7 +54,9 @@ func TestNotificationEndpointPolicyValidate(t *testing.T) { addresses, err := policy.Validate(context.Background(), endpoint) - if test.hasError { + // URL user information does not affect endpoint network safety. + hasError := test.hasError && endpoint.User == nil + if hasError { require.Error(t, err) return } diff --git a/api/pkg/validators/phone_handler_validator_test.go b/api/pkg/validators/phone_handler_validator_test.go index 5fcfe00c..0dd5c2e1 100644 --- a/api/pkg/validators/phone_handler_validator_test.go +++ b/api/pkg/validators/phone_handler_validator_test.go @@ -87,11 +87,6 @@ func TestPhoneHandlerValidatorRejectsUnsafeNotificationURLs(t *testing.T) { netip.MustParseAddr("10.0.0.5"), }, }, - { - name: "embedded credentials", - token: "https://user@adapter.example.com/notify", - addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, - }, { name: "malformed HTTPS", token: "https://%", @@ -162,6 +157,20 @@ func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationTokenWithoutResol assert.Empty(t, errors) } +func TestPhoneHandlerValidatorAcceptsNotificationURLWithUserInformation(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-user:adapter-password@adapter.example.com/notify", + SIM: entities.SIM1.String(), + }) + + assert.Empty(t, errors) +} + func newPhoneHandlerValidatorWithAddresses(addresses map[string][]netip.Addr) *PhoneHandlerValidator { logger := &contactValidatorNoopLogger{} return NewPhoneHandlerValidator( 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 index ad59830b..3cfb59b2 100644 --- 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 @@ -17,6 +17,7 @@ - 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. @@ -224,10 +225,6 @@ func (phone *Phone) NotificationTransport() (NotificationTransport, error) { if endpoint.Hostname() == "" { return "", fmt.Errorf("notification URL must include a hostname") } - if endpoint.User != nil { - return "", fmt.Errorf("notification URL must not contain user information") - } - return NotificationTransportHTTP, nil } @@ -460,8 +457,8 @@ func isPublicNotificationAddress(address netip.Addr) bool { } ``` -`Validate` must verify HTTPS, hostname presence, absent user information, at -least one DNS result, and every resolved address passing +`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 @@ -1236,7 +1233,7 @@ func TestPhoneHandlerValidatorAcceptsPublicHTTPSNotificationURL(t *testing.T) { ``` Add rejection tests for `http://`, loopback resolution, private resolution, -mixed public/private resolution, embedded credentials, and malformed HTTPS. +mixed public/private resolution, and malformed HTTPS. Add an opaque FCM token test to prove the resolver is not required for Firebase tokens. 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 index a127d213..9fb888f2 100644 --- 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 @@ -89,8 +89,8 @@ 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, has no - hostname, or contains embedded user information. +- **Invalid:** the value is URL-like but malformed, uses another scheme, or has + no hostname. Use these entity-level types and methods: @@ -312,7 +312,6 @@ Accepted destinations must: - use `https`; - include a DNS hostname or public IP; -- omit URL user information; - resolve only to public, globally routable IP addresses. Reject destinations resolving to loopback, private, link-local, multicast, @@ -342,8 +341,8 @@ The HTTP client: The endpoint policy accepts an optional exact-host private-destination allowlist. The DI container passes configured values only when `ENV=local`; production ignores the setting. Allowlisting a hostname permits its private -DNS answers but does not permit HTTP, embedded credentials, redirects, proxy -use, or a different hostname. Unit tests use injected resolvers and dialers. +DNS answers but does not permit HTTP, redirects, proxy use, or a different +hostname. Unit tests use injected resolvers and dialers. The Docker integration stack allowlists only `adapter-emulator`. ### 9. Validation and API compatibility @@ -432,8 +431,7 @@ Cover: - ordinary FCM tokens selecting Firebase; - valid public HTTPS URLs selecting HTTP; - empty and nil tokens; -- `http`, `ftp`, URL user information, missing host, and malformed URLs being - invalid; +- `http`, `ftp`, missing host, and malformed URLs being invalid; - URL-like invalid values never falling through to Firebase. ### Endpoint policy tests From 2742f55094366f51f2e6f44585c39ed34220bb8e Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 09:06:54 +0300 Subject: [PATCH 18/22] refactor(api): simplify adapter delivery Use the existing OpenTelemetry HTTP transport and retry-go delivery pattern. Remove endpoint network policy and custom dialing while preserving callback URL redaction in telemetry. Reuse EventDispatcher directly and clarify the phone transport dispatcher name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/di/container.go | 51 +-- api/pkg/di/container_test.go | 123 +++++- api/pkg/di/notification_http_round_tripper.go | 62 +++ api/pkg/entities/phone.go | 2 +- api/pkg/services/http_notification_sender.go | 211 +++------ .../services/http_notification_sender_test.go | 404 ++++-------------- .../services/notification_endpoint_policy.go | 211 --------- .../notification_endpoint_policy_test.go | 212 --------- api/pkg/services/notification_sender.go | 15 +- api/pkg/services/notification_sender_test.go | 12 +- .../services/phone_notification_service.go | 22 +- .../phone_notification_service_test.go | 62 +-- api/pkg/validators/phone_handler_validator.go | 25 +- .../phone_handler_validator_test.go | 122 ++---- ...2-url-backed-phone-notification-adapter.md | 54 ++- ...acked-phone-notification-adapter-design.md | 125 ++---- tests/.env.test | 1 - tests/README.md | 11 +- 18 files changed, 481 insertions(+), 1244 deletions(-) create mode 100644 api/pkg/di/notification_http_round_tripper.go delete mode 100644 api/pkg/services/notification_endpoint_policy.go delete mode 100644 api/pkg/services/notification_endpoint_policy_test.go diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 8f4f5278..736052b4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "fmt" "log" - "net" "net/http" "os" "strconv" @@ -566,56 +565,21 @@ func (container *Container) FCMClient() services.FCMClient { return services.NewFirebaseFCMClient(messagingClient) } -// NotificationEndpointPolicy creates a notification endpoint validation policy. -func (container *Container) NotificationEndpointPolicy() *services.NotificationEndpointPolicy { - allowedPrivateHosts := []string{} - if isLocal() { - allowedPrivateHosts = splitCommaEnv("NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST", "") - } - return services.NewNotificationEndpointPolicy( - net.DefaultResolver, - allowedPrivateHosts, - ) -} - -// NotificationHTTPClient creates the SSRF-safe HTTP client for phone notification adapters. +// NotificationHTTPClient creates the OpenTelemetry-instrumented client for phone notification adapters. func (container *Container) NotificationHTTPClient() *http.Client { - return container.notificationHTTPClient(container.NotificationEndpointPolicy()) -} - -func (container *Container) notificationHTTPClient(policy *services.NotificationEndpointPolicy) *http.Client { - transport := &http.Transport{ - ForceAttemptHTTP2: true, - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - }, - } - return &http.Client{ - Transport: services.NewNotificationHTTPTransport( - policy, - transport, - &net.Dialer{ - Timeout: 5 * time.Second, - KeepAlive: 30 * time.Second, - }, - ), - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, + Transport: container.notificationHTTPRoundTripper(http.DefaultTransport), } } -// NotificationDispatcher creates notification senders for Firebase and HTTP gateways. -func (container *Container) NotificationDispatcher() *services.NotificationDispatcher { - policy := container.NotificationEndpointPolicy() - return services.NewNotificationDispatcher( +// PhoneNotificationDispatcher creates notification senders for Firebase and HTTP gateways. +func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotificationDispatcher { + return services.NewPhoneNotificationDispatcher( services.NewFCMNotificationSender(container.FCMClient()), services.NewHTTPNotificationSender( container.Logger(), container.Tracer(), - container.notificationHTTPClient(policy), - policy, + container.NotificationHTTPClient(), ), ) } @@ -787,7 +751,6 @@ func (container *Container) PhoneHandlerValidator() (validator *validators.Phone container.Logger(), container.Tracer(), container.MessageSendScheduleService(), - container.NotificationEndpointPolicy(), ) } @@ -1771,7 +1734,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi return services.NewNotificationService( container.Logger(), container.Tracer(), - container.NotificationDispatcher(), + container.PhoneNotificationDispatcher(), container.PhoneRepository(), container.PhoneNotificationRepository(), container.MessageSendScheduleRepository(), diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go index a7d410ad..8e55993d 100644 --- a/api/pkg/di/container_test.go +++ b/api/pkg/di/container_test.go @@ -1,46 +1,127 @@ package di import ( - "crypto/tls" + "bytes" + "context" + "io" + "net/http" + "net/url" "reflect" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" ) -func TestNotificationEndpointPolicyCreatesNewInstances(t *testing.T) { +func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) { t.Setenv("ENV", "local") - container := NewLiteContainer() + client := NewLiteContainer().NotificationHTTPClient() - assert.NotSame(t, container.NotificationEndpointPolicy(), container.NotificationEndpointPolicy()) + assert.Zero(t, client.Timeout) + transport, ok := client.Transport.(*notificationTelemetryRoundTripper) + require.True(t, ok) + assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(transport.telemetry).String()) + assert.Nil(t, client.CheckRedirect) } -func TestNotificationDispatcherUsesSecuredTransportAndSafeTelemetry(t *testing.T) { +func TestPhoneNotificationDispatcherInjectsNotificationHTTPClient(t *testing.T) { t.Setenv("ENV", "local") t.Setenv("FCM_ENDPOINT", "http://localhost") - dispatcher := NewLiteContainer().NotificationDispatcher() + dispatcher := NewLiteContainer().PhoneNotificationDispatcher() httpSender := reflect.ValueOf(dispatcher).Elem().FieldByName("httpSender").Elem().Elem() client := httpSender.FieldByName("client").Elem() - trustedTransport := client.FieldByName("Transport").Elem() + transport := client.FieldByName("Transport").Elem() - require.Equal(t, "*services.notificationHTTPTransport", trustedTransport.Type().String()) + assert.Equal(t, "*di.notificationTelemetryRoundTripper", transport.Type().String()) + attemptRecorder := httpSender.FieldByName("attemptRecorder").Elem() + assert.Equal(t, "*services.otelNotificationHTTPAttemptRecorder", attemptRecorder.Type().String()) +} - parent := trustedTransport.Elem().FieldByName("secured") - require.Equal(t, "*http.Transport", parent.Type().String()) +func TestNotificationHTTPRoundTripperSanitizesTelemetryURLOnly(t *testing.T) { + t.Setenv("ENV", "local") + previousMeterProvider := otel.GetMeterProvider() + reader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + otel.SetMeterProvider(meterProvider) + t.Cleanup(func() { + otel.SetMeterProvider(previousMeterProvider) + require.NoError(t, meterProvider.Shutdown(context.Background())) + }) - transport := parent.Elem() - assert.True(t, transport.FieldByName("Proxy").IsNil()) - assert.False(t, transport.FieldByName("DialContext").IsNil()) - assert.True(t, transport.FieldByName("DialTLS").IsNil()) - assert.True(t, transport.FieldByName("DialTLSContext").IsNil()) + var parentRequest *http.Request + var parentBody string + parent := notificationRoundTripFunc(func(request *http.Request) (*http.Response, error) { + parentRequest = request + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + parentBody = string(body) + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: http.NoBody, + Header: make(http.Header), + Request: request, + }, nil + }) + client := &http.Client{ + Transport: NewLiteContainer().notificationHTTPRoundTripper(parent), + } + endpoint := &url.URL{ + Scheme: "https", + User: url.UserPassword("adapter-user", "adapter-password"), + Host: "adapter.example.com:8443", + Path: "/secret/path", + RawQuery: "token=customer-secret", + Fragment: "private-fragment", + } + request, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + endpoint.String(), + bytes.NewBufferString("notification-body"), + ) + require.NoError(t, err) + request.Header.Set("X-Test-Header", "test-value") - tlsConfig := transport.FieldByName("TLSClientConfig").Elem() - assert.False(t, tlsConfig.FieldByName("InsecureSkipVerify").Bool()) - assert.Empty(t, tlsConfig.FieldByName("ServerName").String()) - assert.Equal(t, uint64(tls.VersionTLS12), tlsConfig.FieldByName("MinVersion").Uint()) + response, err := client.Do(request) - attemptRecorder := httpSender.FieldByName("attemptRecorder").Elem() - assert.Equal(t, "*services.otelNotificationHTTPAttemptRecorder", attemptRecorder.Type().String()) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.NotNil(t, parentRequest) + assert.Equal(t, endpoint.String(), parentRequest.URL.String()) + assert.Equal(t, "test-value", parentRequest.Header.Get("X-Test-Header")) + assert.Equal(t, "notification-body", parentBody) + username, password, hasBasicAuth := parentRequest.BasicAuth() + assert.True(t, hasBasicAuth) + assert.Equal(t, "adapter-user", username) + assert.Equal(t, "adapter-password", password) + + var metrics metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &metrics)) + var telemetryURLs []string + for _, scopeMetrics := range metrics.ScopeMetrics { + for _, measured := range scopeMetrics.Metrics { + if measured.Name != "phone_notification_http.attempts" { + continue + } + sum, ok := measured.Data.(metricdata.Sum[int64]) + require.True(t, ok) + for _, point := range sum.DataPoints { + value, ok := point.Attributes.Value(attribute.Key("http.url")) + require.True(t, ok) + telemetryURLs = append(telemetryURLs, value.AsString()) + } + } + } + assert.Equal(t, []string{"https://adapter.example.com:8443"}, telemetryURLs) +} + +type notificationRoundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip notificationRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) } diff --git a/api/pkg/di/notification_http_round_tripper.go b/api/pkg/di/notification_http_round_tripper.go new file mode 100644 index 00000000..72b9a972 --- /dev/null +++ b/api/pkg/di/notification_http_round_tripper.go @@ -0,0 +1,62 @@ +package di + +import ( + "context" + "net/http" + "net/url" + + "github.com/NdoleStudio/go-otelroundtripper" + "go.opentelemetry.io/otel" +) + +type notificationOriginalRequestContextKey struct{} + +// notificationTelemetryRoundTripper gives telemetry a sanitized request while preserving delivery semantics. +type notificationTelemetryRoundTripper struct { + telemetry http.RoundTripper +} + +func (roundTripper *notificationTelemetryRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + if request == nil || request.URL == nil { + return roundTripper.telemetry.RoundTrip(request) + } + + ctx := context.WithValue(request.Context(), notificationOriginalRequestContextKey{}, request) + telemetryRequest := request.Clone(ctx) + telemetryRequest.URL = notificationTelemetryURL(request.URL) + + return roundTripper.telemetry.RoundTrip(telemetryRequest) +} + +type notificationOriginalRequestRoundTripper struct { + parent http.RoundTripper +} + +// RoundTrip restores the original request before the standard transport sends it. +func (roundTripper *notificationOriginalRequestRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + originalRequest, ok := request.Context().Value(notificationOriginalRequestContextKey{}).(*http.Request) + if !ok { + originalRequest = request + } + + return roundTripper.parent.RoundTrip(originalRequest) +} + +func (container *Container) notificationHTTPRoundTripper(parent http.RoundTripper) http.RoundTripper { + originalRequestRoundTripper := ¬ificationOriginalRequestRoundTripper{parent: parent} + + return ¬ificationTelemetryRoundTripper{ + telemetry: otelroundtripper.New( + otelroundtripper.WithName("phone_notification_http"), + otelroundtripper.WithParent(originalRequestRoundTripper), + otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), + ), + } +} + +func notificationTelemetryURL(requestURL *url.URL) *url.URL { + return &url.URL{ + Scheme: requestURL.Scheme, + Host: requestURL.Host, + } +} diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 03dfd3a6..36be28ae 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -40,7 +40,7 @@ type NotificationTransport string const ( // NotificationTransportFCM sends notifications through Firebase. NotificationTransportFCM NotificationTransport = "fcm" - // NotificationTransportHTTP sends notifications to a public HTTPS endpoint. + // NotificationTransportHTTP sends notifications to an HTTPS endpoint. NotificationTransportHTTP NotificationTransport = "http" ) diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index 1d51cab3..bb0e50bc 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -3,12 +3,10 @@ package services import ( "bytes" "context" - "crypto/tls" "encoding/json" "errors" "fmt" "io" - "net" "net/http" "net/url" "strconv" @@ -16,6 +14,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" + "github.com/avast/retry-go/v5" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -26,15 +25,6 @@ import ( const maxNotificationResponseDiscardBytes = 4 * 1024 -type notificationHTTPTransport struct { - secured *http.Transport - policy *NotificationEndpointPolicy -} - -func (transport *notificationHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) { - return transport.secured.RoundTrip(request) -} - type httpNotificationRequest struct { Message httpNotificationMessage `json:"message"` } @@ -55,55 +45,30 @@ type HTTPNotificationSender struct { logger telemetry.Logger tracer telemetry.Tracer client *http.Client - policy *NotificationEndpointPolicy attempts uint timeout time.Duration - retryDelay func(context.Context, time.Duration) error + retryDelay time.Duration attemptRecorder notificationHTTPAttemptRecorder } -// NewHTTPNotificationSender creates an SSRF-safe HTTP notification sender. +// NewHTTPNotificationSender creates an HTTP notification sender. func NewHTTPNotificationSender( logger telemetry.Logger, tracer telemetry.Tracer, client *http.Client, - policy *NotificationEndpointPolicy, ) *HTTPNotificationSender { + if client == nil { + client = http.DefaultClient + } + return &HTTPNotificationSender{ logger: logger, tracer: tracer, - client: newNotificationHTTPClient(client, policy), - policy: policy, + client: client, attempts: 3, timeout: 5 * time.Second, + retryDelay: 250 * time.Millisecond, attemptRecorder: newNotificationHTTPAttemptRecorder(tracer), - retryDelay: func(ctx context.Context, delay time.Duration) error { - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } - }, - } -} - -// NewNotificationHTTPTransport creates a transport that always routes through a policy-secured parent. -func NewNotificationHTTPTransport( - policy *NotificationEndpointPolicy, - transport *http.Transport, - dialer *net.Dialer, -) http.RoundTripper { - if policy == nil { - panic("notification endpoint policy is required") - } - secured := secureNotificationHTTPTransport(transport, policy, dialer) - - return ¬ificationHTTPTransport{ - secured: secured, - policy: policy, } } @@ -118,9 +83,6 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError("", "cannot parse notification endpoint") } hostname := endpoint.Hostname() - if sender.policy == nil { - return "", sender.notificationError(hostname, "notification endpoint policy is required") - } payload := httpNotificationRequest{ Message: httpNotificationMessage{ @@ -143,7 +105,16 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError(hostname, "notification sender has no attempts configured") } - for attempt := uint(1); attempt <= sender.attempts; attempt++ { + attempt := uint(0) + err = retry.New( + retry.Attempts(sender.attempts), + retry.Delay(sender.retryDelay), + retry.DelayType(retry.BackOffDelay), + retry.LastErrorOnly(true), + retry.Context(ctx), + retry.RetryIf(isRetryableNotificationError), + ).Do(func() error { + attempt++ requestCtx, cancel := context.WithTimeout(ctx, sender.timeout) attemptCtx := requestCtx finishAttempt := func(int, error) {} @@ -151,40 +122,34 @@ func (sender *HTTPNotificationSender) Send( attemptCtx, finishAttempt = sender.attemptRecorder.Start(attemptCtx, attempt) } - _, requestErr := sender.policy.Validate(attemptCtx, endpoint) - statusCode := 0 - if requestErr == nil { - var request *http.Request - request, requestErr = http.NewRequestWithContext( - attemptCtx, - http.MethodPost, - endpoint.String(), - bytes.NewReader(body), - ) - if requestErr != nil { - finishAttempt(statusCode, requestErr) - cancel() - return "", sender.notificationError(hostname, "cannot create notification request") - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("X-httpSMS-Notification-ID", notification.NotificationID.String()) - statusCode, requestErr = sender.sendAttempt(request) + request, requestErr := http.NewRequestWithContext( + attemptCtx, + http.MethodPost, + endpoint.String(), + bytes.NewReader(body), + ) + if requestErr != nil { + finishAttempt(0, requestErr) + cancel() + return terminalNotificationRequestError{cause: requestErr} } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-httpSMS-Notification-ID", notification.NotificationID.String()) + + statusCode, requestErr := sender.sendAttempt(request) finishAttempt(statusCode, requestErr) cancel() - if requestErr == nil { - return "http/" + notification.NotificationID.String(), nil - } if ctx.Err() != nil { - return "", sender.notificationError(hostname, "notification request cancelled") - } - if attempt == sender.attempts || !isRetryableNotificationError(requestErr) { - return "", sender.notificationError(hostname, "notification request failed") - } - if sender.retryDelay(ctx, notificationRetryDelay(attempt)) != nil { - return "", sender.notificationError(hostname, "notification retry cancelled") + return terminalNotificationRequestError{cause: ctx.Err()} } + return requestErr + }) + if err == nil { + return "http/" + notification.NotificationID.String(), nil + } + if ctx.Err() != nil { + return "", sender.notificationError(hostname, "notification request cancelled") } return "", sender.notificationError(hostname, "notification request failed") @@ -223,83 +188,12 @@ func (sender *HTTPNotificationSender) notificationError(hostname string, message return err } -func newNotificationHTTPClient(client *http.Client, policy *NotificationEndpointPolicy) *http.Client { - if client == nil { - client = &http.Client{} - } - - configured := *client - configured.Timeout = 0 - configured.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - } - - if trusted, ok := configured.Transport.(*notificationHTTPTransport); ok && - policy != nil && - trusted.policy == policy && - trusted.secured != nil { - return &configured - } - - transport, ok := configured.Transport.(*http.Transport) - if !ok { - transport = http.DefaultTransport.(*http.Transport) - } - configured.Transport = secureNotificationHTTPTransport(transport, policy, &net.Dialer{}) - - return &configured -} - -func secureNotificationHTTPTransport( - transport *http.Transport, - policy *NotificationEndpointPolicy, - dialer *net.Dialer, -) *http.Transport { - if transport == nil { - transport = http.DefaultTransport.(*http.Transport) - } - transport = transport.Clone() - transport.Proxy = nil - transport.DialTLS = nil - transport.DialTLSContext = nil - if transport.TLSClientConfig == nil { - transport.TLSClientConfig = &tls.Config{} - } else { - transport.TLSClientConfig = transport.TLSClientConfig.Clone() - } - transport.TLSClientConfig.InsecureSkipVerify = false - transport.TLSClientConfig.ServerName = "" - if transport.TLSClientConfig.MinVersion < tls.VersionTLS12 { - transport.TLSClientConfig.MinVersion = tls.VersionTLS12 - } - if transport.TLSClientConfig.MaxVersion != 0 && transport.TLSClientConfig.MaxVersion < tls.VersionTLS12 { - transport.TLSClientConfig.MaxVersion = tls.VersionTLS12 - } - if policy != nil { - if dialer == nil { - dialer = &net.Dialer{} - } - configuredDialer := *dialer - transport.DialContext = policy.DialContext(&configuredDialer) - } else { - transport.DialContext = func(context.Context, string, string) (net.Conn, error) { - return nil, stacktrace.NewError("notification endpoint policy is required") - } - } - - return transport -} - func isRetryableNotificationStatus(statusCode int) bool { return statusCode == http.StatusRequestTimeout || statusCode == http.StatusTooManyRequests || (statusCode >= http.StatusInternalServerError && statusCode < 600) } -func notificationRetryDelay(attempt uint) time.Duration { - return time.Duration(1<<(attempt-1)) * 250 * time.Millisecond -} - type retryableNotificationStatusError struct { statusCode int } @@ -316,16 +210,33 @@ 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 || isTerminalNotificationStatusError(err) || isNotificationEndpointPolicyViolation(err) { + if err == nil || isTerminalNotificationError(err) { return false } return true } -func isTerminalNotificationStatusError(err error) bool { +func isTerminalNotificationError(err error) bool { var statusError terminalNotificationStatusError - return errors.As(err, &statusError) + if errors.As(err, &statusError) { + return true + } + + var requestError terminalNotificationRequestError + return errors.As(err, &requestError) } func formatProtobufDuration(value time.Duration) string { diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index de3804a1..917d400c 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -3,15 +3,13 @@ package services import ( "bytes" "context" - "crypto/tls" "encoding/json" "errors" "io" - "net" "net/http" - "net/netip" + "net/url" + "reflect" "strings" - "sync" "testing" "time" @@ -192,6 +190,35 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { } } +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(), "https://adapter.example.com/notify", GatewayNotification{ + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + NotificationID: uuid.New(), + }) + + 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) { @@ -243,18 +270,20 @@ func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { require.NoError(t, err) } -func TestHTTPNotificationSenderConfiguresSecureHTTPClient(t *testing.T) { - policy := newHTTPNotificationPolicy() - sender := NewHTTPNotificationSender(nil, nil, &http.Client{Timeout: time.Minute}, policy) - - transport, ok := sender.client.Transport.(*http.Transport) - require.True(t, ok) - assert.Zero(t, sender.client.Timeout) - assert.Nil(t, transport.Proxy) - assert.NotNil(t, transport.DialContext) - assert.NotNil(t, sender.client.CheckRedirect) - require.NotNil(t, transport.TLSClientConfig) - assert.False(t, transport.TLSClientConfig.InsecureSkipVerify) +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, 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) { @@ -265,65 +294,29 @@ func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { 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(), - "https://adapter-user:adapter-password@adapter.example.com/notify", + endpoint.String(), GatewayNotification{NotificationID: uuid.New()}, ) require.NoError(t, err) } -func TestHTTPNotificationSenderRetriesTransientDNSFailures(t *testing.T) { - resolver := &sequenceHostResolver{outcomes: []hostResolverOutcome{ - {err: errors.New("temporary resolver failure")}, - {addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}, - }} - httpCalls := 0 - sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { - httpCalls++ - return response(http.StatusNoContent, http.NoBody), nil - })) - sender.policy = NewNotificationEndpointPolicy(resolver, nil) - - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - NotificationID: uuid.New(), - }) - - require.NoError(t, err) - assert.Equal(t, 2, resolver.callCount()) - assert.Equal(t, 1, httpCalls) -} - -func TestHTTPNotificationSenderExhaustsTransientDNSFailures(t *testing.T) { - resolver := &sequenceHostResolver{outcomes: []hostResolverOutcome{ - {err: errors.New("temporary resolver failure 1")}, - {err: errors.New("temporary resolver failure 2")}, - {err: errors.New("temporary resolver failure 3")}, - }} - httpCalls := 0 - sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { - httpCalls++ - return response(http.StatusNoContent, http.NoBody), nil - })) - sender.policy = NewNotificationEndpointPolicy(resolver, nil) - - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - NotificationID: uuid.New(), - }) - - require.Error(t, err) - assert.Equal(t, 3, resolver.callCount()) - assert.Zero(t, httpCalls) -} - -func TestHTTPNotificationSenderBoundsDNSResolutionByAttemptTimeout(t *testing.T) { - resolver := &blockingHostResolver{} - sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return response(http.StatusNoContent, http.NoBody), nil +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.policy = NewNotificationEndpointPolicy(resolver, nil) sender.timeout = 10 * time.Millisecond _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ @@ -331,194 +324,25 @@ func TestHTTPNotificationSenderBoundsDNSResolutionByAttemptTimeout(t *testing.T) }) require.Error(t, err) - assert.Equal(t, 3, resolver.callCount()) + assert.Equal(t, 3, calls) } -func TestHTTPNotificationSenderStopsDNSRetriesWhenParentContextIsCancelled(t *testing.T) { - resolver := &blockingHostResolver{} - sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return response(http.StatusNoContent, http.NoBody), nil - })) - sender.policy = NewNotificationEndpointPolicy(resolver, nil) +func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - cancel() + 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, "https://adapter.example.com/notify", GatewayNotification{ NotificationID: uuid.New(), }) require.Error(t, err) - assert.Equal(t, 1, resolver.callCount()) -} - -func TestHTTPNotificationSenderDoesNotRetryTerminalEndpointPolicyFailures(t *testing.T) { - tests := []struct { - name string - endpoint string - addresses []netip.Addr - }{ - {name: "insecure scheme", endpoint: "http://adapter.example.com/notify"}, - {name: "private resolution", endpoint: "https://adapter.example.com/notify", addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - resolver := &countingHostResolver{addresses: test.addresses} - httpCalls := 0 - sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { - httpCalls++ - return response(http.StatusNoContent, http.NoBody), nil - })) - sender.policy = NewNotificationEndpointPolicy(resolver, nil) - - _, err := sender.Send(context.Background(), test.endpoint, GatewayNotification{ - NotificationID: uuid.New(), - }) - - require.Error(t, err) - assert.LessOrEqual(t, resolver.callCount(), 1) - assert.Zero(t, httpCalls) - }) - } -} - -func TestHTTPNotificationSenderDoesNotRetryDialTimePolicyViolation(t *testing.T) { - resolver := &sequenceHostResolver{outcomes: []hostResolverOutcome{ - {addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}}, - {addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, - }} - policy := NewNotificationEndpointPolicy(resolver, nil) - sender := NewHTTPNotificationSender(nil, nil, &http.Client{}, policy) - sender.retryDelay = func(context.Context, time.Duration) error { return nil } - - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - NotificationID: uuid.New(), - }) - - require.Error(t, err) - assert.Equal(t, 2, resolver.callCount()) -} - -func TestHTTPNotificationSenderClearsCustomTLSDialersAndServerName(t *testing.T) { - sender := NewHTTPNotificationSender(nil, nil, &http.Client{ - Transport: &http.Transport{ - DialTLS: func(string, string) (net.Conn, error) { - return nil, errors.New("unsafe TLS dialer must not be used") - }, - DialTLSContext: func(context.Context, string, string) (net.Conn, error) { - return nil, errors.New("unsafe TLS context dialer must not be used") - }, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - ServerName: "attacker.example.com", - }, - }, - }, newHTTPNotificationPolicy()) - - transport, ok := sender.client.Transport.(*http.Transport) - - require.True(t, ok) - assert.Nil(t, transport.DialTLS) - assert.Nil(t, transport.DialTLSContext) - require.NotNil(t, transport.DialContext) - require.NotNil(t, transport.TLSClientConfig) - assert.False(t, transport.TLSClientConfig.InsecureSkipVerify) - assert.Empty(t, transport.TLSClientConfig.ServerName) -} - -func TestHTTPNotificationSenderRejectsOpaqueTransportAndUsesPolicyDialer(t *testing.T) { - opaque := &wrappedNotificationRoundTripper{} - policy := NewNotificationEndpointPolicy(&staticHostResolver{ - addresses: map[string][]netip.Addr{ - "private.example.com": {netip.MustParseAddr("127.0.0.1")}, - }, - }, nil) - sender := NewHTTPNotificationSender(nil, nil, &http.Client{ - Transport: opaque, - }, policy) - - transport, ok := sender.client.Transport.(*http.Transport) - require.True(t, ok) - - _, err := transport.DialContext(context.Background(), "tcp", "private.example.com:443") - - require.Error(t, err) - assert.Zero(t, opaque.calls) -} - -func TestHTTPNotificationSenderTrustsServiceCreatedTransportWithSamePolicy(t *testing.T) { - policy := NewNotificationEndpointPolicy(&staticHostResolver{ - addresses: map[string][]netip.Addr{ - "private.example.com": {netip.MustParseAddr("127.0.0.1")}, - }, - }, nil) - unsafeDialCalls := 0 - transport := NewNotificationHTTPTransport( - policy, - &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: func(context.Context, string, string) (net.Conn, error) { - unsafeDialCalls++ - return nil, errors.New("unsafe dialer must not be used") - }, - DialTLS: func(string, string) (net.Conn, error) { - return nil, errors.New("unsafe TLS dialer must not be used") - }, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - MinVersion: tls.VersionTLS10, - ServerName: "attacker.example.com", - }, - }, - &net.Dialer{Timeout: time.Second}, - ) - - sender := NewHTTPNotificationSender(nil, nil, &http.Client{Transport: transport}, policy) - - assert.Same(t, transport, sender.client.Transport) - trustedTransport, ok := transport.(*notificationHTTPTransport) - require.True(t, ok) - assert.Same(t, policy, trustedTransport.policy) - securedParent := trustedTransport.secured - assert.Nil(t, securedParent.Proxy) - assert.NotNil(t, securedParent.DialContext) - assert.Nil(t, securedParent.DialTLS) - assert.Nil(t, securedParent.DialTLSContext) - require.NotNil(t, securedParent.TLSClientConfig) - assert.False(t, securedParent.TLSClientConfig.InsecureSkipVerify) - assert.Empty(t, securedParent.TLSClientConfig.ServerName) - assert.Equal(t, uint16(tls.VersionTLS12), securedParent.TLSClientConfig.MinVersion) - - _, err := securedParent.DialContext(context.Background(), "tcp", "private.example.com:443") - - require.Error(t, err) - assert.Zero(t, unsafeDialCalls) -} - -func TestNewNotificationHTTPTransportRejectsNilPolicy(t *testing.T) { - assert.Panics(t, func() { - NewNotificationHTTPTransport(nil, &http.Transport{}, &net.Dialer{}) - }) -} - -func TestHTTPNotificationSenderRejectsTransportCreatedForDifferentPolicy(t *testing.T) { - firstPolicy := NewNotificationEndpointPolicy(&staticHostResolver{ - addresses: map[string][]netip.Addr{ - "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, - }, - }, nil) - secondPolicy := NewNotificationEndpointPolicy(&staticHostResolver{ - addresses: map[string][]netip.Addr{ - "adapter.example.com": {netip.MustParseAddr("1.1.1.1")}, - }, - }, nil) - transport := NewNotificationHTTPTransport(firstPolicy, &http.Transport{}, &net.Dialer{}) - - sender := NewHTTPNotificationSender(nil, nil, &http.Client{Transport: transport}, secondPolicy) - - assert.NotSame(t, transport, sender.client.Transport) - _, ok := sender.client.Transport.(*http.Transport) - assert.True(t, ok) + assert.Equal(t, 1, calls) } func TestHTTPNotificationSenderTelemetryDoesNotExportCallbackURL(t *testing.T) { @@ -571,78 +395,6 @@ type boundedReadCloser struct { closed bool } -type wrappedNotificationRoundTripper struct { - calls int -} - -func (transport *wrappedNotificationRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { - transport.calls++ - return nil, errors.New("must not be used") -} - -type hostResolverOutcome struct { - addresses []netip.Addr - err error -} - -type sequenceHostResolver struct { - mu sync.Mutex - outcomes []hostResolverOutcome - calls int -} - -func (resolver *sequenceHostResolver) LookupNetIP(_ context.Context, _ string, _ string) ([]netip.Addr, error) { - resolver.mu.Lock() - defer resolver.mu.Unlock() - outcome := resolver.outcomes[resolver.calls] - resolver.calls++ - return outcome.addresses, outcome.err -} - -func (resolver *sequenceHostResolver) callCount() int { - resolver.mu.Lock() - defer resolver.mu.Unlock() - return resolver.calls -} - -type blockingHostResolver struct { - mu sync.Mutex - calls int -} - -func (resolver *blockingHostResolver) LookupNetIP(ctx context.Context, _ string, _ string) ([]netip.Addr, error) { - resolver.mu.Lock() - resolver.calls++ - resolver.mu.Unlock() - <-ctx.Done() - return nil, ctx.Err() -} - -func (resolver *blockingHostResolver) callCount() int { - resolver.mu.Lock() - defer resolver.mu.Unlock() - return resolver.calls -} - -type countingHostResolver struct { - mu sync.Mutex - addresses []netip.Addr - calls int -} - -func (resolver *countingHostResolver) LookupNetIP(_ context.Context, _ string, _ string) ([]netip.Addr, error) { - resolver.mu.Lock() - defer resolver.mu.Unlock() - resolver.calls++ - return resolver.addresses, nil -} - -func (resolver *countingHostResolver) callCount() int { - resolver.mu.Lock() - defer resolver.mu.Unlock() - return resolver.calls -} - func (reader *boundedReadCloser) Read(buffer []byte) (int, error) { if reader.remaining == 0 { return 0, io.EOF @@ -678,6 +430,7 @@ func (logger *httpNotificationRecordingLogger) WithString(string, string) teleme 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) {} @@ -696,22 +449,9 @@ func newHTTPNotificationSenderWithLogger( transport roundTripFunc, ) *HTTPNotificationSender { t.Helper() - return &HTTPNotificationSender{ - logger: logger, - client: &http.Client{Transport: transport}, - policy: newHTTPNotificationPolicy(), - attempts: 3, - timeout: 5 * time.Second, - retryDelay: func(context.Context, time.Duration) error { return nil }, - } -} - -func newHTTPNotificationPolicy() *NotificationEndpointPolicy { - return NewNotificationEndpointPolicy(&staticHostResolver{ - addresses: map[string][]netip.Addr{ - "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, - }, - }, nil) + sender := NewHTTPNotificationSender(logger, nil, &http.Client{Transport: transport}) + sender.retryDelay = 0 + return sender } func response(statusCode int, body io.ReadCloser) *http.Response { diff --git a/api/pkg/services/notification_endpoint_policy.go b/api/pkg/services/notification_endpoint_policy.go deleted file mode 100644 index 63641565..00000000 --- a/api/pkg/services/notification_endpoint_policy.go +++ /dev/null @@ -1,211 +0,0 @@ -package services - -import ( - "context" - "errors" - "net" - "net/netip" - "net/url" - "strings" - - "github.com/NdoleStudio/stacktrace" -) - -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("100:0:0:1::/64"), - netip.MustParsePrefix("64:ff9b:1::/48"), - netip.MustParsePrefix("2001::/23"), - netip.MustParsePrefix("2001:2::/48"), - netip.MustParsePrefix("2001:db8::/32"), - netip.MustParsePrefix("3fff::/20"), - netip.MustParsePrefix("5f00::/16"), - netip.MustParsePrefix("fc00::/7"), - netip.MustParsePrefix("fe80::/10"), - netip.MustParsePrefix("ff00::/8"), -} - -var globallyReachableNotificationPrefixExceptions = []netip.Prefix{ - netip.MustParsePrefix("2001::/32"), - netip.MustParsePrefix("2001:1::1/128"), - netip.MustParsePrefix("2001:1::2/128"), - netip.MustParsePrefix("2001:1::3/128"), - netip.MustParsePrefix("2001:3::/32"), - netip.MustParsePrefix("2001:4:112::/48"), - netip.MustParsePrefix("2001:20::/28"), - netip.MustParsePrefix("2001:30::/28"), -} - -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 { - privateHosts := make(map[string]struct{}, len(allowedPrivateHosts)) - for _, host := range allowedPrivateHosts { - privateHosts[strings.ToLower(host)] = struct{}{} - } - - return &NotificationEndpointPolicy{ - resolver: resolver, - allowedPrivateHosts: privateHosts, - } -} - -func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint *url.URL) ([]netip.Addr, error) { - if policy == nil || policy.resolver == nil { - return nil, newNotificationEndpointPolicyViolation("notification endpoint policy is required") - } - if endpoint == nil { - return nil, newNotificationEndpointPolicyViolation("notification endpoint is required") - } - if !strings.EqualFold(endpoint.Scheme, "https") { - return nil, newNotificationEndpointPolicyViolation("notification endpoint must use HTTPS") - } - - host := strings.ToLower(endpoint.Hostname()) - if host == "" { - return nil, newNotificationEndpointPolicyViolation("notification endpoint must contain a hostname") - } - if literal, err := netip.ParseAddr(host); err == nil && !isPublicNotificationAddress(literal) { - return nil, newNotificationEndpointPolicyViolation("notification endpoint must not use a non-public IP literal") - } - - addresses, err := policy.resolver.LookupNetIP(ctx, "ip", host) - if err != nil { - return nil, newNotificationEndpointResolutionFailure(err) - } - if len(addresses) == 0 { - return nil, newNotificationEndpointResolutionFailure( - stacktrace.NewError("notification endpoint hostname did not resolve"), - ) - } - - _, privateHostAllowed := policy.allowedPrivateHosts[host] - for _, address := range addresses { - if isPublicNotificationAddress(address) { - continue - } - if privateHostAllowed && address.Unmap().IsPrivate() { - continue - } - return nil, newNotificationEndpointPolicyViolation( - "notification endpoint hostname resolved to a non-public address", - ) - } - - return addresses, nil -} - -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) { - return policy.dialValidated(ctx, network, address, dialer.DialContext) - } -} - -func (policy *NotificationEndpointPolicy) dialValidated( - ctx context.Context, - network string, - address string, - dial func(context.Context, string, string) (net.Conn, error), -) (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 := dial(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") -} - -func isPublicNotificationAddress(address netip.Addr) bool { - address = address.Unmap() - if !address.IsValid() || !address.IsGlobalUnicast() { - return false - } - for _, prefix := range globallyReachableNotificationPrefixExceptions { - if prefix.Contains(address) { - return true - } - } - for _, prefix := range blockedNotificationPrefixes { - if prefix.Contains(address) { - return false - } - } - return true -} - -type notificationEndpointPolicyViolation struct { - message string -} - -func (violation *notificationEndpointPolicyViolation) Error() string { - return violation.message -} - -func newNotificationEndpointPolicyViolation(message string) error { - return stacktrace.Propagatef( - ¬ificationEndpointPolicyViolation{message: message}, - "notification endpoint policy rejected destination", - ) -} - -func isNotificationEndpointPolicyViolation(err error) bool { - var violation *notificationEndpointPolicyViolation - return errors.As(err, &violation) -} - -type notificationEndpointResolutionFailure struct { - cause error -} - -func (failure *notificationEndpointResolutionFailure) Error() string { - return failure.cause.Error() -} - -func (failure *notificationEndpointResolutionFailure) Unwrap() error { - return failure.cause -} - -func newNotificationEndpointResolutionFailure(cause error) error { - return stacktrace.Propagatef( - ¬ificationEndpointResolutionFailure{cause: cause}, - "cannot resolve notification endpoint hostname", - ) -} diff --git a/api/pkg/services/notification_endpoint_policy_test.go b/api/pkg/services/notification_endpoint_policy_test.go deleted file mode 100644 index b525675d..00000000 --- a/api/pkg/services/notification_endpoint_policy_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package services - -import ( - "context" - "errors" - "net" - "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://username:password@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) - - // URL user information does not affect endpoint network safety. - hasError := test.hasError && endpoint.User == nil - if hasError { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, test.addresses, addresses) - }) - } -} - -func TestNotificationEndpointPolicyRejectsNonPublicSpecialPurposeIPv6(t *testing.T) { - tests := []string{ - "64:ff9b:1::1", - "100:0:0:1::1", - "2001:2::1", - "2001:5::1", - "2001:10::1", - "3fff::1", - "5f00::1", - } - - for _, address := range tests { - t.Run(address, func(t *testing.T) { - endpoint, err := url.Parse("https://adapter.example.com/notify") - require.NoError(t, err) - policy := NewNotificationEndpointPolicy(&staticHostResolver{ - addresses: map[string][]netip.Addr{ - endpoint.Hostname(): {netip.MustParseAddr(address)}, - }, - }, nil) - - _, err = policy.Validate(context.Background(), endpoint) - - require.Error(t, err) - }) - } -} - -func TestNotificationEndpointPolicyAllowsGloballyReachableSpecialPurposeIPv6(t *testing.T) { - tests := []string{ - "64:ff9b::0808:0808", - "2001::1", - "2001:1::1", - "2001:1::2", - "2001:1::3", - "2001:3::1", - "2001:4:112::1", - "2001:20::1", - "2001:30::1", - } - - for _, address := range tests { - t.Run(address, func(t *testing.T) { - endpoint, err := url.Parse("https://adapter.example.com/notify") - require.NoError(t, err) - policy := NewNotificationEndpointPolicy(&staticHostResolver{ - addresses: map[string][]netip.Addr{ - endpoint.Hostname(): {netip.MustParseAddr(address)}, - }, - }, nil) - - addresses, err := policy.Validate(context.Background(), endpoint) - - require.NoError(t, err) - assert.Equal(t, []netip.Addr{netip.MustParseAddr(address)}, addresses) - }) - } -} - -func TestNotificationEndpointPolicyRejectsRebindingBeforeDial(t *testing.T) { - endpoint, err := url.Parse("https://adapter.example.com:9091/notify") - require.NoError(t, err) - - resolver := &rebindingHostResolver{ - addresses: [][]netip.Addr{ - {netip.MustParseAddr("8.8.8.8")}, - {netip.MustParseAddr("127.0.0.1")}, - }, - } - policy := NewNotificationEndpointPolicy(resolver, nil) - - _, err = policy.Validate(context.Background(), endpoint) - require.NoError(t, err) - - dialed := false - _, err = policy.dialValidated( - context.Background(), - "tcp", - "adapter.example.com:9091", - func(_ context.Context, _, _ string) (net.Conn, error) { - dialed = true - return nil, errors.New("should not dial") - }, - ) - - require.Error(t, err) - assert.False(t, dialed) -} - -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) -} - -func TestNotificationEndpointPolicyRejectsNonExactOrLiteralPrivateHosts(t *testing.T) { - tests := []struct { - name string - rawURL string - addresses []netip.Addr - }{ - {name: "allowlist suffix", rawURL: "https://adapter-emulator.example.com:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("172.20.0.8")}}, - {name: "private IP literal", rawURL: "https://172.20.0.8:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("172.20.0.8")}}, - {name: "non allowlisted host", rawURL: "https://other-emulator:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("172.20.0.8")}}, - {name: "allowlisted loopback", rawURL: "https://adapter-emulator:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, - {name: "allowlisted documentation range", rawURL: "https://adapter-emulator:9091/notify", addresses: []netip.Addr{netip.MustParseAddr("203.0.113.1")}}, - } - - 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, - }, - }, []string{"adapter-emulator"}) - - _, err = policy.Validate(context.Background(), endpoint) - - require.Error(t, err) - }) - } -} - -type rebindingHostResolver struct { - addresses [][]netip.Addr - lookups int -} - -func (resolver *rebindingHostResolver) LookupNetIP(_ context.Context, _ string, _ string) ([]netip.Addr, error) { - addresses := resolver.addresses[resolver.lookups] - resolver.lookups++ - return addresses, nil -} diff --git a/api/pkg/services/notification_sender.go b/api/pkg/services/notification_sender.go index c7fc25f9..95fc725c 100644 --- a/api/pkg/services/notification_sender.go +++ b/api/pkg/services/notification_sender.go @@ -24,22 +24,25 @@ type NotificationSender interface { Send(ctx context.Context, destination string, notification GatewayNotification) (string, error) } -// NotificationDispatcher routes gateway notifications to the phone's configured transport. -type NotificationDispatcher struct { +// PhoneNotificationDispatcher routes gateway notifications to the phone's configured transport. +type PhoneNotificationDispatcher struct { fcmSender NotificationSender httpSender NotificationSender } -// NewNotificationDispatcher creates a dispatcher for FCM and HTTP notification transports. -func NewNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *NotificationDispatcher { - return &NotificationDispatcher{ +// NewPhoneNotificationDispatcher creates a dispatcher for FCM and HTTP notification transports. +func NewPhoneNotificationDispatcher( + fcmSender NotificationSender, + httpSender NotificationSender, +) *PhoneNotificationDispatcher { + return &PhoneNotificationDispatcher{ fcmSender: fcmSender, httpSender: httpSender, } } // Send delivers a notification using the phone's configured notification transport. -func (dispatcher *NotificationDispatcher) Send( +func (dispatcher *PhoneNotificationDispatcher) Send( ctx context.Context, phone *entities.Phone, notification GatewayNotification, diff --git a/api/pkg/services/notification_sender_test.go b/api/pkg/services/notification_sender_test.go index fa0d74a9..d7e512e5 100644 --- a/api/pkg/services/notification_sender_test.go +++ b/api/pkg/services/notification_sender_test.go @@ -27,12 +27,12 @@ func (sender *recordingNotificationSender) Send(_ context.Context, destination s return sender.result, sender.err } -func TestNotificationDispatcherRoutesFCMToken(t *testing.T) { +func TestPhoneNotificationDispatcherRoutesFCMToken(t *testing.T) { token := "fcm-token:value" phone := &entities.Phone{FcmToken: &token} fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} httpSender := &recordingNotificationSender{} - dispatcher := NewNotificationDispatcher(fcmSender, httpSender) + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} result, err := dispatcher.Send(context.Background(), phone, notification) @@ -44,12 +44,12 @@ func TestNotificationDispatcherRoutesFCMToken(t *testing.T) { assert.Equal(t, token, fcmSender.destination) } -func TestNotificationDispatcherRoutesHTTPSURL(t *testing.T) { +func TestPhoneNotificationDispatcherRoutesHTTPSURL(t *testing.T) { endpoint := "https://adapter.example.com/notifications/gateway-1" phone := &entities.Phone{FcmToken: &endpoint} fcmSender := &recordingNotificationSender{} httpSender := &recordingNotificationSender{result: "accepted"} - dispatcher := NewNotificationDispatcher(fcmSender, httpSender) + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} result, err := dispatcher.Send(context.Background(), phone, notification) @@ -61,12 +61,12 @@ func TestNotificationDispatcherRoutesHTTPSURL(t *testing.T) { assert.Equal(t, endpoint, httpSender.destination) } -func TestNotificationDispatcherRejectsInvalidURLLikeTokenWithoutSending(t *testing.T) { +func TestPhoneNotificationDispatcherRejectsInvalidURLLikeTokenWithoutSending(t *testing.T) { token := "https://" phone := &entities.Phone{FcmToken: &token} fcmSender := &recordingNotificationSender{} httpSender := &recordingNotificationSender{} - dispatcher := NewNotificationDispatcher(fcmSender, httpSender) + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) _, err := dispatcher.Send(context.Background(), phone, GatewayNotification{}) diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index b2b2e622..e0acad73 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -26,34 +26,28 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - notificationDispatcher *NotificationDispatcher - eventDispatcher NotificationEventDispatcher -} - -// NotificationEventDispatcher dispatches phone gateway notification events. -type NotificationEventDispatcher interface { - Dispatch(ctx context.Context, event cloudevents.Event) error - DispatchWithTimeout(ctx context.Context, event cloudevents.Event, timeout time.Duration) (string, error) + phoneNotificationDispatcher *PhoneNotificationDispatcher + eventDispatcher *EventDispatcher } // NewNotificationService creates a new PhoneNotificationService func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - notificationDispatcher *NotificationDispatcher, + phoneNotificationDispatcher *PhoneNotificationDispatcher, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, - dispatcher NotificationEventDispatcher, + eventDispatcher *EventDispatcher, ) (s *PhoneNotificationService) { return &PhoneNotificationService{ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, - notificationDispatcher: notificationDispatcher, + phoneNotificationDispatcher: phoneNotificationDispatcher, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, - eventDispatcher: dispatcher, + eventDispatcher: eventDispatcher, } } @@ -97,7 +91,7 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID)) } - result, err := service.notificationDispatcher.Send(ctx, phone, GatewayNotification{ + result, err := service.phoneNotificationDispatcher.Send(ctx, phone, GatewayNotification{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, @@ -151,7 +145,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.notificationDispatcher.Send(ctx, phone, GatewayNotification{ + result, err := service.phoneNotificationDispatcher.Send(ctx, phone, GatewayNotification{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go index f84607c3..e556c1ce 100644 --- a/api/pkg/services/phone_notification_service_test.go +++ b/api/pkg/services/phone_notification_service_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -48,21 +49,20 @@ func (repository *phoneNotificationRepository) UpdateStatus( return nil } -type phoneNotificationEventDispatcher struct { +type phoneNotificationEventQueue struct { events []cloudevents.Event } -func (dispatcher *phoneNotificationEventDispatcher) Dispatch(_ context.Context, event cloudevents.Event) error { - dispatcher.events = append(dispatcher.events, event) - return nil -} - -func (dispatcher *phoneNotificationEventDispatcher) DispatchWithTimeout( +func (queue *phoneNotificationEventQueue) Enqueue( _ context.Context, - event cloudevents.Event, + task *PushQueueTask, _ time.Duration, ) (string, error) { - dispatcher.events = append(dispatcher.events, event) + var event cloudevents.Event + if err := json.Unmarshal(task.Body, &event); err != nil { + return "", err + } + queue.events = append(queue.events, event) return "", nil } @@ -98,9 +98,9 @@ func TestPhoneNotificationServiceSendUsesHTTPSGatewayNotification(t *testing.T) MessageExpirationSeconds: 90, } httpSender := &recordingNotificationSender{result: "http/notification-1"} - eventDispatcher := &phoneNotificationEventDispatcher{} + eventQueue := &phoneNotificationEventQueue{} notificationRepository := &phoneNotificationRepository{} - service := newPhoneNotificationServiceForTest(phone, notificationRepository, eventDispatcher, &recordingNotificationSender{}, httpSender) + service := newPhoneNotificationServiceForTest(phone, notificationRepository, eventQueue, &recordingNotificationSender{}, httpSender) params := &PhoneNotificationSendParams{ UserID: phone.UserID, PhoneID: phone.ID, @@ -117,8 +117,8 @@ func TestPhoneNotificationServiceSendUsesHTTPSGatewayNotification(t *testing.T) require.NotNil(t, httpSender.notification.TTL) assert.Equal(t, phone.MessageExpirationDuration(), *httpSender.notification.TTL) assert.Equal(t, params.PhoneNotificationID, httpSender.notification.NotificationID) - require.Len(t, eventDispatcher.events, 1) - assert.Equal(t, events.EventTypeMessageNotificationSent, eventDispatcher.events[0].Type()) + 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) } @@ -126,12 +126,12 @@ func TestPhoneNotificationServiceSendUsesHTTPSGatewayNotification(t *testing.T) func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T) { endpoint := "https://adapter.example.com/notify" phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} - eventDispatcher := &phoneNotificationEventDispatcher{} + eventQueue := &phoneNotificationEventQueue{} notificationRepository := &phoneNotificationRepository{} service := newPhoneNotificationServiceForTest( phone, notificationRepository, - eventDispatcher, + eventQueue, &recordingNotificationSender{}, &recordingNotificationSender{err: errors.New("adapter unavailable")}, ) @@ -145,10 +145,10 @@ func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T require.NoError(t, service.Send(context.Background(), params)) - require.Len(t, eventDispatcher.events, 1) - assert.Equal(t, events.EventTypeMessageNotificationFailed, eventDispatcher.events[0].Type()) + require.Len(t, eventQueue.events, 1) + assert.Equal(t, events.EventTypeMessageNotificationFailed, eventQueue.events[0].Type()) var payload events.MessageNotificationFailedPayload - require.NoError(t, eventDispatcher.events[0].DataAs(&payload)) + 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) @@ -157,11 +157,11 @@ func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testing.T) { token := "fcm-token" phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &token, PhoneNumber: "+18005550199"} - eventDispatcher := &phoneNotificationEventDispatcher{} + eventQueue := &phoneNotificationEventQueue{} service := newPhoneNotificationServiceForTest( phone, &phoneNotificationRepository{}, - eventDispatcher, + eventQueue, &recordingNotificationSender{err: errors.New("firebase unavailable")}, &recordingNotificationSender{}, ) @@ -175,9 +175,9 @@ func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testi require.NoError(t, service.Send(context.Background(), params)) - require.Len(t, eventDispatcher.events, 1) + require.Len(t, eventQueue.events, 1) var payload events.MessageNotificationFailedPayload - require.NoError(t, eventDispatcher.events[0].DataAs(&payload)) + 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) } @@ -185,17 +185,18 @@ func TestPhoneNotificationServiceSendDoesNotLogNotificationToken(t *testing.T) { endpoint := "https://adapter.example.com/private-token" phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} logger := &phoneNotificationLogger{} + tracer := telemetry.NewOtelLogger("test", logger) service := NewNotificationService( logger, - telemetry.NewOtelLogger("test", logger), - NewNotificationDispatcher( + tracer, + NewPhoneNotificationDispatcher( &recordingNotificationSender{}, &recordingNotificationSender{err: errors.New("POST " + endpoint + " failed")}, ), &phoneNotificationPhoneRepository{phone: phone}, &phoneNotificationRepository{}, nil, - &phoneNotificationEventDispatcher{}, + NewEventDispatcher(logger, tracer, nil, &phoneNotificationEventQueue{}, PushQueueConfig{}), ) require.NoError(t, service.Send(context.Background(), &PhoneNotificationSendParams{ @@ -218,7 +219,7 @@ func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSGatewayNotification(t service := newPhoneNotificationServiceForTest( phone, &phoneNotificationRepository{}, - &phoneNotificationEventDispatcher{}, + &phoneNotificationEventQueue{}, &recordingNotificationSender{}, httpSender, ) @@ -241,18 +242,19 @@ func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSGatewayNotification(t func newPhoneNotificationServiceForTest( phone *entities.Phone, notificationRepository repositories.PhoneNotificationRepository, - eventDispatcher NotificationEventDispatcher, + eventQueue *phoneNotificationEventQueue, fcmSender NotificationSender, httpSender NotificationSender, ) *PhoneNotificationService { logger := &phoneNotificationLogger{} + tracer := telemetry.NewOtelLogger("test", logger) return NewNotificationService( logger, - telemetry.NewOtelLogger("test", logger), - NewNotificationDispatcher(fcmSender, httpSender), + tracer, + NewPhoneNotificationDispatcher(fcmSender, httpSender), &phoneNotificationPhoneRepository{phone: phone}, notificationRepository, nil, - eventDispatcher, + 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 1e262685..9e21d26a 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -20,7 +20,6 @@ type PhoneHandlerValidator struct { logger telemetry.Logger tracer telemetry.Tracer scheduleService *services.MessageSendScheduleService - endpointPolicy *services.NotificationEndpointPolicy } // NewPhoneHandlerValidator creates a new handlers.PhoneHandler validator @@ -28,13 +27,11 @@ func NewPhoneHandlerValidator( logger telemetry.Logger, tracer telemetry.Tracer, scheduleService *services.MessageSendScheduleService, - endpointPolicy *services.NotificationEndpointPolicy, ) (v *PhoneHandlerValidator) { return &PhoneHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, scheduleService: scheduleService, - endpointPolicy: endpointPolicy, } } @@ -106,7 +103,7 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user return result } - validator.validateNotificationToken(ctx, request.FcmToken, result) + validator.validateNotificationToken(request.FcmToken, result) if len(result) > 0 { return result } @@ -122,7 +119,7 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user } // ValidateFCMToken validates requests.PhoneFCMToken -func (validator *PhoneHandlerValidator) ValidateFCMToken(ctx context.Context, request requests.PhoneFCMToken) url.Values { +func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, request requests.PhoneFCMToken) url.Values { v := govalidator.New(govalidator.Options{ Data: &request, Rules: govalidator.MapData{ @@ -146,12 +143,11 @@ func (validator *PhoneHandlerValidator) ValidateFCMToken(ctx context.Context, re return result } - validator.validateNotificationToken(ctx, request.FcmToken, result) + validator.validateNotificationToken(request.FcmToken, result) return result } func (validator *PhoneHandlerValidator) validateNotificationToken( - ctx context.Context, token string, result url.Values, ) { @@ -161,22 +157,9 @@ func (validator *PhoneHandlerValidator) validateNotificationToken( } phone := &entities.Phone{FcmToken: &token} - transport, err := phone.NotificationTransport() + _, 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") } } diff --git a/api/pkg/validators/phone_handler_validator_test.go b/api/pkg/validators/phone_handler_validator_test.go index 0dd5c2e1..c9dc72b6 100644 --- a/api/pkg/validators/phone_handler_validator_test.go +++ b/api/pkg/validators/phone_handler_validator_test.go @@ -2,37 +2,17 @@ package validators import ( "context" - "errors" - "net/netip" + "net/url" "testing" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/requests" - "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/stretchr/testify/assert" ) -type phoneValidatorStaticHostResolver struct { - addresses map[string][]netip.Addr - err error -} - -func (resolver *phoneValidatorStaticHostResolver) LookupNetIP( - _ context.Context, - _ string, - host string, -) ([]netip.Addr, error) { - if resolver.err != nil { - return nil, resolver.err - } - return resolver.addresses[host], nil -} - -func TestPhoneHandlerValidatorAcceptsPublicHTTPSNotificationURL(t *testing.T) { - validator := newPhoneHandlerValidatorWithAddresses(map[string][]netip.Addr{ - "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, - }) +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURL(t *testing.T) { + validator := newPhoneHandlerValidator() errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ PhoneNumber: "+18005550199", @@ -43,10 +23,8 @@ func TestPhoneHandlerValidatorAcceptsPublicHTTPSNotificationURL(t *testing.T) { assert.Empty(t, errors) } -func TestPhoneHandlerValidatorAcceptsPublicHTTPSNotificationURLOnUpsert(t *testing.T) { - validator := newPhoneHandlerValidatorWithAddresses(map[string][]netip.Addr{ - "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, - }) +func TestPhoneHandlerValidatorAcceptsHTTPSNotificationURLOnUpsert(t *testing.T) { + validator := newPhoneHandlerValidator() errors := validator.ValidateUpsert(context.Background(), "", requests.PhoneUpsert{ PhoneNumber: "+18005550199", @@ -58,39 +36,32 @@ func TestPhoneHandlerValidatorAcceptsPublicHTTPSNotificationURLOnUpsert(t *testi assert.Empty(t, errors) } -func TestPhoneHandlerValidatorRejectsUnsafeNotificationURLs(t *testing.T) { +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 - addresses []netip.Addr + name string + token string }{ - { - name: "insecure HTTP", - token: "http://adapter.example.com/notify", - addresses: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, - }, - { - name: "loopback resolution", - token: "https://adapter.example.com/notify", - addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}, - }, - { - name: "private resolution", - token: "https://adapter.example.com/notify", - addresses: []netip.Addr{netip.MustParseAddr("10.0.0.5")}, - }, - { - name: "mixed public and private resolution", - token: "https://adapter.example.com/notify", - addresses: []netip.Addr{ - netip.MustParseAddr("8.8.8.8"), - netip.MustParseAddr("10.0.0.5"), - }, - }, - { - name: "malformed HTTPS", - token: "https://%", - }, + {name: "insecure HTTP", token: "http://adapter.example.com/notify"}, + {name: "missing host", token: "https:///notify"}, + {name: "malformed HTTPS", token: "https://%"}, } validationPaths := []struct { @@ -124,11 +95,7 @@ func TestPhoneHandlerValidatorRejectsUnsafeNotificationURLs(t *testing.T) { t.Run(validationPath.name, func(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - validator := newPhoneHandlerValidatorWithAddresses(map[string][]netip.Addr{ - "adapter.example.com": test.addresses, - }) - - validationErrors := validationPath.validate(validator, test.token) + validationErrors := validationPath.validate(newPhoneHandlerValidator(), test.token) assert.NotEmpty(t, validationErrors["fcm_token"]) }) @@ -137,16 +104,8 @@ func TestPhoneHandlerValidatorRejectsUnsafeNotificationURLs(t *testing.T) { } } -func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationTokenWithoutResolution(t *testing.T) { - logger := &contactValidatorNoopLogger{} - validator := NewPhoneHandlerValidator( - logger, - telemetry.NewOtelLogger("test", logger), - nil, - services.NewNotificationEndpointPolicy(&phoneValidatorStaticHostResolver{ - err: errors.New("resolver must not be called for Firebase tokens"), - }, nil), - ) +func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationToken(t *testing.T) { + validator := newPhoneHandlerValidator() errors := validator.ValidateFCMToken(context.Background(), requests.PhoneFCMToken{ PhoneNumber: "+18005550199", @@ -158,27 +117,28 @@ func TestPhoneHandlerValidatorAcceptsOpaqueFirebaseNotificationTokenWithoutResol } func TestPhoneHandlerValidatorAcceptsNotificationURLWithUserInformation(t *testing.T) { - validator := newPhoneHandlerValidatorWithAddresses(map[string][]netip.Addr{ - "adapter.example.com": {netip.MustParseAddr("8.8.8.8")}, - }) + 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: "https://adapter-user:adapter-password@adapter.example.com/notify", + FcmToken: endpoint.String(), SIM: entities.SIM1.String(), }) assert.Empty(t, errors) } -func newPhoneHandlerValidatorWithAddresses(addresses map[string][]netip.Addr) *PhoneHandlerValidator { +func newPhoneHandlerValidator() *PhoneHandlerValidator { logger := &contactValidatorNoopLogger{} return NewPhoneHandlerValidator( logger, telemetry.NewOtelLogger("test", logger), nil, - services.NewNotificationEndpointPolicy(&phoneValidatorStaticHostResolver{ - addresses: addresses, - }, 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 index 3cfb59b2..18a20a1d 100644 --- 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 @@ -2,18 +2,39 @@ > **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 a public HTTPS URL to receive message and heartbeat wake-ups over HTTP while preserving the current scheduling, backpressure, outstanding-message, and status-event flows. +**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`, then route a transport-neutral `GatewayNotification` through a dispatcher backed by Firebase and HTTP senders. The HTTP path uses a shared endpoint policy at validation and connection time, bounded retries, FCM-compatible JSON, and the existing notification success/failure state transitions. +**Architecture:** Add transport helpers to `entities.Phone`, then route a transport-neutral `GatewayNotification` through Firebase and HTTP senders. 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`, `net/netip`, Testify, Docker Compose. +**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 + `http.DefaultTransport` as the final parent. +- A telemetry-only seam exposes only scheme and host/port to OTel while the + default transport receives the original URL, headers, and body. +- `HTTPNotificationSender` owns exactly three application attempts through + `retry.New(...).Do`, with a fresh request/body and five-second context per + attempt. +- No endpoint DNS/IP/SSRF policy, custom notification transport, or private-host + allowlist is part of the final implementation. +- The transport router is `PhoneNotificationDispatcher`; 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 public `https://` URL selects HTTP; an opaque non-URL token selects Firebase. +- 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. @@ -21,10 +42,8 @@ - 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. -- Reject redirects, proxies, private destinations, loopback destinations, link-local destinations, and reserved destinations. -- Allow a private destination only when its exact hostname is explicitly - allowlisted by the DI container in `ENV=local`; production never reads the - allowlist. +- 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. @@ -811,7 +830,8 @@ Add table-driven tests for: - `500`, `502`, then `204`: three calls, success; - `400`: one call, error; - three `503` responses: three calls, error; -- redirect `302`: one call, 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. @@ -1300,10 +1320,7 @@ func (container *Container) NotificationEndpointPolicy() *services.NotificationE return container.notificationEndpointPolicy } - allowedPrivateHosts := []string{} - if isLocal() { - allowedPrivateHosts = splitCommaEnv("NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST", "") - } + allowedPrivateHosts := []string{} // Superseded: no private-host allowlist is configured. container.notificationEndpointPolicy = services.NewNotificationEndpointPolicy( net.DefaultResolver, allowedPrivateHosts, @@ -1335,9 +1352,6 @@ func (container *Container) NotificationHTTPClient() *http.Client { otelroundtripper.WithParent(transport), otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), ), - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, } } ``` @@ -1769,11 +1783,9 @@ container and set: SSL_CERT_FILE: /adapter-certs/ca.pem ``` -Add to `tests/.env.test`: - -```text -NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator -``` +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** 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 index 9fb888f2..4ca91637 100644 --- 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 @@ -21,7 +21,6 @@ Firebase. Customer-controlled callback URLs create additional security and delivery requirements: -- outbound requests must not provide an SSRF path into httpSMS infrastructure; - callbacks are wake-up hints, not proof that a message was sent; - callback delivery is at least once and must have an idempotency identity; - transient endpoint failures need bounded retries; @@ -33,26 +32,25 @@ requirements: 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 public `https://` URL selects HTTP delivery. Non-URL tokens select - Firebase. URL-like but malformed or unsupported values are rejected. -- Use a transport-neutral notification dispatcher with separate Firebase and - HTTP senders. +- A valid `https://` URL with a hostname selects HTTP delivery. Non-URL tokens + select Firebase. URL-like but malformed or unsupported values are rejected. +- Use `PhoneNotificationDispatcher` with separate Firebase and HTTP senders; + 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 a five-second timeout per attempt. +- 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. -- Restrict production callback destinations to public HTTPS endpoints. -- Permit private callback resolution only for exact hostnames on an explicit - allowlist that the DI container reads when `ENV=local`. This exists for the - Docker integration emulator and is never enabled implicitly. +- 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. @@ -280,7 +278,7 @@ 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 public HTTPS URL. + 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 @@ -303,47 +301,22 @@ 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. SSRF protections +### 8. HTTP client and retry ownership -Because any customer can configure the URL, endpoint policy is part of the -feature rather than optional hardening. +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. -Accepted destinations must: +`Container.NotificationHTTPClient` is a standard `http.Client` using the +existing `go-otelroundtripper` pattern without transport-level retries. A +telemetry-only seam presents scheme and host/port to OpenTelemetry while +`http.DefaultTransport` receives the original request URL, headers, and body. +The client preserves Go's default redirect behavior. -- use `https`; -- include a DNS hostname or public IP; -- resolve only to public, globally routable IP addresses. - -Reject destinations resolving to loopback, private, link-local, multicast, -unspecified, carrier-grade NAT, documentation, benchmarking, and other -non-public reserved ranges for both IPv4 and IPv6. - -Apply policy at two points: - -1. **Registration/update validation:** provide an immediate validation error for - unsafe or unresolvable URL tokens. -2. **Connection time:** resolve and validate again, then dial a validated IP - while preserving TLS Server Name Indication and hostname certificate - verification. - -The connection-time check prevents DNS rebinding between phone registration and -notification delivery. A custom `DialContext` or equivalent must ensure the -validated address is the address actually dialed; a check followed by a normal -second DNS lookup is insufficient. - -The HTTP client: - -- does not inherit environment proxy settings; -- refuses redirects rather than following them to an unchecked destination; -- uses the approved per-attempt timeout; -- retains OpenTelemetry instrumentation around the SSRF-safe transport. - -The endpoint policy accepts an optional exact-host private-destination -allowlist. The DI container passes configured values only when `ENV=local`; -production ignores the setting. Allowlisting a hostname permits its private -DNS answers but does not permit HTTP, redirects, proxy use, or a different -hostname. Unit tests use injected resolvers and dialers. -The Docker integration stack allowlists only `adapter-emulator`. +`HTTPNotificationSender` owns retries through `retry.New(...).Do`. It makes +exactly three total attempts, creates a fresh request and body for each +attempt, and applies a five-second context per attempt. ### 9. Validation and API compatibility @@ -356,20 +329,16 @@ Keep these public fields and routes unchanged: Extend phone validation only when `fcm_token` is URL-like: -- enforce valid public HTTPS endpoint policy; +- enforce valid HTTPS syntax and require a hostname; - preserve the existing maximum token length; -- return field-level `fcm_token` validation errors for malformed, unsafe, or - unresolvable destinations. +- 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. -The URL policy should be a reusable component with an injectable resolver so -request validation and connection-time checks apply the same address rules and -remain deterministic in tests. - Update request and Swagger descriptions to explain that `fcm_token` accepts -either an FCM registration token or a public HTTPS adapter callback URL. +either an FCM registration token or an HTTPS adapter callback URL. Regenerate Swagger documentation after implementation. ### 10. Observability and sensitive values @@ -406,12 +375,10 @@ Implementation is expected to touch: sender interface, and dispatcher; - `api/pkg/services/http_notification_sender.go` for HTTP payload encoding and delivery; -- `api/pkg/services/notification_endpoint_policy.go` for URL and resolved-IP - validation; - `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, HTTP client, resolver, and sender - construction; +- `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; @@ -429,24 +396,16 @@ handling, or phone API-key authorization into the new transport code. Cover: - ordinary FCM tokens selecting Firebase; -- valid public HTTPS URLs selecting HTTP; +- 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. -### Endpoint policy tests - -Use an injectable resolver and dialer to cover: +### HTTP client wiring tests -- public IPv4 and IPv6 acceptance; -- loopback, private, link-local, multicast, unspecified, carrier-grade NAT, and - reserved-range rejection; -- mixed DNS answers being rejected if any candidate is unsafe; -- validation-time and connection-time checks; -- DNS rebinding attempts; -- redirects being refused; -- environment proxy settings being ignored; -- TLS hostname verification remaining enabled. +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 @@ -499,16 +458,9 @@ 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. The API runs with: - -```text -NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator -``` - -Because `.env.test` uses `ENV=local`, the exact hostname can resolve to the -Docker-private emulator address while the full HTTPS, TLS verification, -payload, dispatch, and API callback paths remain exercised. No insecure HTTP -callback exception is added. +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: @@ -558,7 +510,7 @@ delivery. Initial rollout should watch: - HTTP callback success and retry rates; - terminal failures by status class; - callback latency; -- endpoint-policy rejections; +- transport failures; - message expiration after a successful HTTP wake-up; - duplicate notification IDs observed by test adapters. @@ -578,5 +530,4 @@ URL-backed phones stop receiving wake-ups if the feature is rolled back. - Web UI for configuring adapters. - Android application changes. - General-purpose outbound webhook refactoring. -- Private callback destinations outside the exact local-only integration-test - allowlist. +- Destination-specific DNS/IP filtering or allowlists. diff --git a/tests/.env.test b/tests/.env.test index 5bfdb6c8..c4c1e3ef 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -8,7 +8,6 @@ EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events EVENTS_QUEUE_USER_API_KEY=system-user-api-key EVENTS_QUEUE_USER_ID=system-user-id FCM_ENDPOINT=http://wiremock:8080 -NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator DATABASE_URL=postgresql://root@cockroachdb:26257/httpsms?sslmode=disable DATABASE_URL_DEDICATED=postgresql://root@cockroachdb:26257/httpsms?sslmode=disable DATABASE_MIGRATION_CONSTRAINT_FIX=1 diff --git a/tests/README.md b/tests/README.md index e2eef517..35c144b6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -60,8 +60,8 @@ Data stores: CockroachDB, Redis, and MongoDB. 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. Local SSRF policy allows -the exact private hostname `adapter-emulator`. +`SSL_CERT_FILE`; HTTPS verification is never bypassed. The notification sender +uses the standard OpenTelemetry-instrumented Go HTTP transport. ## Test Coverage @@ -71,7 +71,7 @@ the exact private hostname `adapter-emulator`. - [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 and exact private-host allowlist are exercised +- [x] HTTPS certificate trust is exercised ## Prerequisites @@ -198,9 +198,8 @@ docker compose logs --tail 200 api adapter-emulator ``` Adapter logs should show callback receipt, the outstanding-message fetch, -`SENT`, and `DELIVERED`. Confirm -`NOTIFICATION_ENDPOINT_PRIVATE_HOST_ALLOWLIST=adapter-emulator` and -`SSL_CERT_FILE=/adapter-certs/ca.pem` are present in the API container. +`SENT`, and `DELIVERED`. Confirm `SSL_CERT_FILE=/adapter-certs/ca.pem` is +present in the API container. ### URL-backed incoming message times out From cc133f80611dd34c6eaaaa3a4c0da05a5508a1ae Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 19:22:55 +0300 Subject: [PATCH 19/22] refactor(api): use standard logging paths Use the same OpenTelemetry HTTP client as webhooks and preserve default request, database, and notification logging without feature-specific redaction. Keep GORM query variables in traces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/di/container.go | 8 +- api/pkg/di/container_test.go | 100 +----------------- api/pkg/di/notification_http_round_tripper.go | 62 ----------- api/pkg/handlers/phone_handler.go | 12 +-- api/pkg/handlers/phone_handler_log_test.go | 17 --- .../http_request_logger_middleware.go | 3 +- .../http_request_logger_middleware_test.go | 59 ----------- .../services/phone_notification_service.go | 15 +-- .../phone_notification_service_test.go | 32 ------ api/pkg/telemetry/gorm_logger.go | 8 -- api/pkg/telemetry/gorm_logger_test.go | 24 ----- api/pkg/telemetry/redaction.go | 77 -------------- api/pkg/telemetry/redaction_test.go | 35 ------ ...2-url-backed-phone-notification-adapter.md | 10 +- ...acked-phone-notification-adapter-design.md | 25 +---- 15 files changed, 23 insertions(+), 464 deletions(-) delete mode 100644 api/pkg/di/notification_http_round_tripper.go delete mode 100644 api/pkg/handlers/phone_handler_log_test.go delete mode 100644 api/pkg/middlewares/http_request_logger_middleware_test.go delete mode 100644 api/pkg/telemetry/gorm_logger_test.go delete mode 100644 api/pkg/telemetry/redaction.go delete mode 100644 api/pkg/telemetry/redaction_test.go diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 736052b4..0d5a8387 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -272,7 +272,7 @@ func (container *Container) DedicatedDB() (db *gorm.DB) { container.logger.Fatal(err) } - if err = db.Use(tracing.NewPlugin(tracing.WithoutQueryVariables())); err != nil { + if err = db.Use(tracing.NewPlugin()); err != nil { container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } @@ -330,7 +330,7 @@ func (container *Container) DBWithoutMigration() (db *gorm.DB) { } container.db = db - if err = db.Use(tracing.NewPlugin(tracing.WithoutQueryVariables())); err != nil { + if err = db.Use(tracing.NewPlugin()); err != nil { container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } return container.db @@ -355,7 +355,7 @@ func (container *Container) DB() (db *gorm.DB) { } container.db = db - if err = db.Use(tracing.NewPlugin(tracing.WithoutQueryVariables())); err != nil { + if err = db.Use(tracing.NewPlugin()); err != nil { container.logger.Fatal(stacktrace.Propagatef(err, "cannot use GORM tracing plugin")) } @@ -568,7 +568,7 @@ func (container *Container) FCMClient() services.FCMClient { // NotificationHTTPClient creates the OpenTelemetry-instrumented client for phone notification adapters. func (container *Container) NotificationHTTPClient() *http.Client { return &http.Client{ - Transport: container.notificationHTTPRoundTripper(http.DefaultTransport), + Transport: container.HTTPRoundTripperWithoutRetry("phone_notification_http"), } } diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go index 8e55993d..2da36efb 100644 --- a/api/pkg/di/container_test.go +++ b/api/pkg/di/container_test.go @@ -1,20 +1,10 @@ package di import ( - "bytes" - "context" - "io" - "net/http" - "net/url" "reflect" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/metric/metricdata" ) func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) { @@ -22,9 +12,7 @@ func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) client := NewLiteContainer().NotificationHTTPClient() assert.Zero(t, client.Timeout) - transport, ok := client.Transport.(*notificationTelemetryRoundTripper) - require.True(t, ok) - assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(transport.telemetry).String()) + assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(client.Transport).String()) assert.Nil(t, client.CheckRedirect) } @@ -37,91 +25,7 @@ func TestPhoneNotificationDispatcherInjectsNotificationHTTPClient(t *testing.T) client := httpSender.FieldByName("client").Elem() transport := client.FieldByName("Transport").Elem() - assert.Equal(t, "*di.notificationTelemetryRoundTripper", transport.Type().String()) + assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String()) attemptRecorder := httpSender.FieldByName("attemptRecorder").Elem() assert.Equal(t, "*services.otelNotificationHTTPAttemptRecorder", attemptRecorder.Type().String()) } - -func TestNotificationHTTPRoundTripperSanitizesTelemetryURLOnly(t *testing.T) { - t.Setenv("ENV", "local") - previousMeterProvider := otel.GetMeterProvider() - reader := sdkmetric.NewManualReader() - meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) - otel.SetMeterProvider(meterProvider) - t.Cleanup(func() { - otel.SetMeterProvider(previousMeterProvider) - require.NoError(t, meterProvider.Shutdown(context.Background())) - }) - - var parentRequest *http.Request - var parentBody string - parent := notificationRoundTripFunc(func(request *http.Request) (*http.Response, error) { - parentRequest = request - body, err := io.ReadAll(request.Body) - require.NoError(t, err) - parentBody = string(body) - return &http.Response{ - StatusCode: http.StatusNoContent, - Body: http.NoBody, - Header: make(http.Header), - Request: request, - }, nil - }) - client := &http.Client{ - Transport: NewLiteContainer().notificationHTTPRoundTripper(parent), - } - endpoint := &url.URL{ - Scheme: "https", - User: url.UserPassword("adapter-user", "adapter-password"), - Host: "adapter.example.com:8443", - Path: "/secret/path", - RawQuery: "token=customer-secret", - Fragment: "private-fragment", - } - request, err := http.NewRequestWithContext( - context.Background(), - http.MethodPost, - endpoint.String(), - bytes.NewBufferString("notification-body"), - ) - require.NoError(t, err) - request.Header.Set("X-Test-Header", "test-value") - - response, err := client.Do(request) - - require.NoError(t, err) - require.NoError(t, response.Body.Close()) - require.NotNil(t, parentRequest) - assert.Equal(t, endpoint.String(), parentRequest.URL.String()) - assert.Equal(t, "test-value", parentRequest.Header.Get("X-Test-Header")) - assert.Equal(t, "notification-body", parentBody) - username, password, hasBasicAuth := parentRequest.BasicAuth() - assert.True(t, hasBasicAuth) - assert.Equal(t, "adapter-user", username) - assert.Equal(t, "adapter-password", password) - - var metrics metricdata.ResourceMetrics - require.NoError(t, reader.Collect(context.Background(), &metrics)) - var telemetryURLs []string - for _, scopeMetrics := range metrics.ScopeMetrics { - for _, measured := range scopeMetrics.Metrics { - if measured.Name != "phone_notification_http.attempts" { - continue - } - sum, ok := measured.Data.(metricdata.Sum[int64]) - require.True(t, ok) - for _, point := range sum.DataPoints { - value, ok := point.Attributes.Value(attribute.Key("http.url")) - require.True(t, ok) - telemetryURLs = append(telemetryURLs, value.AsString()) - } - } - } - assert.Equal(t, []string{"https://adapter.example.com:8443"}, telemetryURLs) -} - -type notificationRoundTripFunc func(*http.Request) (*http.Response, error) - -func (roundTrip notificationRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { - return roundTrip(request) -} diff --git a/api/pkg/di/notification_http_round_tripper.go b/api/pkg/di/notification_http_round_tripper.go deleted file mode 100644 index 72b9a972..00000000 --- a/api/pkg/di/notification_http_round_tripper.go +++ /dev/null @@ -1,62 +0,0 @@ -package di - -import ( - "context" - "net/http" - "net/url" - - "github.com/NdoleStudio/go-otelroundtripper" - "go.opentelemetry.io/otel" -) - -type notificationOriginalRequestContextKey struct{} - -// notificationTelemetryRoundTripper gives telemetry a sanitized request while preserving delivery semantics. -type notificationTelemetryRoundTripper struct { - telemetry http.RoundTripper -} - -func (roundTripper *notificationTelemetryRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { - if request == nil || request.URL == nil { - return roundTripper.telemetry.RoundTrip(request) - } - - ctx := context.WithValue(request.Context(), notificationOriginalRequestContextKey{}, request) - telemetryRequest := request.Clone(ctx) - telemetryRequest.URL = notificationTelemetryURL(request.URL) - - return roundTripper.telemetry.RoundTrip(telemetryRequest) -} - -type notificationOriginalRequestRoundTripper struct { - parent http.RoundTripper -} - -// RoundTrip restores the original request before the standard transport sends it. -func (roundTripper *notificationOriginalRequestRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { - originalRequest, ok := request.Context().Value(notificationOriginalRequestContextKey{}).(*http.Request) - if !ok { - originalRequest = request - } - - return roundTripper.parent.RoundTrip(originalRequest) -} - -func (container *Container) notificationHTTPRoundTripper(parent http.RoundTripper) http.RoundTripper { - originalRequestRoundTripper := ¬ificationOriginalRequestRoundTripper{parent: parent} - - return ¬ificationTelemetryRoundTripper{ - telemetry: otelroundtripper.New( - otelroundtripper.WithName("phone_notification_http"), - otelroundtripper.WithParent(originalRequestRoundTripper), - otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), - ), - } -} - -func notificationTelemetryURL(requestURL *url.URL) *url.URL { - return &url.URL{ - Scheme: requestURL.Scheme, - Host: requestURL.Host, - } -} diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index 60e7e94e..3a3fcfaf 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -122,7 +122,7 @@ func (h *PhoneHandler) Upsert(c fiber.Ctx) error { ctxLogger.Warn(stacktrace.NewErrorf( "validation errors [%s], while updating phone request [%s]", spew.Sdump(errors), - redactedPhoneRequestBody(c.Body()), + c.Body(), )) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } @@ -132,7 +132,7 @@ func (h *PhoneHandler) Upsert(c fiber.Ctx) error { ctxLogger.Error(stacktrace.Propagatef( err, "cannot update phone with request [%s]", - redactedPhoneRequestBody(c.Body()), + c.Body(), )) return h.responseInternalServerError(c) } @@ -208,7 +208,7 @@ func (h *PhoneHandler) UpsertFCMToken(c fiber.Ctx) error { ctxLogger.Warn(stacktrace.NewErrorf( "validation errors [%s], while updating phone token request [%s]", spew.Sdump(errors), - redactedPhoneRequestBody(c.Body()), + c.Body(), )) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } @@ -218,14 +218,10 @@ func (h *PhoneHandler) UpsertFCMToken(c fiber.Ctx) error { ctxLogger.Error(stacktrace.Propagatef( err, "cannot update phone token with request [%s]", - redactedPhoneRequestBody(c.Body()), + c.Body(), )) return h.responseInternalServerError(c) } return h.responseOK(c, "FCM token updated successfully", phone) } - -func redactedPhoneRequestBody(body []byte) string { - return telemetry.RedactJSONFields(body, "fcm_token") -} diff --git a/api/pkg/handlers/phone_handler_log_test.go b/api/pkg/handlers/phone_handler_log_test.go deleted file mode 100644 index 2e24f80f..00000000 --- a/api/pkg/handlers/phone_handler_log_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package handlers - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRedactedPhoneRequestBodyDoesNotExposeFCMToken(t *testing.T) { - body := []byte(`{"phone_number":"+18005550199","fcm_token":"https://adapter.example.com/secret?token=customer-secret"}`) - - redacted := redactedPhoneRequestBody(body) - - assert.Contains(t, redacted, "[redacted]") - assert.NotContains(t, redacted, "adapter.example.com") - assert.NotContains(t, redacted, "customer-secret") -} diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go index d3470953..cd11c600 100644 --- a/api/pkg/middlewares/http_request_logger_middleware.go +++ b/api/pkg/middlewares/http_request_logger_middleware.go @@ -24,8 +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) { - body := telemetry.RedactJSONFields(c.Request().Body(), "fcm_token") - ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewErrorf("http.status [%d], body [%s]", statusCode, 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/middlewares/http_request_logger_middleware_test.go b/api/pkg/middlewares/http_request_logger_middleware_test.go deleted file mode 100644 index 5ad039a0..00000000 --- a/api/pkg/middlewares/http_request_logger_middleware_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package middlewares - -import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/gofiber/fiber/v3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel/trace" -) - -func TestHTTPRequestLoggerRedactsFCMTokenFromFailedRequestBody(t *testing.T) { - logger := &requestLoggerRecordingLogger{} - app := fiber.New() - app.Use(HTTPRequestLogger(telemetry.NewOtelLogger("test", logger), logger)) - app.Put("/v1/phones", func(c fiber.Ctx) error { - return c.SendStatus(http.StatusUnprocessableEntity) - }) - body := `{"phone_number":"+18005550199","fcm_token":"https://adapter.example.com/secret?token=customer-secret"}` - request := httptest.NewRequest(http.MethodPut, "/v1/phones", bytes.NewBufferString(body)) - request.Header.Set("Content-Type", "application/json") - - response, err := app.Test(request) - - require.NoError(t, err) - require.NoError(t, response.Body.Close()) - logged := strings.Join(logger.warnings, "\n") - assert.Contains(t, logged, "[redacted]") - assert.NotContains(t, logged, "adapter.example.com") - assert.NotContains(t, logged, "customer-secret") -} - -type requestLoggerRecordingLogger struct { - warnings []string -} - -func (logger *requestLoggerRecordingLogger) Error(error) {} -func (logger *requestLoggerRecordingLogger) WithService(string) telemetry.Logger { - return logger -} -func (logger *requestLoggerRecordingLogger) WithString(string, string) telemetry.Logger { - return logger -} -func (logger *requestLoggerRecordingLogger) WithSpan(trace.SpanContext) telemetry.Logger { - return logger -} -func (logger *requestLoggerRecordingLogger) Trace(string) {} -func (logger *requestLoggerRecordingLogger) Info(string) {} -func (logger *requestLoggerRecordingLogger) Warn(err error) { - logger.warnings = append(logger.warnings, err.Error()) -} -func (logger *requestLoggerRecordingLogger) Debug(string) {} -func (logger *requestLoggerRecordingLogger) Fatal(error) {} -func (logger *requestLoggerRecordingLogger) Printf(string, ...interface{}) {} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index e0acad73..c5366ee9 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "github.com/NdoleStudio/httpsms/pkg/events" @@ -100,7 +99,7 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p }) if err != nil { ctxLogger.Warn(stacktrace.Propagatef( - redactNotificationToken(err, *phone.FcmToken), + err, "cannot send heartbeat notification to phone with id [%s] for user [%s]", phone.ID, phone.UserID, @@ -157,7 +156,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone transport, transportErr := phone.NotificationTransport() if transportErr != nil { ctxLogger.Warn(stacktrace.Propagatef( - redactNotificationToken(transportErr, *phone.FcmToken), + transportErr, "cannot determine notification transport for phone with ID [%s] for user with ID [%s] and message [%s]", phone.ID, phone.UserID, @@ -168,7 +167,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ctxLogger.Warn(stacktrace.Propagatef( - redactNotificationToken(err, *phone.FcmToken), + err, "cannot send %s notification to phone with ID [%s] for user with ID [%s] and message [%s]", transport, phone.ID, @@ -188,14 +187,6 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone return service.handleNotificationSent(ctx, phone, result, params) } -func redactNotificationToken(err error, token string) error { - token = strings.TrimSpace(token) - if token == "" { - return err - } - return errors.New(strings.ReplaceAll(err.Error(), token, "[redacted]")) -} - // 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 index e556c1ce..0c6504fb 100644 --- a/api/pkg/services/phone_notification_service_test.go +++ b/api/pkg/services/phone_notification_service_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "strings" "testing" "time" @@ -181,37 +180,6 @@ func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testi assert.Equal(t, "cannot send notification to your phone [+18005550199]. Reinstall the httpSMS app on your Android phone.", payload.ErrorMessage) } -func TestPhoneNotificationServiceSendDoesNotLogNotificationToken(t *testing.T) { - endpoint := "https://adapter.example.com/private-token" - phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint, PhoneNumber: "+18005550199"} - logger := &phoneNotificationLogger{} - tracer := telemetry.NewOtelLogger("test", logger) - service := NewNotificationService( - logger, - tracer, - NewPhoneNotificationDispatcher( - &recordingNotificationSender{}, - &recordingNotificationSender{err: errors.New("POST " + endpoint + " failed")}, - ), - &phoneNotificationPhoneRepository{phone: phone}, - &phoneNotificationRepository{}, - nil, - NewEventDispatcher(logger, tracer, nil, &phoneNotificationEventQueue{}, PushQueueConfig{}), - ) - - require.NoError(t, service.Send(context.Background(), &PhoneNotificationSendParams{ - UserID: phone.UserID, - PhoneID: phone.ID, - PhoneNotificationID: uuid.New(), - Source: "test", - MessageID: uuid.New(), - })) - - for _, warning := range logger.warnings { - assert.False(t, strings.Contains(warning, endpoint), "warning exposes notification token: %s", warning) - } -} - func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSGatewayNotification(t *testing.T) { endpoint := "https://adapter.example.com/notify" phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} diff --git a/api/pkg/telemetry/gorm_logger.go b/api/pkg/telemetry/gorm_logger.go index 7da1c34e..7f9d9517 100644 --- a/api/pkg/telemetry/gorm_logger.go +++ b/api/pkg/telemetry/gorm_logger.go @@ -6,7 +6,6 @@ import ( "time" "github.com/NdoleStudio/stacktrace" - "gorm.io/gorm" "gorm.io/gorm/logger" ) @@ -15,8 +14,6 @@ type gormLogger struct { logger Logger } -var _ gorm.ParamsFilter = (*gormLogger)(nil) - // NewGormLogger creates a new instance of gormLogger func NewGormLogger(tracer Tracer, logger Logger) logger.Interface { return &gormLogger{ @@ -42,11 +39,6 @@ func (gorm *gormLogger) Error(ctx context.Context, s string, i ...any) { gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Error(fmt.Errorf(s, i...)) } -// ParamsFilter keeps SQL telemetry parameterized so bound values never enter logs. -func (gorm *gormLogger) ParamsFilter(_ context.Context, sql string, _ ...any) (string, []any) { - return sql, nil -} - func (gorm *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { elapsed := time.Since(begin) l := gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).WithString("latency", elapsed.String()) diff --git a/api/pkg/telemetry/gorm_logger_test.go b/api/pkg/telemetry/gorm_logger_test.go deleted file mode 100644 index 3776afb8..00000000 --- a/api/pkg/telemetry/gorm_logger_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package telemetry - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestGormLoggerUsesParameterizedQueries(t *testing.T) { - logger := &gormLogger{} - secret := "https://adapter.example.com/secret?token=customer-secret" - - query, params := logger.ParamsFilter( - context.Background(), - `UPDATE "phones" SET "fcm_token"=$1 WHERE "id"=$2`, - secret, - "phone-id", - ) - - assert.Equal(t, `UPDATE "phones" SET "fcm_token"=$1 WHERE "id"=$2`, query) - assert.Empty(t, params) - assert.NotContains(t, query, secret) -} diff --git a/api/pkg/telemetry/redaction.go b/api/pkg/telemetry/redaction.go deleted file mode 100644 index c74a18ac..00000000 --- a/api/pkg/telemetry/redaction.go +++ /dev/null @@ -1,77 +0,0 @@ -package telemetry - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "strings" -) - -const ( - redactedLogValue = "[redacted]" - omittedRequestBodyValue = "[request body omitted]" -) - -// RedactJSONFields returns a log-safe JSON body with matching field values removed. -func RedactJSONFields(body []byte, fields ...string) string { - if len(body) == 0 { - return "" - } - - var value any - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.UseNumber() - if err := decoder.Decode(&value); err != nil { - return omittedRequestBodyValue - } - if err := ensureJSONEnd(decoder); err != nil { - return omittedRequestBodyValue - } - - sensitiveFields := make(map[string]struct{}, len(fields)) - for _, field := range fields { - sensitiveFields[strings.ToLower(field)] = struct{}{} - } - if len(sensitiveFields) > 0 { - if _, ok := value.(map[string]any); !ok { - return omittedRequestBodyValue - } - } - redactJSONValue(value, sensitiveFields) - - redacted, err := json.Marshal(value) - if err != nil { - return omittedRequestBodyValue - } - return string(redacted) -} - -func ensureJSONEnd(decoder *json.Decoder) error { - var extra any - err := decoder.Decode(&extra) - if err == io.EOF { - return nil - } - if err == nil { - return errors.New("request body contains multiple JSON values") - } - return err -} - -func redactJSONValue(value any, sensitiveFields map[string]struct{}) { - switch typed := value.(type) { - case map[string]any: - for key, child := range typed { - if _, ok := sensitiveFields[strings.ToLower(key)]; ok { - typed[key] = redactedLogValue - continue - } - redactJSONValue(child, sensitiveFields) - } - case []any: - for _, child := range typed { - redactJSONValue(child, sensitiveFields) - } - } -} diff --git a/api/pkg/telemetry/redaction_test.go b/api/pkg/telemetry/redaction_test.go deleted file mode 100644 index 2a40a593..00000000 --- a/api/pkg/telemetry/redaction_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package telemetry - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRedactJSONFieldsRemovesSensitiveValues(t *testing.T) { - body := []byte(`{"phone_number":"+18005550199","fcm_token":"https://adapter.example.com/secret?token=customer-secret","nested":{"fcm_token":"nested-secret"}}`) - - redacted := RedactJSONFields(body, "fcm_token") - - assert.Contains(t, redacted, `"phone_number":"+18005550199"`) - assert.Equal(t, 2, strings.Count(redacted, "[redacted]")) - assert.NotContains(t, redacted, "adapter.example.com") - assert.NotContains(t, redacted, "customer-secret") - assert.NotContains(t, redacted, "nested-secret") -} - -func TestRedactJSONFieldsFailsClosedForMalformedSensitiveJSON(t *testing.T) { - body := []byte(`{"fcm_token":"customer-secret"`) - - redacted := RedactJSONFields(body, "fcm_token") - - assert.Equal(t, "[request body omitted]", redacted) - assert.NotContains(t, redacted, "customer-secret") -} - -func TestRedactJSONFieldsFailsClosedForMalformedNonSensitiveBody(t *testing.T) { - body := []byte(`not-json`) - - assert.Equal(t, "[request body omitted]", RedactJSONFields(body, "fcm_token")) -} 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 index 18a20a1d..2e52bcf8 100644 --- 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 @@ -60,8 +60,8 @@ private-host allowlist. - `api/pkg/services/notification_endpoint_policy_test.go` - deterministic resolver/dialer tests, including DNS rebinding protection. - `api/pkg/services/notification_sender.go` - transport-neutral notification, sender interface, Firebase adapter, and dispatcher. - `api/pkg/services/notification_sender_test.go` - dispatcher routing and Firebase payload mapping tests. -- `api/pkg/services/http_notification_sender.go` - HTTP request encoding, retry classification, timeout, and sanitized results. -- `api/pkg/services/http_notification_sender_test.go` - payload, retry, idempotency, response, and redaction tests. +- `api/pkg/services/http_notification_sender.go` - HTTP request encoding, retry classification, and timeout. +- `api/pkg/services/http_notification_sender_test.go` - payload, retry, idempotency, 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. @@ -901,8 +901,7 @@ func notificationRetryDelay(attempt uint) time.Duration { 7. close each response body after copying at most 4 KiB to `io.Discard`; 8. return `http/` for any `2xx`; 9. retry only the approved errors/statuses while attempts remain; -10. return a stacktrace-wrapped error that contains the sanitized hostname but - not the full URL, path, query, or response body. +10. return a stacktrace-wrapped error. Set constructor defaults: @@ -924,7 +923,7 @@ retryDelay: func(ctx context.Context, delay time.Duration) error { Do not use the container's retrying HTTP client; retries belong in this sender so status classification, attempt count, and idempotency are explicit. -- [ ] **Step 6: Add redaction and heartbeat tests** +- [ ] **Step 6: Add heartbeat tests** Add a test using: @@ -1467,7 +1466,6 @@ Confirm: - `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; -- no full callback URL is logged. - [ ] **Step 12: Format and commit** 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 index 4ca91637..90fd59dc 100644 --- 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 @@ -341,26 +341,11 @@ 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 and sensitive values - -The callback URL is stored in the existing token field and may contain a -customer-controlled path or query. Treat the complete value as sensitive even -though callback requests are unsigned. - -Logs and traces must not include the full FCM token or URL. Record only: - -- selected transport; -- sanitized destination hostname for HTTP; -- phone ID; -- notification ID or heartbeat delivery ID; -- message ID where already permitted by existing telemetry; -- attempt number; -- response status class; -- success, retry, or terminal failure. - -Errors propagated to handlers and events must not embed full URLs, response -bodies, or DNS result lists. Existing stacktrace propagation and OpenTelemetry -span error behavior remain in use. +### 10. Observability + +Use the existing request, database, and OpenTelemetry logging behavior without +special redaction for notification tokens or callback URLs. Notification +attempt metrics record the attempt number, response status class, and result. ## Components and Expected Files From 5482dce07f6ae4394dcbb53267fa9ee6cd1937f6 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 19:43:39 +0300 Subject: [PATCH 20/22] refactor(api): focus notification delivery Reuse Firebase messages across transports and initialize one reusable retry policy per HTTP sender. Split phone transport dispatch into its own component and rely on the existing HTTP instrumentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/di/container.go | 1 - api/pkg/di/container_test.go | 2 - api/pkg/services/http_notification_sender.go | 313 ++++++------------ .../services/http_notification_sender_test.go | 190 +++++------ api/pkg/services/notification_sender.go | 68 +--- api/pkg/services/notification_sender_test.go | 95 ++---- .../services/phone_notification_dispatcher.go | 58 ++++ .../phone_notification_dispatcher_test.go | 105 ++++++ .../services/phone_notification_service.go | 20 +- .../phone_notification_service_test.go | 28 +- ...2-url-backed-phone-notification-adapter.md | 295 +++++++++-------- ...acked-phone-notification-adapter-design.md | 75 +++-- 12 files changed, 601 insertions(+), 649 deletions(-) create mode 100644 api/pkg/services/phone_notification_dispatcher.go create mode 100644 api/pkg/services/phone_notification_dispatcher_test.go diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 0d5a8387..45be2573 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -578,7 +578,6 @@ func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotific services.NewFCMNotificationSender(container.FCMClient()), services.NewHTTPNotificationSender( container.Logger(), - container.Tracer(), container.NotificationHTTPClient(), ), ) diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go index 2da36efb..2b0be2d8 100644 --- a/api/pkg/di/container_test.go +++ b/api/pkg/di/container_test.go @@ -26,6 +26,4 @@ func TestPhoneNotificationDispatcherInjectsNotificationHTTPClient(t *testing.T) transport := client.FieldByName("Transport").Elem() assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String()) - attemptRecorder := httpSender.FieldByName("attemptRecorder").Elem() - assert.Equal(t, "*services.otelNotificationHTTPAttemptRecorder", attemptRecorder.Type().String()) } diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index bb0e50bc..41d91e48 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -5,148 +5,84 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "net/http" "net/url" - "strconv" "time" + "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" "github.com/avast/retry-go/v5" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/propagation" - "google.golang.org/protobuf/types/known/durationpb" + "github.com/google/uuid" ) -const maxNotificationResponseDiscardBytes = 4 * 1024 - -type httpNotificationRequest struct { - Message httpNotificationMessage `json:"message"` -} - -type httpNotificationMessage struct { - Token string `json:"token"` - Data map[string]string `json:"data,omitempty"` - Android httpNotificationAndroid `json:"android,omitempty"` -} - -type httpNotificationAndroid struct { - Priority string `json:"priority,omitempty"` - TTL string `json:"ttl,omitempty"` -} +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 - tracer telemetry.Tracer - client *http.Client - attempts uint - timeout time.Duration - retryDelay time.Duration - attemptRecorder notificationHTTPAttemptRecorder + logger telemetry.Logger + client *http.Client + retrier *retry.Retrier + timeout time.Duration } // NewHTTPNotificationSender creates an HTTP notification sender. func NewHTTPNotificationSender( logger telemetry.Logger, - tracer telemetry.Tracer, client *http.Client, ) *HTTPNotificationSender { - if client == nil { - client = http.DefaultClient - } + return newHTTPNotificationSenderWithRetrier( + logger, + client, + newHTTPNotificationRetrier(notificationHTTPRetryDelay), + ) +} +func newHTTPNotificationSenderWithRetrier( + logger telemetry.Logger, + client *http.Client, + retrier *retry.Retrier, +) *HTTPNotificationSender { return &HTTPNotificationSender{ - logger: logger, - tracer: tracer, - client: client, - attempts: 3, - timeout: 5 * time.Second, - retryDelay: 250 * time.Millisecond, - attemptRecorder: newNotificationHTTPAttemptRecorder(tracer), + 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, - destination string, - notification GatewayNotification, + message *messaging.Message, + notificationID uuid.UUID, ) (string, error) { - endpoint, err := url.Parse(destination) + 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() - payload := httpNotificationRequest{ - Message: httpNotificationMessage{ - Token: destination, - Data: notification.Data, - Android: httpNotificationAndroid{ - Priority: notification.Priority, - }, - }, - } - if notification.TTL != nil { - payload.Message.Android.TTL = formatProtobufDuration(*notification.TTL) - } - body, err := json.Marshal(payload) + body, err := encodeHTTPNotificationPayload(message) if err != nil { return "", sender.notificationError(hostname, "cannot encode notification") } - if sender.attempts == 0 { - return "", sender.notificationError(hostname, "notification sender has no attempts configured") - } - - attempt := uint(0) - err = retry.New( - retry.Attempts(sender.attempts), - retry.Delay(sender.retryDelay), - retry.DelayType(retry.BackOffDelay), - retry.LastErrorOnly(true), - retry.Context(ctx), - retry.RetryIf(isRetryableNotificationError), - ).Do(func() error { - attempt++ - requestCtx, cancel := context.WithTimeout(ctx, sender.timeout) - attemptCtx := requestCtx - finishAttempt := func(int, error) {} - if sender.attemptRecorder != nil { - attemptCtx, finishAttempt = sender.attemptRecorder.Start(attemptCtx, attempt) - } - - request, requestErr := http.NewRequestWithContext( - attemptCtx, - http.MethodPost, - endpoint.String(), - bytes.NewReader(body), - ) - if requestErr != nil { - finishAttempt(0, requestErr) - cancel() - return terminalNotificationRequestError{cause: requestErr} - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("X-httpSMS-Notification-ID", notification.NotificationID.String()) - - statusCode, requestErr := sender.sendAttempt(request) - finishAttempt(statusCode, requestErr) - cancel() - - if ctx.Err() != nil { - return terminalNotificationRequestError{cause: ctx.Err()} - } - return requestErr + err = sender.retrier.Do(func() error { + return sender.deliver(ctx, endpoint, body, notificationID.String()) }) if err == nil { - return "http/" + notification.NotificationID.String(), nil + return "http/" + notificationID.String(), nil } if ctx.Err() != nil { return "", sender.notificationError(hostname, "notification request cancelled") @@ -155,26 +91,84 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError(hostname, "notification request failed") } -func (sender *HTTPNotificationSender) sendAttempt(request *http.Request) (int, error) { - otel.GetTextMapPropagator().Inject(request.Context(), propagation.HeaderCarrier(request.Header)) +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, + notificationID string, +) 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, notificationID) + 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, + notificationID string, +) (*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") + request.Header.Set("X-httpSMS-Notification-ID", notificationID) + return request, nil +} +func (sender *HTTPNotificationSender) sendAttempt(request *http.Request) error { response, err := sender.client.Do(request) if err != nil { - return 0, err + 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 response.StatusCode, nil + return nil } if isRetryableNotificationStatus(response.StatusCode) { - err = retryableNotificationStatusError{statusCode: response.StatusCode} - return response.StatusCode, err + return retryableNotificationStatusError{statusCode: response.StatusCode} } - err = terminalNotificationStatusError{statusCode: response.StatusCode} - return response.StatusCode, err + 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 { @@ -238,102 +232,3 @@ func isTerminalNotificationError(err error) bool { var requestError terminalNotificationRequestError return errors.As(err, &requestError) } - -func formatProtobufDuration(value time.Duration) string { - duration := durationpb.New(value) - seconds := duration.Seconds - nanoseconds := int64(duration.Nanos) - sign := "" - if seconds < 0 || nanoseconds < 0 { - sign = "-" - seconds = -seconds - nanoseconds = -nanoseconds - } - - result := sign + strconv.FormatInt(seconds, 10) - if nanoseconds == 0 { - return result + "s" - } - - fraction := fmt.Sprintf("%09d", nanoseconds) - switch { - case nanoseconds%1_000_000 == 0: - fraction = fraction[:3] - case nanoseconds%1_000 == 0: - fraction = fraction[:6] - } - - return result + "." + fraction + "s" -} - -type notificationHTTPAttemptRecorder interface { - Start(context.Context, uint) (context.Context, func(int, error)) -} - -type otelNotificationHTTPAttemptRecorder struct { - tracer telemetry.Tracer - attemptCounter metric.Int64Counter - durationSeconds metric.Float64Histogram -} - -func newNotificationHTTPAttemptRecorder(tracer telemetry.Tracer) notificationHTTPAttemptRecorder { - if tracer == nil { - return nil - } - - meter := otel.GetMeterProvider().Meter("github.com/NdoleStudio/httpsms/pkg/services") - attemptCounter, _ := meter.Int64Counter("httpsms.notification.http.attempts") - durationSeconds, _ := meter.Float64Histogram("httpsms.notification.http.attempt.duration") - - return &otelNotificationHTTPAttemptRecorder{ - tracer: tracer, - attemptCounter: attemptCounter, - durationSeconds: durationSeconds, - } -} - -func (recorder *otelNotificationHTTPAttemptRecorder) Start( - ctx context.Context, - attempt uint, -) (context.Context, func(int, error)) { - ctx, span := recorder.tracer.Start(ctx, "phone_notification_http") - span.SetAttributes( - attribute.String("notification.transport", "http"), - attribute.Int("notification.attempt", int(attempt)), - ) - startedAt := time.Now() - - return ctx, func(statusCode int, err error) { - statusClass := notificationHTTPStatusClass(statusCode, err) - attributes := []attribute.KeyValue{ - attribute.String("notification.transport", "http"), - attribute.Int("notification.attempt", int(attempt)), - attribute.String("notification.status_class", statusClass), - } - span.SetAttributes(attributes...) - if err != nil { - span.SetStatus(codes.Error, "notification HTTP attempt failed") - } else { - span.SetStatus(codes.Ok, "") - } - - options := metric.WithAttributes(attributes...) - if recorder.attemptCounter != nil { - recorder.attemptCounter.Add(ctx, 1, options) - } - if recorder.durationSeconds != nil { - recorder.durationSeconds.Record(ctx, time.Since(startedAt).Seconds(), options) - } - span.End() - } -} - -func notificationHTTPStatusClass(statusCode int, err error) string { - if err != nil && statusCode == 0 { - return "transport_error" - } - if statusCode < 100 { - return "unknown" - } - return fmt.Sprintf("%dxx", statusCode/100) -} diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 917d400c..17ba1073 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -1,7 +1,6 @@ package services import ( - "bytes" "context" "encoding/json" "errors" @@ -9,16 +8,14 @@ import ( "net/http" "net/url" "reflect" - "strings" "testing" "time" + "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/trace" ) @@ -42,16 +39,18 @@ type httpNotificationPayload struct { func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { notificationID := uuid.New() ttl := 10 * time.Minute - notification := GatewayNotification{ - Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, - Priority: "high", - TTL: &ttl, - NotificationID: notificationID, + 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")) - assert.Equal(t, notification.NotificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) + assert.Equal(t, notificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) var payload httpNotificationPayload require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) @@ -64,32 +63,12 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { return response(http.StatusNoContent, http.NoBody), nil })) - result, err := sender.Send(context.Background(), "https://adapter.example.com/notify", notification) + result, err := sender.Send(context.Background(), message, notificationID) require.NoError(t, err) assert.Equal(t, "http/"+notificationID.String(), result) } -func TestFormatProtobufDuration(t *testing.T) { - tests := []struct { - name string - duration time.Duration - expected string - }{ - {name: "whole seconds", duration: 10 * time.Minute, expected: "600s"}, - {name: "milliseconds", duration: 1500 * time.Millisecond, expected: "1.500s"}, - {name: "microseconds", duration: time.Second + 234567*time.Microsecond, expected: "1.234567s"}, - {name: "nanoseconds", duration: time.Second + 234567890*time.Nanosecond, expected: "1.234567890s"}, - {name: "negative subsecond", duration: -500 * time.Millisecond, expected: "-0.500s"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.expected, formatProtobufDuration(test.duration)) - }) - } -} - func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { tests := []struct { name string @@ -175,9 +154,11 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { })) notificationID := uuid.New() - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - NotificationID: notificationID, - }) + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + notificationID, + ) if test.wantErr { require.Error(t, err) @@ -190,6 +171,28 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { } } +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"}, + uuid.New(), + ) + require.NoError(t, err) + } + + assert.Equal(t, 4, calls) +} + func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *testing.T) { var requests []*http.Request var bodies [][]byte @@ -204,10 +207,14 @@ func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *test return response(http.StatusNoContent, http.NoBody), nil })) - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, - NotificationID: uuid.New(), - }) + _, err := sender.Send( + context.Background(), + &messaging.Message{ + Token: "https://adapter.example.com/notify", + Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, + }, + uuid.New(), + ) require.NoError(t, err) require.Len(t, requests, 3) @@ -225,32 +232,17 @@ func TestHTTPNotificationSenderBoundsResponseBodyDiscard(t *testing.T) { return response(http.StatusNoContent, body), nil })) - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - NotificationID: uuid.New(), - }) + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + uuid.New(), + ) require.NoError(t, err) assert.Equal(t, int64(4096), body.read) assert.True(t, body.closed) } -func TestHTTPNotificationSenderRedactsDestinationSecrets(t *testing.T) { - logger := &httpNotificationRecordingLogger{} - sender := newHTTPNotificationSenderWithLogger(t, logger, roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return response(http.StatusBadRequest, io.NopCloser(bytes.NewBufferString("customer-secret"))), nil - })) - destination := "https://adapter.example.com/secret/path?token=customer-secret" - - _, err := sender.Send(context.Background(), destination, GatewayNotification{NotificationID: uuid.New()}) - - require.Error(t, err) - assert.Contains(t, err.Error(), "adapter.example.com") - for _, secret := range []string{"secret/path", "customer-secret", destination} { - assert.NotContains(t, err.Error(), secret) - assert.NotContains(t, strings.Join(logger.errors, "\n"), secret) - } -} - func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { var payload httpNotificationPayload @@ -261,11 +253,17 @@ func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { return response(http.StatusNoContent, http.NoBody), nil })) - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - Data: map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"}, - Priority: "high", - NotificationID: uuid.New(), - }) + _, 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", + }, + }, + uuid.New(), + ) require.NoError(t, err) } @@ -279,7 +277,7 @@ func TestHTTPNotificationSenderUsesInjectedHTTPClientUnchanged(t *testing.T) { Timeout: time.Minute, } - sender := NewHTTPNotificationSender(nil, nil, client) + sender := NewHTTPNotificationSender(nil, client) assert.Same(t, client, sender.client) assert.Equal(t, reflect.ValueOf(transport).Pointer(), reflect.ValueOf(sender.client.Transport).Pointer()) @@ -303,8 +301,8 @@ func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { _, err := sender.Send( context.Background(), - endpoint.String(), - GatewayNotification{NotificationID: uuid.New()}, + &messaging.Message{Token: endpoint.String()}, + uuid.New(), ) require.NoError(t, err) @@ -319,9 +317,11 @@ func TestHTTPNotificationSenderBoundsEveryAttemptByTimeout(t *testing.T) { })) sender.timeout = 10 * time.Millisecond - _, err := sender.Send(context.Background(), "https://adapter.example.com/notify", GatewayNotification{ - NotificationID: uuid.New(), - }) + _, err := sender.Send( + context.Background(), + &messaging.Message{Token: "https://adapter.example.com/notify"}, + uuid.New(), + ) require.Error(t, err) assert.Equal(t, 3, calls) @@ -337,51 +337,25 @@ func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testi return nil, request.Context().Err() })) - _, err := sender.Send(ctx, "https://adapter.example.com/notify", GatewayNotification{ - NotificationID: uuid.New(), - }) + _, err := sender.Send( + ctx, + &messaging.Message{Token: "https://adapter.example.com/notify"}, + uuid.New(), + ) require.Error(t, err) assert.Equal(t, 1, calls) } -func TestHTTPNotificationSenderTelemetryDoesNotExportCallbackURL(t *testing.T) { - spanRecorder := tracetest.NewSpanRecorder() - provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) - t.Cleanup(func() { - require.NoError(t, provider.Shutdown(context.Background())) - }) - ctx, parent := provider.Tracer("test").Start(context.Background(), "parent") - logger := &httpNotificationRecordingLogger{} - tracer := telemetry.NewOtelLogger("test", logger) +func TestHTTPNotificationSenderRejectsNilMessage(t *testing.T) { sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { return response(http.StatusNoContent, http.NoBody), nil })) - sender.attemptRecorder = newNotificationHTTPAttemptRecorder(tracer) - destination := "https://adapter.example.com/secret/path?token=customer-secret" - _, err := sender.Send(ctx, destination, GatewayNotification{NotificationID: uuid.New()}) - parent.End() + _, err := sender.Send(context.Background(), nil, uuid.New()) - require.NoError(t, err) - var exported string - for _, span := range spanRecorder.Ended() { - exported += span.Name() + span.Status().Description - for _, attribute := range span.Attributes() { - exported += string(attribute.Key) + attribute.Value.Emit() - } - for _, event := range span.Events() { - exported += event.Name - for _, attribute := range event.Attributes { - exported += string(attribute.Key) + attribute.Value.Emit() - } - } - } - assert.Contains(t, exported, "notification.transporthttp") - assert.Contains(t, exported, "notification.status_class2xx") - for _, secret := range []string{destination, "secret/path", "customer-secret"} { - assert.NotContains(t, exported, secret) - } + require.Error(t, err) + assert.Contains(t, err.Error(), "notification message is nil") } type roundTripOutcome struct { @@ -449,9 +423,11 @@ func newHTTPNotificationSenderWithLogger( transport roundTripFunc, ) *HTTPNotificationSender { t.Helper() - sender := NewHTTPNotificationSender(logger, nil, &http.Client{Transport: transport}) - sender.retryDelay = 0 - return sender + return newHTTPNotificationSenderWithRetrier( + logger, + &http.Client{Transport: transport}, + newHTTPNotificationRetrier(0), + ) } func response(statusCode int, body io.ReadCloser) *http.Response { diff --git a/api/pkg/services/notification_sender.go b/api/pkg/services/notification_sender.go index 95fc725c..b1d37607 100644 --- a/api/pkg/services/notification_sender.go +++ b/api/pkg/services/notification_sender.go @@ -2,65 +2,15 @@ package services import ( "context" - "strings" - "time" "firebase.google.com/go/messaging" - "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/stacktrace" "github.com/google/uuid" ) -// GatewayNotification is a transport-neutral notification for a phone gateway. -type GatewayNotification struct { - Data map[string]string - Priority string - TTL *time.Duration - NotificationID uuid.UUID -} - // NotificationSender delivers a notification to a transport-specific destination. type NotificationSender interface { - Send(ctx context.Context, destination string, notification GatewayNotification) (string, error) -} - -// PhoneNotificationDispatcher routes gateway notifications to the phone's configured transport. -type PhoneNotificationDispatcher struct { - fcmSender NotificationSender - httpSender NotificationSender -} - -// NewPhoneNotificationDispatcher creates a dispatcher for FCM and HTTP notification transports. -func NewPhoneNotificationDispatcher( - fcmSender NotificationSender, - httpSender NotificationSender, -) *PhoneNotificationDispatcher { - return &PhoneNotificationDispatcher{ - fcmSender: fcmSender, - httpSender: httpSender, - } -} - -// Send delivers a notification using the phone's configured notification transport. -func (dispatcher *PhoneNotificationDispatcher) Send( - ctx context.Context, - phone *entities.Phone, - notification GatewayNotification, -) (string, error) { - transport, err := phone.NotificationTransport() - if err != nil { - return "", stacktrace.Propagatef(err, "cannot determine notification transport for phone [%s]", phone.ID) - } - - destination := strings.TrimSpace(*phone.FcmToken) - switch transport { - case entities.NotificationTransportFCM: - return dispatcher.fcmSender.Send(ctx, destination, notification) - case entities.NotificationTransportHTTP: - return dispatcher.httpSender.Send(ctx, destination, notification) - default: - return "", stacktrace.NewErrorf("unsupported notification transport [%s]", transport) - } + Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) } // FCMNotificationSender delivers gateway notifications through Firebase Cloud Messaging. @@ -76,16 +26,14 @@ func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender { // Send delivers a gateway notification through Firebase Cloud Messaging. func (sender *FCMNotificationSender) Send( ctx context.Context, - destination string, - notification GatewayNotification, + message *messaging.Message, + _ uuid.UUID, ) (string, error) { - message := &messaging.Message{ - Token: destination, - Data: notification.Data, - Android: &messaging.AndroidConfig{ - Priority: notification.Priority, - TTL: notification.TTL, - }, + if message == nil { + return "", stacktrace.Propagatef( + stacktrace.NewErrorf("notification message is nil"), + "cannot send Firebase notification", + ) } result, err := sender.client.Send(ctx, message) diff --git a/api/pkg/services/notification_sender_test.go b/api/pkg/services/notification_sender_test.go index d7e512e5..020cee52 100644 --- a/api/pkg/services/notification_sender_test.go +++ b/api/pkg/services/notification_sender_test.go @@ -6,75 +6,11 @@ import ( "time" "firebase.google.com/go/messaging" - "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type recordingNotificationSender struct { - destination string - notification GatewayNotification - result string - err error - calls int -} - -func (sender *recordingNotificationSender) Send(_ context.Context, destination string, notification GatewayNotification) (string, error) { - sender.calls++ - sender.destination = destination - sender.notification = notification - return sender.result, sender.err -} - -func TestPhoneNotificationDispatcherRoutesFCMToken(t *testing.T) { - token := "fcm-token:value" - phone := &entities.Phone{FcmToken: &token} - fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} - httpSender := &recordingNotificationSender{} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} - - result, err := dispatcher.Send(context.Background(), phone, notification) - - require.NoError(t, err) - assert.Equal(t, "projects/test/messages/1", result) - assert.Equal(t, 1, fcmSender.calls) - assert.Zero(t, httpSender.calls) - assert.Equal(t, token, fcmSender.destination) -} - -func TestPhoneNotificationDispatcherRoutesHTTPSURL(t *testing.T) { - endpoint := "https://adapter.example.com/notifications/gateway-1" - phone := &entities.Phone{FcmToken: &endpoint} - fcmSender := &recordingNotificationSender{} - httpSender := &recordingNotificationSender{result: "accepted"} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} - - result, err := dispatcher.Send(context.Background(), phone, notification) - - require.NoError(t, err) - assert.Equal(t, "accepted", result) - assert.Zero(t, fcmSender.calls) - assert.Equal(t, 1, httpSender.calls) - assert.Equal(t, endpoint, httpSender.destination) -} - -func TestPhoneNotificationDispatcherRejectsInvalidURLLikeTokenWithoutSending(t *testing.T) { - token := "https://" - phone := &entities.Phone{FcmToken: &token} - fcmSender := &recordingNotificationSender{} - httpSender := &recordingNotificationSender{} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - - _, err := dispatcher.Send(context.Background(), phone, GatewayNotification{}) - - require.Error(t, err) - assert.Zero(t, fcmSender.calls) - assert.Zero(t, httpSender.calls) -} - type recordingFCMClient struct { message *messaging.Message result string @@ -88,24 +24,26 @@ func (client *recordingFCMClient) Send(_ context.Context, message *messaging.Mes return client.result, client.err } -func TestFCMNotificationSenderMapsGatewayNotification(t *testing.T) { +func TestFCMNotificationSenderPassesMessageUnchanged(t *testing.T) { ttl := 5 * time.Minute - notificationID := uuid.New() data := map[string]string{"KEY_MESSAGE_ID": uuid.NewString()} client := &recordingFCMClient{result: "projects/test/messages/1"} sender := NewFCMNotificationSender(client) + message := &messaging.Message{ + Token: "fcm-token:value", + Data: data, + Android: &messaging.AndroidConfig{ + Priority: "normal", + TTL: &ttl, + }, + } - result, err := sender.Send(context.Background(), "fcm-token:value", GatewayNotification{ - Data: data, - Priority: "normal", - TTL: &ttl, - NotificationID: notificationID, - }) + result, err := sender.Send(context.Background(), message, uuid.New()) require.NoError(t, err) assert.Equal(t, "projects/test/messages/1", result) require.Equal(t, 1, client.calls) - require.NotNil(t, client.message) + assert.Same(t, message, client.message) assert.Equal(t, "fcm-token:value", client.message.Token) assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": data["KEY_MESSAGE_ID"]}, client.message.Data) require.NotNil(t, client.message.Android) @@ -113,3 +51,14 @@ func TestFCMNotificationSenderMapsGatewayNotification(t *testing.T) { assert.Equal(t, &ttl, client.message.Android.TTL) assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": data["KEY_MESSAGE_ID"]}, data) } + +func TestFCMNotificationSenderRejectsNilMessage(t *testing.T) { + client := &recordingFCMClient{} + sender := NewFCMNotificationSender(client) + + _, err := sender.Send(context.Background(), nil, uuid.New()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "notification message is nil") + assert.Zero(t, client.calls) +} diff --git a/api/pkg/services/phone_notification_dispatcher.go b/api/pkg/services/phone_notification_dispatcher.go new file mode 100644 index 00000000..ffcb078e --- /dev/null +++ b/api/pkg/services/phone_notification_dispatcher.go @@ -0,0 +1,58 @@ +package services + +import ( + "context" + "strings" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" +) + +// PhoneNotificationDispatcher routes gateway notifications to the phone's configured transport. +type PhoneNotificationDispatcher struct { + fcmSender NotificationSender + httpSender NotificationSender +} + +// NewPhoneNotificationDispatcher creates a dispatcher for FCM and HTTP notification transports. +func NewPhoneNotificationDispatcher( + fcmSender NotificationSender, + httpSender NotificationSender, +) *PhoneNotificationDispatcher { + return &PhoneNotificationDispatcher{ + fcmSender: fcmSender, + httpSender: httpSender, + } +} + +// Send delivers a notification using the phone's configured notification transport. +func (dispatcher *PhoneNotificationDispatcher) Send( + ctx context.Context, + phone *entities.Phone, + message *messaging.Message, + notificationID uuid.UUID, +) (string, error) { + transport, err := phone.NotificationTransport() + if err != nil { + return "", stacktrace.Propagatef(err, "cannot determine notification transport for phone [%s]", phone.ID) + } + if message == nil { + return "", stacktrace.Propagatef( + stacktrace.NewErrorf("notification message is nil"), + "cannot dispatch notification for phone [%s]", + phone.ID, + ) + } + + message.Token = strings.TrimSpace(*phone.FcmToken) + switch transport { + case entities.NotificationTransportFCM: + return dispatcher.fcmSender.Send(ctx, message, notificationID) + case entities.NotificationTransportHTTP: + return dispatcher.httpSender.Send(ctx, message, notificationID) + default: + return "", stacktrace.NewErrorf("unsupported notification transport [%s]", transport) + } +} diff --git a/api/pkg/services/phone_notification_dispatcher_test.go b/api/pkg/services/phone_notification_dispatcher_test.go new file mode 100644 index 00000000..f7c5a7af --- /dev/null +++ b/api/pkg/services/phone_notification_dispatcher_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "context" + "testing" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type recordingNotificationSender struct { + destination string + message *messaging.Message + notificationID uuid.UUID + result string + err error + calls int +} + +func (sender *recordingNotificationSender) Send( + _ context.Context, + message *messaging.Message, + notificationID uuid.UUID, +) (string, error) { + sender.calls++ + sender.message = message + sender.notificationID = notificationID + if message != nil { + sender.destination = message.Token + } + return sender.result, sender.err +} + +func TestPhoneNotificationDispatcherRoutesFCMToken(t *testing.T) { + token := " fcm-token:value " + phone := &entities.Phone{FcmToken: &token} + fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} + httpSender := &recordingNotificationSender{} + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) + message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + notificationID := uuid.New() + + result, err := dispatcher.Send(context.Background(), phone, message, notificationID) + + require.NoError(t, err) + assert.Equal(t, "projects/test/messages/1", result) + assert.Equal(t, 1, fcmSender.calls) + assert.Zero(t, httpSender.calls) + assert.Equal(t, "fcm-token:value", fcmSender.destination) + assert.Equal(t, "fcm-token:value", message.Token) + assert.Same(t, message, fcmSender.message) + assert.Equal(t, notificationID, fcmSender.notificationID) +} + +func TestPhoneNotificationDispatcherRoutesHTTPSURL(t *testing.T) { + endpoint := "https://adapter.example.com/notifications/gateway-1" + phone := &entities.Phone{FcmToken: &endpoint} + fcmSender := &recordingNotificationSender{} + httpSender := &recordingNotificationSender{result: "accepted"} + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) + message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + notificationID := uuid.New() + + result, err := dispatcher.Send(context.Background(), phone, message, notificationID) + + require.NoError(t, err) + assert.Equal(t, "accepted", result) + assert.Zero(t, fcmSender.calls) + assert.Equal(t, 1, httpSender.calls) + assert.Equal(t, endpoint, httpSender.destination) + assert.Same(t, message, httpSender.message) + assert.Equal(t, notificationID, httpSender.notificationID) +} + +func TestPhoneNotificationDispatcherRejectsInvalidURLLikeTokenWithoutSending(t *testing.T) { + token := "https://" + phone := &entities.Phone{FcmToken: &token} + fcmSender := &recordingNotificationSender{} + httpSender := &recordingNotificationSender{} + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) + + _, err := dispatcher.Send(context.Background(), phone, &messaging.Message{}, uuid.New()) + + require.Error(t, err) + assert.Zero(t, fcmSender.calls) + assert.Zero(t, httpSender.calls) +} + +func TestPhoneNotificationDispatcherRejectsNilMessage(t *testing.T) { + token := "fcm-token:value" + phone := &entities.Phone{ID: uuid.New(), FcmToken: &token} + fcmSender := &recordingNotificationSender{} + httpSender := &recordingNotificationSender{} + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) + + _, err := dispatcher.Send(context.Background(), phone, nil, uuid.New()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "notification message is nil") + assert.Zero(t, fcmSender.calls) + assert.Zero(t, httpSender.calls) +} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index c5366ee9..2082a3fe 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/events" cloudevents "github.com/cloudevents/sdk-go/v2" @@ -90,13 +91,13 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID)) } - result, err := service.phoneNotificationDispatcher.Send(ctx, phone, GatewayNotification{ + notificationID := uuid.New() + result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, - Priority: "high", - NotificationID: uuid.New(), - }) + Android: &messaging.AndroidConfig{Priority: "high"}, + }, notificationID) if err != nil { ctxLogger.Warn(stacktrace.Propagatef( err, @@ -144,14 +145,15 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.phoneNotificationDispatcher.Send(ctx, phone, GatewayNotification{ + result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, - Priority: "normal", - TTL: &ttl, - NotificationID: params.PhoneNotificationID, - }) + Android: &messaging.AndroidConfig{ + Priority: "normal", + TTL: &ttl, + }, + }, params.PhoneNotificationID) if err != nil { transport, transportErr := phone.NotificationTransport() if transportErr != nil { diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go index 0c6504fb..ebf7a604 100644 --- a/api/pkg/services/phone_notification_service_test.go +++ b/api/pkg/services/phone_notification_service_test.go @@ -87,7 +87,7 @@ func (logger *phoneNotificationLogger) Debug(string) {} func (logger *phoneNotificationLogger) Fatal(error) {} func (logger *phoneNotificationLogger) Printf(string, ...interface{}) {} -func TestPhoneNotificationServiceSendUsesHTTPSGatewayNotification(t *testing.T) { +func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) { endpoint := "https://adapter.example.com/notify" phone := &entities.Phone{ ID: uuid.New(), @@ -111,11 +111,14 @@ func TestPhoneNotificationServiceSendUsesHTTPSGatewayNotification(t *testing.T) require.NoError(t, service.Send(context.Background(), params)) - assert.Equal(t, params.MessageID.String(), httpSender.notification.Data["KEY_MESSAGE_ID"]) - assert.Equal(t, "normal", httpSender.notification.Priority) - require.NotNil(t, httpSender.notification.TTL) - assert.Equal(t, phone.MessageExpirationDuration(), *httpSender.notification.TTL) - assert.Equal(t, params.PhoneNotificationID, httpSender.notification.NotificationID) + require.NotNil(t, httpSender.message) + assert.Equal(t, endpoint, httpSender.message.Token) + assert.Equal(t, params.MessageID.String(), httpSender.message.Data["KEY_MESSAGE_ID"]) + require.NotNil(t, httpSender.message.Android) + assert.Equal(t, "normal", httpSender.message.Android.Priority) + require.NotNil(t, httpSender.message.Android.TTL) + assert.Equal(t, phone.MessageExpirationDuration(), *httpSender.message.Android.TTL) + assert.Equal(t, params.PhoneNotificationID, httpSender.notificationID) require.Len(t, eventQueue.events, 1) assert.Equal(t, events.EventTypeMessageNotificationSent, eventQueue.events[0].Type()) assert.Equal(t, params.PhoneNotificationID, notificationRepository.notificationID) @@ -180,7 +183,7 @@ func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testi assert.Equal(t, "cannot send notification to your phone [+18005550199]. Reinstall the httpSMS app on your Android phone.", payload.ErrorMessage) } -func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSGatewayNotification(t *testing.T) { +func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSMessage(t *testing.T) { endpoint := "https://adapter.example.com/notify" phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} httpSender := &recordingNotificationSender{err: errors.New("adapter unavailable")} @@ -199,12 +202,15 @@ func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSGatewayNotification(t }) require.NoError(t, err) - heartbeatID := httpSender.notification.Data["KEY_HEARTBEAT_ID"] + require.NotNil(t, httpSender.message) + assert.Equal(t, endpoint, httpSender.message.Token) + heartbeatID := httpSender.message.Data["KEY_HEARTBEAT_ID"] _, err = time.Parse(time.RFC3339, heartbeatID) require.NoError(t, err) - assert.Equal(t, "high", httpSender.notification.Priority) - assert.Nil(t, httpSender.notification.TTL) - assert.NotEqual(t, uuid.Nil, httpSender.notification.NotificationID) + require.NotNil(t, httpSender.message.Android) + assert.Equal(t, "high", httpSender.message.Android.Priority) + assert.Nil(t, httpSender.message.Android.TTL) + assert.NotEqual(t, uuid.Nil, httpSender.notificationID) } func newPhoneNotificationServiceForTest( 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 index 2e52bcf8..198d9116 100644 --- 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 @@ -4,7 +4,7 @@ **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`, then route a transport-neutral `GatewayNotification` through Firebase and HTTP senders. 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. +**Architecture:** Add transport helpers to `entities.Phone`, then route a shared Firebase `*messaging.Message` through Firebase and HTTP senders while passing the delivery UUID separately. 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. @@ -20,16 +20,25 @@ private-host allowlist. hostname classification; URL user information remains valid. - `Container.NotificationHTTPClient` uses the existing `go-otelroundtripper` pattern with the `phone_notification_http` name and - `http.DefaultTransport` as the final parent. -- A telemetry-only seam exposes only scheme and host/port to OTel while the - default transport receives the original URL, headers, and body. -- `HTTPNotificationSender` owns exactly three application attempts through - `retry.New(...).Do`, with a fresh request/body and five-second context per - attempt. + 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. - The transport router is `PhoneNotificationDispatcher`; domain events continue to use the existing `EventDispatcher` directly. +- `PhoneNotificationService` constructs `*messaging.Message` directly. + `PhoneNotificationDispatcher` sets the trimmed destination in `message.Token` + and passes the same pointer to either sender with the notification UUID as a + separate argument. Nil messages fail explicitly with stacktrace errors. ## Global Constraints @@ -58,8 +67,10 @@ private-host allowlist. - `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/notification_sender.go` - transport-neutral notification, sender interface, Firebase adapter, and dispatcher. -- `api/pkg/services/notification_sender_test.go` - dispatcher routing and Firebase payload mapping tests. +- `api/pkg/services/notification_sender.go` - shared sender interface and Firebase adapter. +- `api/pkg/services/notification_sender_test.go` - Firebase payload mapping tests. +- `api/pkg/services/phone_notification_dispatcher.go` - phone transport routing. +- `api/pkg/services/phone_notification_dispatcher_test.go` - dispatcher routing tests and sender fake. - `api/pkg/services/http_notification_sender.go` - HTTP request encoding, retry classification, and timeout. - `api/pkg/services/http_notification_sender_test.go` - payload, retry, idempotency, and response tests. - `api/pkg/services/phone_notification_service_test.go` - message and heartbeat integration tests with hand-written fakes. @@ -548,6 +559,8 @@ git commit -m "feat(api): validate adapter endpoints" **Files:** - Create: `api/pkg/services/notification_sender.go` - Create: `api/pkg/services/notification_sender_test.go` +- Create: `api/pkg/services/phone_notification_dispatcher.go` +- Create: `api/pkg/services/phone_notification_dispatcher_test.go` - Modify: `api/pkg/services/fcm_client.go` **Interfaces:** @@ -560,50 +573,43 @@ func (phone *entities.Phone) NotificationTransport() (entities.NotificationTrans - Produces: ```go -type GatewayNotification struct { - Data map[string]string - Priority string - TTL *time.Duration - NotificationID uuid.UUID -} - type NotificationSender interface { - Send(ctx context.Context, destination string, notification GatewayNotification) (string, error) + Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) } -type NotificationDispatcher struct { +type PhoneNotificationDispatcher struct { fcmSender NotificationSender httpSender NotificationSender } -func NewNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *NotificationDispatcher -func (dispatcher *NotificationDispatcher) Send(ctx context.Context, phone *entities.Phone, notification GatewayNotification) (string, error) +func NewPhoneNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *PhoneNotificationDispatcher +func (dispatcher *PhoneNotificationDispatcher) Send(ctx context.Context, phone *entities.Phone, message *messaging.Message, notificationID uuid.UUID) (string, error) type FCMNotificationSender struct { client FCMClient } func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender -func (sender *FCMNotificationSender) Send(ctx context.Context, destination string, notification GatewayNotification) (string, error) +func (sender *FCMNotificationSender) Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) ``` - [ ] **Step 1: Write failing dispatcher and Firebase mapping tests** -Create `api/pkg/services/notification_sender_test.go` with a recording sender: +Create `api/pkg/services/phone_notification_dispatcher_test.go` with a recording sender: ```go type recordingNotificationSender struct { - destination string - notification GatewayNotification - result string - err error - calls int + message *messaging.Message + notificationID uuid.UUID + result string + err error + calls int } -func (sender *recordingNotificationSender) Send(_ context.Context, destination string, notification GatewayNotification) (string, error) { +func (sender *recordingNotificationSender) Send(_ context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) { sender.calls++ - sender.destination = destination - sender.notification = notification + sender.message = message + sender.notificationID = notificationID return sender.result, sender.err } ``` @@ -611,29 +617,33 @@ func (sender *recordingNotificationSender) Send(_ context.Context, destination s Add tests that assert: ```go -func TestNotificationDispatcherRoutesFCMToken(t *testing.T) { +func TestPhoneNotificationDispatcherRoutesFCMToken(t *testing.T) { token := "fcm-token:value" phone := &entities.Phone{FcmToken: &token} fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} httpSender := &recordingNotificationSender{} - dispatcher := NewNotificationDispatcher(fcmSender, httpSender) - notification := GatewayNotification{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) + message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} + notificationID := uuid.New() - result, err := dispatcher.Send(context.Background(), phone, notification) + result, err := dispatcher.Send(context.Background(), phone, message, notificationID) require.NoError(t, err) assert.Equal(t, "projects/test/messages/1", result) assert.Equal(t, 1, fcmSender.calls) assert.Zero(t, httpSender.calls) - assert.Equal(t, token, fcmSender.destination) + assert.Same(t, message, fcmSender.message) + assert.Equal(t, token, message.Token) + assert.Equal(t, notificationID, fcmSender.notificationID) } ``` Add the equivalent HTTPS routing test and an invalid URL-like token test that asserts neither sender is called. -Create a recording `FCMClient` and assert `FCMNotificationSender.Send` maps -destination, data, priority, and TTL to `messaging.Message` without mutation. +In `notification_sender_test.go`, create a recording `FCMClient` and assert +`FCMNotificationSender.Send` passes the exact message pointer through without +reconstruction. Add explicit nil-message tests for the dispatcher and sender. - [ ] **Step 2: Run the sender tests and confirm the API is missing** @@ -641,33 +651,43 @@ Run: ```bash cd api -go test ./pkg/services -run 'TestNotificationDispatcher|TestFCMNotificationSender' -count=1 +go test ./pkg/services -run 'TestPhoneNotificationDispatcher|TestFCMNotificationSender' -count=1 ``` -Expected: compilation fails because the neutral sender types do not exist. +Expected: compilation fails because the shared sender contract does not exist. - [ ] **Step 3: Implement the neutral notification and dispatcher** -Create `api/pkg/services/notification_sender.go` with the exact interfaces -above. Implement dispatcher routing: +Keep the shared sender interface and Firebase sender in +`api/pkg/services/notification_sender.go`. Implement dispatcher routing in +`api/pkg/services/phone_notification_dispatcher.go`: ```go -func (dispatcher *NotificationDispatcher) Send( +func (dispatcher *PhoneNotificationDispatcher) Send( ctx context.Context, phone *entities.Phone, - notification GatewayNotification, + message *messaging.Message, + notificationID uuid.UUID, ) (string, error) { transport, err := phone.NotificationTransport() if err != nil { return "", stacktrace.Propagatef(err, "cannot determine notification transport for phone [%s]", phone.ID) } - destination := strings.TrimSpace(*phone.FcmToken) + if message == nil { + return "", stacktrace.Propagatef( + stacktrace.NewErrorf("notification message is nil"), + "cannot dispatch notification for phone [%s]", + phone.ID, + ) + } + + message.Token = strings.TrimSpace(*phone.FcmToken) switch transport { case entities.NotificationTransportFCM: - return dispatcher.fcmSender.Send(ctx, destination, notification) + return dispatcher.fcmSender.Send(ctx, message, notificationID) case entities.NotificationTransportHTTP: - return dispatcher.httpSender.Send(ctx, destination, notification) + return dispatcher.httpSender.Send(ctx, message, notificationID) default: return "", stacktrace.NewErrorf("unsupported notification transport [%s]", transport) } @@ -681,16 +701,14 @@ In the same file, implement: ```go func (sender *FCMNotificationSender) Send( ctx context.Context, - destination string, - notification GatewayNotification, + message *messaging.Message, + _ uuid.UUID, ) (string, error) { - message := &messaging.Message{ - Token: destination, - Data: notification.Data, - Android: &messaging.AndroidConfig{ - Priority: notification.Priority, - TTL: notification.TTL, - }, + if message == nil { + return "", stacktrace.Propagatef( + stacktrace.NewErrorf("notification message is nil"), + "cannot send Firebase notification", + ) } result, err := sender.client.Send(ctx, message) @@ -711,7 +729,7 @@ Run: ```bash cd api -go test ./pkg/services -run 'TestNotificationDispatcher|TestFCMNotificationSender' -count=1 +go test ./pkg/services -run 'TestPhoneNotificationDispatcher|TestFCMNotificationSender' -count=1 ``` Expected: PASS. @@ -722,8 +740,8 @@ Run: ```bash cd api -go-fumpt -w pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/fcm_client.go -git add pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/fcm_client.go +go-fumpt -w pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/phone_notification_dispatcher.go pkg/services/phone_notification_dispatcher_test.go pkg/services/fcm_client.go +git add pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/phone_notification_dispatcher.go pkg/services/phone_notification_dispatcher_test.go pkg/services/fcm_client.go git commit -m "refactor(api): dispatch gateway notifications" ``` @@ -739,11 +757,8 @@ git commit -m "refactor(api): dispatch gateway notifications" - Consumes: ```go -type GatewayNotification struct { - Data map[string]string - Priority string - TTL *time.Duration - NotificationID uuid.UUID +type NotificationSender interface { + Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) } func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint *url.URL) ([]netip.Addr, error) @@ -753,26 +768,21 @@ func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint ```go type HTTPNotificationSender struct { - logger telemetry.Logger - tracer telemetry.Tracer - client *http.Client - policy *NotificationEndpointPolicy - attempts uint - timeout time.Duration - retryDelay func(context.Context, time.Duration) error + logger telemetry.Logger + client *http.Client + retrier *retry.Retrier + timeout time.Duration } func NewHTTPNotificationSender( logger telemetry.Logger, - tracer telemetry.Tracer, client *http.Client, - policy *NotificationEndpointPolicy, ) *HTTPNotificationSender func (sender *HTTPNotificationSender) Send( ctx context.Context, - destination string, - notification GatewayNotification, + message *messaging.Message, + notificationID uuid.UUID, ) (string, error) ``` @@ -789,8 +799,8 @@ func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, } ``` -Construct the sender directly in tests with `attempts: 3`, -`timeout: 5*time.Second`, and a `retryDelay` that returns nil immediately. +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. @@ -799,10 +809,10 @@ Assert the request: ```go assert.Equal(t, http.MethodPost, request.Method) assert.Equal(t, "application/json", request.Header.Get("Content-Type")) -assert.Equal(t, notification.NotificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) +assert.Equal(t, notificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) ``` -Decode the body and assert this structure: +Keep a test-only decoding struct and assert this structure: ```go type httpNotificationRequest struct { @@ -851,28 +861,20 @@ Expected: compilation fails because `HTTPNotificationSender` does not exist. - [ ] **Step 4: Implement the FCM-compatible HTTP payload** -Create `api/pkg/services/http_notification_sender.go` with private payload -types: +Create `api/pkg/services/http_notification_sender.go` and build the one-use +payload directly with `map[string]any`: ```go -type httpNotificationRequest struct { - Message httpNotificationMessage `json:"message"` -} - -type httpNotificationMessage struct { - Token string `json:"token"` - Data map[string]string `json:"data,omitempty"` - Android httpNotificationAndroid `json:"android,omitempty"` -} - -type httpNotificationAndroid struct { - Priority string `json:"priority,omitempty"` - TTL string `json:"ttl,omitempty"` +payload := map[string]any{ + "message": message, } ``` -Format a non-nil TTL with `notification.TTL.String()`. Build a new request body -for every attempt so retries never reuse a consumed reader. +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** @@ -890,36 +892,39 @@ func notificationRetryDelay(attempt uint) time.Duration { } ``` -`Send` must: - -1. parse `destination`; -2. call `policy.Validate` before the first attempt; -3. marshal the request body once, then create a fresh reader per request; -4. create a child context with the configured timeout per attempt; -5. set `Content-Type` and `X-httpSMS-Notification-ID`; -6. call `client.Do`; -7. close each response body after copying at most 4 KiB to `io.Discard`; -8. return `http/` for any `2xx`; -9. retry only the approved errors/statuses while attempts remain; -10. return a stacktrace-wrapped error. - -Set constructor defaults: +Create one reusable retrier during sender initialization: ```go -attempts: 3, -timeout: 5 * time.Second, -retryDelay: func(ctx context.Context, delay time.Duration) error { - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -}, +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` and `X-httpSMS-Notification-ID`; +5. call `client.Do`; +6. close each response body after copying at most 4 KiB to `io.Discard`; +7. return `http/` 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, attempt count, and idempotency are explicit. @@ -973,10 +978,11 @@ git commit -m "feat(api): send notifications to adapters" - Consumes: ```go -func (dispatcher *NotificationDispatcher) Send( +func (dispatcher *PhoneNotificationDispatcher) Send( ctx context.Context, phone *entities.Phone, - notification GatewayNotification, + message *messaging.Message, + notificationID uuid.UUID, ) (string, error) ``` @@ -991,7 +997,7 @@ type NotificationEventDispatcher interface { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - notificationDispatcher *NotificationDispatcher, + phoneNotificationDispatcher *PhoneNotificationDispatcher, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, @@ -1039,7 +1045,7 @@ func (repository *phoneNotificationRepository) UpdateStatus( ``` Add a fake event dispatcher that records CloudEvents and returns no error. Add a -recording notification sender to a real `NotificationDispatcher`. +recording notification sender to a real `PhoneNotificationDispatcher`. Test `Send` with an HTTPS token and assert: @@ -1085,9 +1091,9 @@ Expected: compilation fails because `PhoneNotificationService` still consumes In `api/pkg/services/phone_notification_service.go`: -- remove the Firebase `messaging` import; +- construct Firebase `messaging.Message` values directly; - replace `messagingClient FCMClient` with - `notificationDispatcher *NotificationDispatcher`; + `phoneNotificationDispatcher *PhoneNotificationDispatcher`; - change the constructor to the exact signature above; - change `eventDispatcher` to `NotificationEventDispatcher`. @@ -1095,26 +1101,27 @@ For message notifications, call: ```go ttl := phone.MessageExpirationDuration() -result, err := service.notificationDispatcher.Send(ctx, phone, GatewayNotification{ +result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, - Priority: "normal", - TTL: &ttl, - NotificationID: params.PhoneNotificationID, -}) + Android: &messaging.AndroidConfig{ + Priority: "normal", + TTL: &ttl, + }, +}, params.PhoneNotificationID) ``` For heartbeat notifications, call: ```go -result, err := service.notificationDispatcher.Send(ctx, phone, GatewayNotification{ +notificationID := uuid.New() +result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, - Priority: "high", - NotificationID: uuid.New(), -}) + Android: &messaging.AndroidConfig{Priority: "high"}, +}, notificationID) ``` - [ ] **Step 4: Add transport-aware failure text** @@ -1168,7 +1175,7 @@ Run: ```bash cd api -go test ./pkg/services -run 'TestPhoneNotificationService|TestNotificationDispatcher|TestHTTPNotificationSender' -count=1 +go test ./pkg/services -run 'TestPhoneNotificationService|TestPhoneNotificationDispatcher|TestHTTPNotificationSender' -count=1 ``` Expected: PASS. @@ -1203,9 +1210,9 @@ git commit -m "feat(api): route phone gateway wake-ups" ```go func NewNotificationEndpointPolicy(resolver HostResolver, allowedPrivateHosts []string) *NotificationEndpointPolicy -func NewNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *NotificationDispatcher +func NewPhoneNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *PhoneNotificationDispatcher func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender -func NewHTTPNotificationSender(logger telemetry.Logger, tracer telemetry.Tracer, client *http.Client, policy *NotificationEndpointPolicy) *HTTPNotificationSender +func NewHTTPNotificationSender(logger telemetry.Logger, client *http.Client) *HTTPNotificationSender ``` - Produces container factories: @@ -1213,7 +1220,7 @@ func NewHTTPNotificationSender(logger telemetry.Logger, tracer telemetry.Tracer, ```go func (container *Container) NotificationEndpointPolicy() *services.NotificationEndpointPolicy func (container *Container) NotificationHTTPClient() *http.Client -func (container *Container) NotificationDispatcher() *services.NotificationDispatcher +func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotificationDispatcher ``` - Produces validator constructor: @@ -1361,14 +1368,12 @@ context for each attempt. Add: ```go -func (container *Container) NotificationDispatcher() *services.NotificationDispatcher { - return services.NewNotificationDispatcher( +func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotificationDispatcher { + return services.NewPhoneNotificationDispatcher( services.NewFCMNotificationSender(container.FCMClient()), services.NewHTTPNotificationSender( container.Logger(), - container.Tracer(), container.NotificationHTTPClient(), - container.NotificationEndpointPolicy(), ), ) } @@ -1386,7 +1391,7 @@ allowlist. Do not cache per-request sender state. - [ ] **Step 5: Wire service and validator constructors** Change `container.NotificationService()` to pass -`container.NotificationDispatcher()` instead of `container.FCMClient()`. +`container.PhoneNotificationDispatcher()` instead of `container.FCMClient()`. Find `container.PhoneHandlerValidator()` and pass `container.NotificationEndpointPolicy()` as its fourth argument. Keep existing 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 index 90fd59dc..d29f8ddd 100644 --- 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 @@ -113,31 +113,34 @@ 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-neutral notification +### 2. Shared Firebase message contract -Add a small internal notification type containing only the data needed by both -transports: +Use Firebase's `messaging.Message` as the notification contract for both +transports instead of maintaining a duplicate DTO: ```go -type GatewayNotification struct { - Token string - Data map[string]string - Priority string - TTL *time.Duration - NotificationID string +type NotificationSender interface { + Send( + ctx context.Context, + message *messaging.Message, + notificationID uuid.UUID, + ) (string, error) } ``` -`NotificationID` is the persisted `PhoneNotification.ID` for outgoing -messages. Heartbeats have no persisted phone notification, so the service -generates a request UUID for their delivery identity. +`PhoneNotificationService` constructs `messaging.Message` directly with the +data and Android configuration. The notification ID remains a separate +argument: it is the persisted `PhoneNotification.ID` for outgoing messages, +while heartbeats generate a request UUID for delivery identity. Add a dispatcher that: 1. uses the phone helper to determine the transport; -2. delegates Firebase tokens to the Firebase sender; -3. delegates URL tokens to the HTTP sender; -4. returns a transport-neutral delivery result string or an error. +2. rejects a nil message with a stacktrace error; +3. trims the phone's configured token and assigns it to `message.Token`; +4. delegates the same message pointer and notification ID to the selected + sender; +5. returns a transport-neutral delivery result string or an error. The result string is used only by existing notification event bookkeeping. Firebase keeps the message name returned by the SDK. HTTP delivery uses a @@ -146,12 +149,9 @@ generated identifier that does not expose the callback URL. ### 3. Firebase sender Adapt the existing `FCMClient` behind the transport-neutral sender interface. -It maps `GatewayNotification` to `messaging.Message`: - -- `Data` maps directly to the FCM data payload; -- `Priority` maps to `messaging.AndroidConfig.Priority`; -- `TTL` maps to `messaging.AndroidConfig.TTL`; -- `Token` remains the FCM registration token. +`FCMNotificationSender` validates that the message is non-nil and passes the +same `*messaging.Message` directly to `FCMClient.Send` without reconstructing +the payload. The production Firebase client and emulator client remain available. Android tokens follow the same SDK path, payload keys, priorities, TTL values, success @@ -193,6 +193,11 @@ Heartbeat notifications use the same shape with: 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 @@ -309,14 +314,17 @@ not perform endpoint DNS/IP classification, custom dialing, or private-host allowlisting. `Container.NotificationHTTPClient` is a standard `http.Client` using the -existing `go-otelroundtripper` pattern without transport-level retries. A -telemetry-only seam presents scheme and host/port to OpenTelemetry while -`http.DefaultTransport` receives the original request URL, headers, and body. -The client preserves Go's default redirect behavior. - -`HTTPNotificationSender` owns retries through `retry.New(...).Do`. It makes -exactly three total attempts, creates a fresh request and body for each -attempt, and applies a five-second context per attempt. +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 @@ -344,8 +352,9 @@ 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. Notification -attempt metrics record the attempt number, response status class, and result. +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 @@ -357,7 +366,9 @@ Implementation is expected to touch: notifications and preserve existing state transitions; - `api/pkg/services/fcm_client.go` to adapt Firebase to the neutral sender; - `api/pkg/services/notification_sender.go` for the neutral notification, - sender interface, and dispatcher; + sender interface, and Firebase sender; +- `api/pkg/services/phone_notification_dispatcher.go` for phone transport + routing; - `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 From 9512759ebce6e1a724f051aa1b52c3c17a340323 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 3 Sep 2026 20:04:45 +0300 Subject: [PATCH 21/22] refactor(api): map notification clients Reuse the existing FCMClient contract across Firebase and HTTP so new phone transports only require DI map configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/di/container.go | 14 +- api/pkg/di/container_test.go | 15 +- api/pkg/services/fcm_client.go | 4 +- api/pkg/services/http_notification_sender.go | 14 +- .../services/http_notification_sender_test.go | 37 +- api/pkg/services/notification_sender.go | 44 --- api/pkg/services/notification_sender_test.go | 64 ---- .../services/phone_notification_dispatcher.go | 58 --- .../phone_notification_dispatcher_test.go | 105 ------ .../services/phone_notification_service.go | 60 +++- .../phone_notification_service_test.go | 145 ++++++-- ...2-url-backed-phone-notification-adapter.md | 333 ++++++------------ ...acked-phone-notification-adapter-design.md | 97 +++-- tests/adapter-emulator/emulator.go | 65 ++-- tests/adapter-emulator/emulator_test.go | 79 ++--- .../adapter-emulator/notification_handler.go | 27 +- tests/adapter_integration_test.go | 1 - tests/helpers_test.go | 14 +- 18 files changed, 406 insertions(+), 770 deletions(-) delete mode 100644 api/pkg/services/notification_sender.go delete mode 100644 api/pkg/services/notification_sender_test.go delete mode 100644 api/pkg/services/phone_notification_dispatcher.go delete mode 100644 api/pkg/services/phone_notification_dispatcher_test.go diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 45be2573..8d9e778d 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -572,15 +572,15 @@ func (container *Container) NotificationHTTPClient() *http.Client { } } -// PhoneNotificationDispatcher creates notification senders for Firebase and HTTP gateways. -func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotificationDispatcher { - return services.NewPhoneNotificationDispatcher( - services.NewFCMNotificationSender(container.FCMClient()), - services.NewHTTPNotificationSender( +// 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. @@ -1733,7 +1733,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi return services.NewNotificationService( container.Logger(), container.Tracer(), - container.PhoneNotificationDispatcher(), + container.PhoneNotificationClients(), container.PhoneRepository(), container.PhoneNotificationRepository(), container.MessageSendScheduleRepository(), diff --git a/api/pkg/di/container_test.go b/api/pkg/di/container_test.go index 2b0be2d8..611708d3 100644 --- a/api/pkg/di/container_test.go +++ b/api/pkg/di/container_test.go @@ -4,7 +4,10 @@ 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) { @@ -16,13 +19,17 @@ func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) assert.Nil(t, client.CheckRedirect) } -func TestPhoneNotificationDispatcherInjectsNotificationHTTPClient(t *testing.T) { +func TestPhoneNotificationClientsMapsConfiguredTransports(t *testing.T) { t.Setenv("ENV", "local") t.Setenv("FCM_ENDPOINT", "http://localhost") - dispatcher := NewLiteContainer().PhoneNotificationDispatcher() - httpSender := reflect.ValueOf(dispatcher).Elem().FieldByName("httpSender").Elem().Elem() - client := httpSender.FieldByName("client").Elem() + 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/services/fcm_client.go b/api/pkg/services/fcm_client.go index 78f5fb40..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 low-level Firebase SDK boundary used by FCMNotificationSender. +// 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 index 41d91e48..9477611a 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -14,7 +14,6 @@ import ( "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" "github.com/avast/retry-go/v5" - "github.com/google/uuid" ) const ( @@ -32,6 +31,8 @@ type HTTPNotificationSender struct { timeout time.Duration } +var _ FCMClient = (*HTTPNotificationSender)(nil) + // NewHTTPNotificationSender creates an HTTP notification sender. func NewHTTPNotificationSender( logger telemetry.Logger, @@ -61,7 +62,6 @@ func newHTTPNotificationSenderWithRetrier( func (sender *HTTPNotificationSender) Send( ctx context.Context, message *messaging.Message, - notificationID uuid.UUID, ) (string, error) { if message == nil { return "", sender.notificationError("", "notification message is nil") @@ -79,10 +79,10 @@ func (sender *HTTPNotificationSender) Send( } err = sender.retrier.Do(func() error { - return sender.deliver(ctx, endpoint, body, notificationID.String()) + return sender.deliver(ctx, endpoint, body) }) if err == nil { - return "http/" + notificationID.String(), nil + return "http/success", nil } if ctx.Err() != nil { return "", sender.notificationError(hostname, "notification request cancelled") @@ -101,7 +101,6 @@ func (sender *HTTPNotificationSender) deliver( ctx context.Context, endpoint *url.URL, body []byte, - notificationID string, ) error { if err := ctx.Err(); err != nil { return terminalNotificationRequestError{cause: err} @@ -110,7 +109,7 @@ func (sender *HTTPNotificationSender) deliver( attemptCtx, cancel := context.WithTimeout(ctx, sender.timeout) defer cancel() - request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body, notificationID) + request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body) if err != nil { return terminalNotificationRequestError{cause: err} } @@ -126,7 +125,6 @@ func createHTTPNotificationRequest( ctx context.Context, endpoint *url.URL, body []byte, - notificationID string, ) (*http.Request, error) { request, err := http.NewRequestWithContext( ctx, @@ -137,9 +135,7 @@ func createHTTPNotificationRequest( if err != nil { return nil, err } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("X-httpSMS-Notification-ID", notificationID) return request, nil } diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 17ba1073..075f99c8 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -13,7 +13,6 @@ import ( "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" @@ -37,7 +36,6 @@ type httpNotificationPayload struct { } func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { - notificationID := uuid.New() ttl := 10 * time.Minute message := &messaging.Message{ Token: "https://adapter.example.com/notify", @@ -50,7 +48,6 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { 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")) - assert.Equal(t, notificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) var payload httpNotificationPayload require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) @@ -63,10 +60,10 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { return response(http.StatusNoContent, http.NoBody), nil })) - result, err := sender.Send(context.Background(), message, notificationID) + result, err := sender.Send(context.Background(), message) require.NoError(t, err) - assert.Equal(t, "http/"+notificationID.String(), result) + assert.Equal(t, "http/success", result) } func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { @@ -141,10 +138,8 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - var notificationIDs []string calls := 0 - sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { - notificationIDs = append(notificationIDs, request.Header.Get("X-httpSMS-Notification-ID")) + sender := newHTTPNotificationSender(t, roundTripFunc(func(_ *http.Request) (*http.Response, error) { outcome := test.outcomes[calls] calls++ if outcome.err != nil { @@ -152,12 +147,9 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { } return response(outcome.statusCode, http.NoBody), nil })) - notificationID := uuid.New() - - _, err := sender.Send( + result, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, - notificationID, ) if test.wantErr { @@ -166,7 +158,9 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { require.NoError(t, err) } assert.Equal(t, test.wantCalls, calls) - assert.Equal(t, makeNotificationIDs(notificationID.String(), test.wantCalls), notificationIDs) + if !test.wantErr { + assert.Equal(t, "http/success", result) + } }) } } @@ -185,7 +179,6 @@ func TestHTTPNotificationSenderReusesRetrierAcrossSends(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, - uuid.New(), ) require.NoError(t, err) } @@ -213,7 +206,6 @@ func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *test Token: "https://adapter.example.com/notify", Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, }, - uuid.New(), ) require.NoError(t, err) @@ -235,7 +227,6 @@ func TestHTTPNotificationSenderBoundsResponseBodyDiscard(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, - uuid.New(), ) require.NoError(t, err) @@ -262,7 +253,6 @@ func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { Priority: "high", }, }, - uuid.New(), ) require.NoError(t, err) @@ -302,7 +292,6 @@ func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: endpoint.String()}, - uuid.New(), ) require.NoError(t, err) @@ -320,7 +309,6 @@ func TestHTTPNotificationSenderBoundsEveryAttemptByTimeout(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, - uuid.New(), ) require.Error(t, err) @@ -340,7 +328,6 @@ func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testi _, err := sender.Send( ctx, &messaging.Message{Token: "https://adapter.example.com/notify"}, - uuid.New(), ) require.Error(t, err) @@ -352,7 +339,7 @@ func TestHTTPNotificationSenderRejectsNilMessage(t *testing.T) { return response(http.StatusNoContent, http.NoBody), nil })) - _, err := sender.Send(context.Background(), nil, uuid.New()) + _, err := sender.Send(context.Background(), nil) require.Error(t, err) assert.Contains(t, err.Error(), "notification message is nil") @@ -437,11 +424,3 @@ func response(statusCode int, body io.ReadCloser) *http.Response { Header: make(http.Header), } } - -func makeNotificationIDs(notificationID string, length int) []string { - notificationIDs := make([]string, length) - for index := range notificationIDs { - notificationIDs[index] = notificationID - } - return notificationIDs -} diff --git a/api/pkg/services/notification_sender.go b/api/pkg/services/notification_sender.go deleted file mode 100644 index b1d37607..00000000 --- a/api/pkg/services/notification_sender.go +++ /dev/null @@ -1,44 +0,0 @@ -package services - -import ( - "context" - - "firebase.google.com/go/messaging" - "github.com/NdoleStudio/stacktrace" - "github.com/google/uuid" -) - -// NotificationSender delivers a notification to a transport-specific destination. -type NotificationSender interface { - Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) -} - -// FCMNotificationSender delivers gateway notifications through Firebase Cloud Messaging. -type FCMNotificationSender struct { - client FCMClient -} - -// NewFCMNotificationSender creates a Firebase notification sender. -func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender { - return &FCMNotificationSender{client: client} -} - -// Send delivers a gateway notification through Firebase Cloud Messaging. -func (sender *FCMNotificationSender) Send( - ctx context.Context, - message *messaging.Message, - _ uuid.UUID, -) (string, error) { - if message == nil { - return "", stacktrace.Propagatef( - stacktrace.NewErrorf("notification message is nil"), - "cannot send Firebase notification", - ) - } - - result, err := sender.client.Send(ctx, message) - if err != nil { - return "", stacktrace.Propagatef(err, "cannot send Firebase notification") - } - return result, nil -} diff --git a/api/pkg/services/notification_sender_test.go b/api/pkg/services/notification_sender_test.go deleted file mode 100644 index 020cee52..00000000 --- a/api/pkg/services/notification_sender_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package services - -import ( - "context" - "testing" - "time" - - "firebase.google.com/go/messaging" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type recordingFCMClient struct { - message *messaging.Message - result string - err error - calls int -} - -func (client *recordingFCMClient) Send(_ context.Context, message *messaging.Message) (string, error) { - client.calls++ - client.message = message - return client.result, client.err -} - -func TestFCMNotificationSenderPassesMessageUnchanged(t *testing.T) { - ttl := 5 * time.Minute - data := map[string]string{"KEY_MESSAGE_ID": uuid.NewString()} - client := &recordingFCMClient{result: "projects/test/messages/1"} - sender := NewFCMNotificationSender(client) - message := &messaging.Message{ - Token: "fcm-token:value", - Data: data, - Android: &messaging.AndroidConfig{ - Priority: "normal", - TTL: &ttl, - }, - } - - result, err := sender.Send(context.Background(), message, uuid.New()) - - require.NoError(t, err) - assert.Equal(t, "projects/test/messages/1", result) - require.Equal(t, 1, client.calls) - assert.Same(t, message, client.message) - assert.Equal(t, "fcm-token:value", client.message.Token) - assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": data["KEY_MESSAGE_ID"]}, client.message.Data) - require.NotNil(t, client.message.Android) - assert.Equal(t, "normal", client.message.Android.Priority) - assert.Equal(t, &ttl, client.message.Android.TTL) - assert.Equal(t, map[string]string{"KEY_MESSAGE_ID": data["KEY_MESSAGE_ID"]}, data) -} - -func TestFCMNotificationSenderRejectsNilMessage(t *testing.T) { - client := &recordingFCMClient{} - sender := NewFCMNotificationSender(client) - - _, err := sender.Send(context.Background(), nil, uuid.New()) - - require.Error(t, err) - assert.Contains(t, err.Error(), "notification message is nil") - assert.Zero(t, client.calls) -} diff --git a/api/pkg/services/phone_notification_dispatcher.go b/api/pkg/services/phone_notification_dispatcher.go deleted file mode 100644 index ffcb078e..00000000 --- a/api/pkg/services/phone_notification_dispatcher.go +++ /dev/null @@ -1,58 +0,0 @@ -package services - -import ( - "context" - "strings" - - "firebase.google.com/go/messaging" - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/NdoleStudio/stacktrace" - "github.com/google/uuid" -) - -// PhoneNotificationDispatcher routes gateway notifications to the phone's configured transport. -type PhoneNotificationDispatcher struct { - fcmSender NotificationSender - httpSender NotificationSender -} - -// NewPhoneNotificationDispatcher creates a dispatcher for FCM and HTTP notification transports. -func NewPhoneNotificationDispatcher( - fcmSender NotificationSender, - httpSender NotificationSender, -) *PhoneNotificationDispatcher { - return &PhoneNotificationDispatcher{ - fcmSender: fcmSender, - httpSender: httpSender, - } -} - -// Send delivers a notification using the phone's configured notification transport. -func (dispatcher *PhoneNotificationDispatcher) Send( - ctx context.Context, - phone *entities.Phone, - message *messaging.Message, - notificationID uuid.UUID, -) (string, error) { - transport, err := phone.NotificationTransport() - if err != nil { - return "", stacktrace.Propagatef(err, "cannot determine notification transport for phone [%s]", phone.ID) - } - if message == nil { - return "", stacktrace.Propagatef( - stacktrace.NewErrorf("notification message is nil"), - "cannot dispatch notification for phone [%s]", - phone.ID, - ) - } - - message.Token = strings.TrimSpace(*phone.FcmToken) - switch transport { - case entities.NotificationTransportFCM: - return dispatcher.fcmSender.Send(ctx, message, notificationID) - case entities.NotificationTransportHTTP: - return dispatcher.httpSender.Send(ctx, message, notificationID) - default: - return "", stacktrace.NewErrorf("unsupported notification transport [%s]", transport) - } -} diff --git a/api/pkg/services/phone_notification_dispatcher_test.go b/api/pkg/services/phone_notification_dispatcher_test.go deleted file mode 100644 index f7c5a7af..00000000 --- a/api/pkg/services/phone_notification_dispatcher_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package services - -import ( - "context" - "testing" - - "firebase.google.com/go/messaging" - "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type recordingNotificationSender struct { - destination string - message *messaging.Message - notificationID uuid.UUID - result string - err error - calls int -} - -func (sender *recordingNotificationSender) Send( - _ context.Context, - message *messaging.Message, - notificationID uuid.UUID, -) (string, error) { - sender.calls++ - sender.message = message - sender.notificationID = notificationID - if message != nil { - sender.destination = message.Token - } - return sender.result, sender.err -} - -func TestPhoneNotificationDispatcherRoutesFCMToken(t *testing.T) { - token := " fcm-token:value " - phone := &entities.Phone{FcmToken: &token} - fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} - httpSender := &recordingNotificationSender{} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} - notificationID := uuid.New() - - result, err := dispatcher.Send(context.Background(), phone, message, notificationID) - - require.NoError(t, err) - assert.Equal(t, "projects/test/messages/1", result) - assert.Equal(t, 1, fcmSender.calls) - assert.Zero(t, httpSender.calls) - assert.Equal(t, "fcm-token:value", fcmSender.destination) - assert.Equal(t, "fcm-token:value", message.Token) - assert.Same(t, message, fcmSender.message) - assert.Equal(t, notificationID, fcmSender.notificationID) -} - -func TestPhoneNotificationDispatcherRoutesHTTPSURL(t *testing.T) { - endpoint := "https://adapter.example.com/notifications/gateway-1" - phone := &entities.Phone{FcmToken: &endpoint} - fcmSender := &recordingNotificationSender{} - httpSender := &recordingNotificationSender{result: "accepted"} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} - notificationID := uuid.New() - - result, err := dispatcher.Send(context.Background(), phone, message, notificationID) - - require.NoError(t, err) - assert.Equal(t, "accepted", result) - assert.Zero(t, fcmSender.calls) - assert.Equal(t, 1, httpSender.calls) - assert.Equal(t, endpoint, httpSender.destination) - assert.Same(t, message, httpSender.message) - assert.Equal(t, notificationID, httpSender.notificationID) -} - -func TestPhoneNotificationDispatcherRejectsInvalidURLLikeTokenWithoutSending(t *testing.T) { - token := "https://" - phone := &entities.Phone{FcmToken: &token} - fcmSender := &recordingNotificationSender{} - httpSender := &recordingNotificationSender{} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - - _, err := dispatcher.Send(context.Background(), phone, &messaging.Message{}, uuid.New()) - - require.Error(t, err) - assert.Zero(t, fcmSender.calls) - assert.Zero(t, httpSender.calls) -} - -func TestPhoneNotificationDispatcherRejectsNilMessage(t *testing.T) { - token := "fcm-token:value" - phone := &entities.Phone{ID: uuid.New(), FcmToken: &token} - fcmSender := &recordingNotificationSender{} - httpSender := &recordingNotificationSender{} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - - _, err := dispatcher.Send(context.Background(), phone, nil, uuid.New()) - - require.Error(t, err) - assert.Contains(t, err.Error(), "notification message is nil") - assert.Zero(t, fcmSender.calls) - assert.Zero(t, httpSender.calls) -} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 2082a3fe..d3166854 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "firebase.google.com/go/messaging" @@ -26,7 +27,7 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - phoneNotificationDispatcher *PhoneNotificationDispatcher + phoneNotificationClients map[entities.NotificationTransport]FCMClient eventDispatcher *EventDispatcher } @@ -34,7 +35,7 @@ type PhoneNotificationService struct { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - phoneNotificationDispatcher *PhoneNotificationDispatcher, + phoneNotificationClients map[entities.NotificationTransport]FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, @@ -43,7 +44,7 @@ func NewNotificationService( return &PhoneNotificationService{ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})), tracer: tracer, - phoneNotificationDispatcher: phoneNotificationDispatcher, + phoneNotificationClients: phoneNotificationClients, phoneNotificationRepository: phoneNotificationRepository, phoneRepository: phoneRepository, messageSendScheduleRepository: messageSendScheduleRepository, @@ -91,13 +92,12 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID)) } - notificationID := uuid.New() - result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &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"}, - }, notificationID) + }) if err != nil { ctxLogger.Warn(stacktrace.Propagatef( err, @@ -145,7 +145,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone } ttl := phone.MessageExpirationDuration() - result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.Message{ + result, transport, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, @@ -153,12 +153,11 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone Priority: "normal", TTL: &ttl, }, - }, params.PhoneNotificationID) + }) if err != nil { - transport, transportErr := phone.NotificationTransport() - if transportErr != nil { + if transport == "" { ctxLogger.Warn(stacktrace.Propagatef( - transportErr, + err, "cannot determine notification transport for phone with ID [%s] for user with ID [%s] and message [%s]", phone.ID, phone.UserID, @@ -189,6 +188,45 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone 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 index ebf7a604..9908f5d9 100644 --- a/api/pkg/services/phone_notification_service_test.go +++ b/api/pkg/services/phone_notification_service_test.go @@ -7,6 +7,7 @@ import ( "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" @@ -87,6 +88,79 @@ 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{ @@ -96,10 +170,18 @@ func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) { PhoneNumber: "+18005550199", MessageExpirationSeconds: 90, } - httpSender := &recordingNotificationSender{result: "http/notification-1"} + httpClient := &recordingPhoneNotificationClient{result: "http/success"} eventQueue := &phoneNotificationEventQueue{} notificationRepository := &phoneNotificationRepository{} - service := newPhoneNotificationServiceForTest(phone, notificationRepository, eventQueue, &recordingNotificationSender{}, httpSender) + service := newPhoneNotificationServiceForTest( + phone, + notificationRepository, + eventQueue, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, + ) params := &PhoneNotificationSendParams{ UserID: phone.UserID, PhoneID: phone.ID, @@ -111,14 +193,13 @@ func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) { require.NoError(t, service.Send(context.Background(), params)) - require.NotNil(t, httpSender.message) - assert.Equal(t, endpoint, httpSender.message.Token) - assert.Equal(t, params.MessageID.String(), httpSender.message.Data["KEY_MESSAGE_ID"]) - require.NotNil(t, httpSender.message.Android) - assert.Equal(t, "normal", httpSender.message.Android.Priority) - require.NotNil(t, httpSender.message.Android.TTL) - assert.Equal(t, phone.MessageExpirationDuration(), *httpSender.message.Android.TTL) - assert.Equal(t, params.PhoneNotificationID, httpSender.notificationID) + 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) @@ -134,8 +215,12 @@ func TestPhoneNotificationServiceSendHTTPFailureUsesAdapterGuidance(t *testing.T phone, notificationRepository, eventQueue, - &recordingNotificationSender{}, - &recordingNotificationSender{err: errors.New("adapter unavailable")}, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{ + err: errors.New("adapter unavailable"), + }, + }, ) params := &PhoneNotificationSendParams{ UserID: phone.UserID, @@ -160,12 +245,15 @@ func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testi 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, - &recordingNotificationSender{err: errors.New("firebase unavailable")}, - &recordingNotificationSender{}, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: fcmClient, + entities.NotificationTransportHTTP: &recordingPhoneNotificationClient{}, + }, ) params := &PhoneNotificationSendParams{ UserID: phone.UserID, @@ -178,6 +266,9 @@ func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testi 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) @@ -186,13 +277,15 @@ func TestPhoneNotificationServiceSendFCMFailurePreservesAndroidGuidance(t *testi func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSMessage(t *testing.T) { endpoint := "https://adapter.example.com/notify" phone := &entities.Phone{ID: uuid.New(), UserID: "user-1", FcmToken: &endpoint} - httpSender := &recordingNotificationSender{err: errors.New("adapter unavailable")} + httpClient := &recordingPhoneNotificationClient{err: errors.New("adapter unavailable")} service := newPhoneNotificationServiceForTest( phone, &phoneNotificationRepository{}, &phoneNotificationEventQueue{}, - &recordingNotificationSender{}, - httpSender, + map[entities.NotificationTransport]FCMClient{ + entities.NotificationTransportFCM: &recordingPhoneNotificationClient{}, + entities.NotificationTransportHTTP: httpClient, + }, ) err := service.SendHeartbeatFCM(context.Background(), &events.PhoneHeartbeatMissedPayload{ @@ -202,30 +295,28 @@ func TestPhoneNotificationServiceSendHeartbeatFCMUsesHTTPSMessage(t *testing.T) }) require.NoError(t, err) - require.NotNil(t, httpSender.message) - assert.Equal(t, endpoint, httpSender.message.Token) - heartbeatID := httpSender.message.Data["KEY_HEARTBEAT_ID"] + 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, httpSender.message.Android) - assert.Equal(t, "high", httpSender.message.Android.Priority) - assert.Nil(t, httpSender.message.Android.TTL) - assert.NotEqual(t, uuid.Nil, httpSender.notificationID) + 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, - fcmSender NotificationSender, - httpSender NotificationSender, + clients map[entities.NotificationTransport]FCMClient, ) *PhoneNotificationService { logger := &phoneNotificationLogger{} tracer := telemetry.NewOtelLogger("test", logger) return NewNotificationService( logger, tracer, - NewPhoneNotificationDispatcher(fcmSender, httpSender), + clients, &phoneNotificationPhoneRepository{phone: phone}, notificationRepository, 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 index 198d9116..6b81ee35 100644 --- 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 @@ -4,7 +4,7 @@ **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`, then route a shared Firebase `*messaging.Message` through Firebase and HTTP senders while passing the delivery UUID separately. 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. +**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. @@ -33,12 +33,14 @@ private-host allowlist. or spans. - No endpoint DNS/IP/SSRF policy, custom notification transport, or private-host allowlist is part of the final implementation. -- The transport router is `PhoneNotificationDispatcher`; domain events continue - to use the existing `EventDispatcher` directly. -- `PhoneNotificationService` constructs `*messaging.Message` directly. - `PhoneNotificationDispatcher` sets the trimmed destination in `message.Token` - and passes the same pointer to either sender with the notification UUID as a - separate argument. Nil messages fail explicitly with stacktrace errors. +- `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 @@ -67,12 +69,10 @@ private-host allowlist. - `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/notification_sender.go` - shared sender interface and Firebase adapter. -- `api/pkg/services/notification_sender_test.go` - Firebase payload mapping tests. -- `api/pkg/services/phone_notification_dispatcher.go` - phone transport routing. -- `api/pkg/services/phone_notification_dispatcher_test.go` - dispatcher routing tests and sender fake. +- `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, idempotency, and response tests. +- `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. @@ -91,7 +91,8 @@ private-host allowlist. - `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 policy, SSRF-safe HTTP transport/client, senders, dispatcher, and updated validator/service dependencies. +- `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. @@ -554,14 +555,12 @@ git commit -m "feat(api): validate adapter endpoints" --- -### Task 3: Add the Notification Dispatcher and Firebase Adapter +### Task 3: Add the Transport Client Map **Files:** -- Create: `api/pkg/services/notification_sender.go` -- Create: `api/pkg/services/notification_sender_test.go` -- Create: `api/pkg/services/phone_notification_dispatcher.go` -- Create: `api/pkg/services/phone_notification_dispatcher_test.go` - 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: @@ -573,175 +572,83 @@ func (phone *entities.Phone) NotificationTransport() (entities.NotificationTrans - Produces: ```go -type NotificationSender interface { - Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) -} - -type PhoneNotificationDispatcher struct { - fcmSender NotificationSender - httpSender NotificationSender -} - -func NewPhoneNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *PhoneNotificationDispatcher -func (dispatcher *PhoneNotificationDispatcher) Send(ctx context.Context, phone *entities.Phone, message *messaging.Message, notificationID uuid.UUID) (string, error) - -type FCMNotificationSender struct { - client FCMClient +type FCMClient interface { + Send(context.Context, *messaging.Message) (string, error) } - -func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender -func (sender *FCMNotificationSender) Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) ``` -- [ ] **Step 1: Write failing dispatcher and Firebase mapping tests** +- [ ] **Step 1: Write failing transport-client map tests** -Create `api/pkg/services/phone_notification_dispatcher_test.go` with a recording sender: +Create a recording client in `phone_notification_service_test.go`: ```go -type recordingNotificationSender struct { - message *messaging.Message - notificationID uuid.UUID - result string - err error - calls int +type recordingPhoneNotificationClient struct { + message *messaging.Message + result string + err error + calls int } -func (sender *recordingNotificationSender) Send(_ context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) { - sender.calls++ - sender.message = message - sender.notificationID = notificationID - return sender.result, sender.err -} -``` - -Add tests that assert: - -```go -func TestPhoneNotificationDispatcherRoutesFCMToken(t *testing.T) { - token := "fcm-token:value" - phone := &entities.Phone{FcmToken: &token} - fcmSender := &recordingNotificationSender{result: "projects/test/messages/1"} - httpSender := &recordingNotificationSender{} - dispatcher := NewPhoneNotificationDispatcher(fcmSender, httpSender) - message := &messaging.Message{Data: map[string]string{"KEY_MESSAGE_ID": uuid.NewString()}} - notificationID := uuid.New() - - result, err := dispatcher.Send(context.Background(), phone, message, notificationID) - - require.NoError(t, err) - assert.Equal(t, "projects/test/messages/1", result) - assert.Equal(t, 1, fcmSender.calls) - assert.Zero(t, httpSender.calls) - assert.Same(t, message, fcmSender.message) - assert.Equal(t, token, message.Token) - assert.Equal(t, notificationID, fcmSender.notificationID) +func (client *recordingPhoneNotificationClient) Send(_ context.Context, message *messaging.Message) (string, error) { + client.calls++ + client.message = message + return client.result, client.err } ``` -Add the equivalent HTTPS routing test and an invalid URL-like token test that -asserts neither sender is called. - -In `notification_sender_test.go`, create a recording `FCMClient` and assert -`FCMNotificationSender.Send` passes the exact message pointer through without -reconstruction. Add explicit nil-message tests for the dispatcher and sender. +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 sender tests and confirm the API is missing** +- [ ] **Step 2: Run the service tests and confirm the map dependency is missing** Run: ```bash cd api -go test ./pkg/services -run 'TestPhoneNotificationDispatcher|TestFCMNotificationSender' -count=1 +go test ./pkg/services -run 'TestPhoneNotificationService' -count=1 ``` -Expected: compilation fails because the shared sender contract does not exist. +Expected: compilation fails because the service does not accept the client map. -- [ ] **Step 3: Implement the neutral notification and dispatcher** +- [ ] **Step 3: Implement focused map-based delivery** -Keep the shared sender interface and Firebase sender in -`api/pkg/services/notification_sender.go`. Implement dispatcher routing in -`api/pkg/services/phone_notification_dispatcher.go`: +Store `map[entities.NotificationTransport]FCMClient` on +`PhoneNotificationService` and implement: ```go -func (dispatcher *PhoneNotificationDispatcher) Send( +func (service *PhoneNotificationService) sendPhoneNotification( ctx context.Context, phone *entities.Phone, message *messaging.Message, - notificationID uuid.UUID, -) (string, error) { - transport, err := phone.NotificationTransport() - if err != nil { - return "", stacktrace.Propagatef(err, "cannot determine notification transport for phone [%s]", phone.ID) - } - - if message == nil { - return "", stacktrace.Propagatef( - stacktrace.NewErrorf("notification message is nil"), - "cannot dispatch notification for phone [%s]", - phone.ID, - ) - } - - message.Token = strings.TrimSpace(*phone.FcmToken) - switch transport { - case entities.NotificationTransportFCM: - return dispatcher.fcmSender.Send(ctx, message, notificationID) - case entities.NotificationTransportHTTP: - return dispatcher.httpSender.Send(ctx, message, notificationID) - default: - return "", stacktrace.NewErrorf("unsupported notification transport [%s]", transport) - } -} -``` - -- [ ] **Step 4: Implement the Firebase adapter** - -In the same file, implement: - -```go -func (sender *FCMNotificationSender) Send( - ctx context.Context, - message *messaging.Message, - _ uuid.UUID, -) (string, error) { - if message == nil { - return "", stacktrace.Propagatef( - stacktrace.NewErrorf("notification message is nil"), - "cannot send Firebase notification", - ) - } - - result, err := sender.client.Send(ctx, message) - if err != nil { - return "", stacktrace.Propagatef(err, "cannot send Firebase notification") - } - return result, nil +) (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 low-level Firebase SDK boundary used by `FCMNotificationSender`. Do not -change `FirebaseFCMClient.Send` or `EmulatorFCMClient.Send`. +the common phone notification transport boundary. Use the Firebase client +directly; do not add a wrapper or dispatcher. -- [ ] **Step 5: Run focused tests** +- [ ] **Step 4: Run focused tests** Run: ```bash cd api -go test ./pkg/services -run 'TestPhoneNotificationDispatcher|TestFCMNotificationSender' -count=1 +go test ./pkg/services -run 'TestPhoneNotificationService' -count=1 ``` Expected: PASS. -- [ ] **Step 6: Format and commit** +- [ ] **Step 5: Format and commit** Run: ```bash cd api -go-fumpt -w pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/phone_notification_dispatcher.go pkg/services/phone_notification_dispatcher_test.go pkg/services/fcm_client.go -git add pkg/services/notification_sender.go pkg/services/notification_sender_test.go pkg/services/phone_notification_dispatcher.go pkg/services/phone_notification_dispatcher_test.go pkg/services/fcm_client.go +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" ``` @@ -757,11 +664,9 @@ git commit -m "refactor(api): dispatch gateway notifications" - Consumes: ```go -type NotificationSender interface { - Send(ctx context.Context, message *messaging.Message, notificationID uuid.UUID) (string, error) +type FCMClient interface { + Send(context.Context, *messaging.Message) (string, error) } - -func (policy *NotificationEndpointPolicy) Validate(ctx context.Context, endpoint *url.URL) ([]netip.Addr, error) ``` - Produces: @@ -782,7 +687,6 @@ func NewHTTPNotificationSender( func (sender *HTTPNotificationSender) Send( ctx context.Context, message *messaging.Message, - notificationID uuid.UUID, ) (string, error) ``` @@ -809,7 +713,6 @@ Assert the request: ```go assert.Equal(t, http.MethodPost, request.Method) assert.Equal(t, "application/json", request.Header.Get("Content-Type")) -assert.Equal(t, notificationID.String(), request.Header.Get("X-httpSMS-Notification-ID")) ``` Keep a test-only decoding struct and assert this structure: @@ -827,8 +730,7 @@ type httpNotificationRequest struct { } ``` -Return `204 No Content` and assert `Send` succeeds with result -`http/`. +Return `204 No Content` and assert `Send` succeeds with result `http/success`. - [ ] **Step 2: Write failing retry-classification tests** @@ -845,8 +747,7 @@ Add table-driven tests for: - response with a body larger than the discard limit: success without reading unbounded content. -Record each request's notification ID and assert it does not change between -attempts. +Assert retry attempts receive fresh requests and bodies. - [ ] **Step 3: Run the HTTP sender tests and confirm the type is missing** @@ -916,32 +817,24 @@ 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` and `X-httpSMS-Notification-ID`; +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/` for any `2xx`; +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, attempt count, and idempotency are explicit. +so status classification and attempt count are explicit. - [ ] **Step 6: Add heartbeat tests** -Add a test using: - -```text -https://adapter.example.com/secret/path?token=customer-secret -``` - -Force a terminal error and assert neither `secret/path`, -`customer-secret`, nor the full destination appears in the returned error or -recording logger. Assert `adapter.example.com` may appear. - -Add a heartbeat payload test with `KEY_HEARTBEAT_ID`, high priority, nil TTL, -and a generated notification ID; assert the `ttl` field is omitted. +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** @@ -978,12 +871,7 @@ git commit -m "feat(api): send notifications to adapters" - Consumes: ```go -func (dispatcher *PhoneNotificationDispatcher) Send( - ctx context.Context, - phone *entities.Phone, - message *messaging.Message, - notificationID uuid.UUID, -) (string, error) +map[entities.NotificationTransport]FCMClient ``` - Produces: @@ -997,7 +885,7 @@ type NotificationEventDispatcher interface { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - phoneNotificationDispatcher *PhoneNotificationDispatcher, + phoneNotificationClients map[entities.NotificationTransport]FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, @@ -1044,15 +932,15 @@ func (repository *phoneNotificationRepository) UpdateStatus( } ``` -Add a fake event dispatcher that records CloudEvents and returns no error. Add a -recording notification sender to a real `PhoneNotificationDispatcher`. +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()`; -- `NotificationID` equals `params.PhoneNotificationID`; +- the same `messaging.Message` pointer reaches the HTTP client; - a `message.notification.sent` event is dispatched; - phone-notification status becomes sent. @@ -1071,10 +959,11 @@ Test `SendHeartbeatFCM` with an HTTPS token and assert: - `KEY_HEARTBEAT_ID` parses as RFC3339; - priority is `high`; - TTL is nil; -- `NotificationID` is non-zero; - 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: @@ -1092,16 +981,20 @@ Expected: compilation fails because `PhoneNotificationService` still consumes In `api/pkg/services/phone_notification_service.go`: - construct Firebase `messaging.Message` values directly; -- replace `messagingClient FCMClient` with - `phoneNotificationDispatcher *PhoneNotificationDispatcher`; +- replace the direct client with + `phoneNotificationClients map[entities.NotificationTransport]FCMClient`; - change the constructor to the exact signature above; - change `eventDispatcher` to `NotificationEventDispatcher`. -For message notifications, call: +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() -result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.Message{ +message := &messaging.Message{ Data: map[string]string{ "KEY_MESSAGE_ID": params.MessageID.String(), }, @@ -1109,25 +1002,24 @@ result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.M Priority: "normal", TTL: &ttl, }, -}, params.PhoneNotificationID) +} ``` For heartbeat notifications, call: ```go -notificationID := uuid.New() -result, err := service.phoneNotificationDispatcher.Send(ctx, phone, &messaging.Message{ +message := &messaging.Message{ Data: map[string]string{ "KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339), }, Android: &messaging.AndroidConfig{Priority: "high"}, -}, notificationID) +} ``` - [ ] **Step 4: Add transport-aware failure text** -After a dispatcher error, obtain the phone's transport through -`phone.NotificationTransport()`. +Use the transport returned by `sendPhoneNotification`; do not classify the +phone a second time. For HTTP use: @@ -1175,7 +1067,7 @@ Run: ```bash cd api -go test ./pkg/services -run 'TestPhoneNotificationService|TestPhoneNotificationDispatcher|TestHTTPNotificationSender' -count=1 +go test ./pkg/services -run 'TestPhoneNotificationService|TestHTTPNotificationSender' -count=1 ``` Expected: PASS. @@ -1209,18 +1101,15 @@ git commit -m "feat(api): route phone gateway wake-ups" - Consumes: ```go -func NewNotificationEndpointPolicy(resolver HostResolver, allowedPrivateHosts []string) *NotificationEndpointPolicy -func NewPhoneNotificationDispatcher(fcmSender NotificationSender, httpSender NotificationSender) *PhoneNotificationDispatcher -func NewFCMNotificationSender(client FCMClient) *FCMNotificationSender +func (container *Container) FCMClient() services.FCMClient func NewHTTPNotificationSender(logger telemetry.Logger, client *http.Client) *HTTPNotificationSender ``` - Produces container factories: ```go -func (container *Container) NotificationEndpointPolicy() *services.NotificationEndpointPolicy func (container *Container) NotificationHTTPClient() *http.Client -func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotificationDispatcher +func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient ``` - Produces validator constructor: @@ -1368,14 +1257,14 @@ context for each attempt. Add: ```go -func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotificationDispatcher { - return services.NewPhoneNotificationDispatcher( - services.NewFCMNotificationSender(container.FCMClient()), - services.NewHTTPNotificationSender( +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(), ), - ) + } } ``` @@ -1391,7 +1280,7 @@ allowlist. Do not cache per-request sender state. - [ ] **Step 5: Wire service and validator constructors** Change `container.NotificationService()` to pass -`container.PhoneNotificationDispatcher()` instead of `container.FCMClient()`. +`container.PhoneNotificationClients()`. Find `container.PhoneHandlerValidator()` and pass `container.NotificationEndpointPolicy()` as its fourth argument. Keep existing @@ -1538,14 +1427,12 @@ type incomingMessageRequest struct { ```go type notificationRecord struct { - NotificationID string `json:"notification_id"` - GatewayID string `json:"gateway_id"` - Data map[string]string `json:"data"` - MessageID string `json:"message_id,omitempty"` - Kind string `json:"kind"` - Attempts int `json:"attempts"` - Processed bool `json:"processed"` - Error string `json:"error,omitempty"` + 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"` } ``` @@ -1623,7 +1510,7 @@ type emulator struct { client *http.Client mu sync.RWMutex gateways map[string]gateway - records map[string]*notificationRecord + records []*notificationRecord } func newEmulator(apiBaseURL string, client *http.Client) *emulator { @@ -1631,15 +1518,12 @@ func newEmulator(apiBaseURL string, client *http.Client) *emulator { apiBaseURL: strings.TrimRight(apiBaseURL, "/"), client: client, gateways: make(map[string]gateway), - records: make(map[string]*notificationRecord), } } ``` -Add locked methods to register a gateway, load a gateway, begin a notification, +Add locked methods to register a gateway, load a gateway, record each callback, mark it processed/failed, and list copied records for one gateway. -`beginNotification` increments `Attempts` for duplicate IDs and returns -`firstDelivery=false` without processing the message again. - [ ] **Step 4: Implement existing phone API calls** @@ -1702,17 +1586,14 @@ type callbackEnvelope struct { The handler must: 1. load `gatewayID` from the route; -2. require `X-httpSMS-Notification-ID`; -3. return `404` for unknown gateways; -4. return `400` for missing IDs or unsupported data; -5. record every delivery attempt; -6. return `204` immediately for a duplicate notification ID already being - processed or completed; -7. for `KEY_MESSAGE_ID`, fetch outstanding, fire `SENT`, fire `DELIVERED`, and +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`; -8. for `KEY_HEARTBEAT_ID`, store a heartbeat and mark the record processed with +6. for `KEY_HEARTBEAT_ID`, store a heartbeat and mark the record processed with kind `heartbeat`; -9. return `500` and retain the error string in the record when processing +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 @@ -1853,13 +1734,11 @@ func TestAdapterGatewayOutgoingMessage(t *testing.T) { assert.Equal(t, "message", records[0].Kind) assert.True(t, records[0].Processed) assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"]) - assert.NotEmpty(t, records[0].NotificationID) } ``` -The record-list helper queries by message ID because the notification ID is -generated inside the API. Assert one processed record to verify the emulator -does not send the message twice. +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** @@ -1929,7 +1808,7 @@ func TestAdapterGatewayHeartbeatWakeUp(t *testing.T) { The direct internal event avoids waiting for the production 16-minute monitor interval while still exercising `PhoneNotificationListener`, -`PhoneNotificationService`, the HTTP dispatcher, the emulator callback, and the +`PhoneNotificationService`, the HTTP client, the emulator callback, and the existing heartbeat API. - [ ] **Step 12: Update local and CI commands** 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 index d29f8ddd..5d833dfb 100644 --- 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 @@ -22,7 +22,8 @@ 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 and must have an idempotency identity; +- 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. @@ -34,8 +35,9 @@ requirements: 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. -- Use `PhoneNotificationDispatcher` with separate Firebase and HTTP senders; - domain events continue to use the existing `EventDispatcher`. +- 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. @@ -113,45 +115,36 @@ 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. Shared Firebase message contract +### 2. Transport client map -Use Firebase's `messaging.Message` as the notification contract for both -transports instead of maintaining a duplicate DTO: +Use the existing `FCMClient` contract for every phone notification transport: ```go -type NotificationSender interface { - Send( - ctx context.Context, - message *messaging.Message, - notificationID uuid.UUID, - ) (string, error) +type FCMClient interface { + Send(context.Context, *messaging.Message) (string, error) } ``` -`PhoneNotificationService` constructs `messaging.Message` directly with the -data and Android configuration. The notification ID remains a separate -argument: it is the persisted `PhoneNotification.ID` for outgoing messages, -while heartbeats generate a request UUID for delivery identity. +`PhoneNotificationService` receives +`map[entities.NotificationTransport]FCMClient` and constructs +`messaging.Message` directly. A focused private helper: -Add a dispatcher that: - -1. uses the phone helper to determine the transport; -2. rejects a nil message with a stacktrace error; -3. trims the phone's configured token and assigns it to `message.Token`; -4. delegates the same message pointer and notification ID to the selected - sender; -5. returns a transport-neutral delivery result string or an error. +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 sender +### 3. Firebase client -Adapt the existing `FCMClient` behind the transport-neutral sender interface. -`FCMNotificationSender` validates that the message is non-nil and passes the -same `*messaging.Message` directly to `FCMClient.Send` without reconstructing -the payload. +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 @@ -201,15 +194,12 @@ shape and protobuf duration formatting, including a ten-minute TTL as `600s`. Every request includes: ```text -X-httpSMS-Notification-ID: Content-Type: application/json ``` -For an outgoing message, the header value is the persisted phone-notification -ID. Retries of the same HTTP delivery reuse that ID. If the normal message -expiration flow schedules another send attempt, it creates a new -`PhoneNotification` and therefore a new ID. The adapter can distinguish a -duplicate HTTP request from an intentional later send attempt. +`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: @@ -260,17 +250,17 @@ 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 and adapter idempotency +### 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 then delivers the same notification ID again. +The retry can therefore deliver the same wake-up payload again. Adapters must: -- deduplicate callback requests by `X-httpSMS-Notification-ID`; - treat callbacks as hints to fetch work, not as message content; -- avoid sending the external message twice for the same notification ID; +- 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. @@ -362,13 +352,10 @@ 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` to build generic - notifications and preserve existing state transitions; -- `api/pkg/services/fcm_client.go` to adapt Firebase to the neutral sender; -- `api/pkg/services/notification_sender.go` for the neutral notification, - sender interface, and Firebase sender; -- `api/pkg/services/phone_notification_dispatcher.go` for phone transport - routing; +- `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 @@ -409,7 +396,7 @@ Use an HTTP test server or controlled transport to cover: - FCM-compatible message and heartbeat JSON; - message priority and TTL mapping; -- stable `X-httpSMS-Notification-ID` across transport retries; +- 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; @@ -438,15 +425,14 @@ Add a dedicated Go service under `tests/adapter-emulator/`. It exposes: - 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 keyed by `X-httpSMS-Notification-ID`. +- callback records for integration assertions. For `KEY_MESSAGE_ID`, the emulator: -1. deduplicates the notification ID; -2. fetches `/v1/messages/outstanding` using the registered phone API key; -3. posts the existing `SENT` event; -4. posts the existing `DELIVERED` event; -5. records the fetched message and final adapter action for test assertions. +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 @@ -466,8 +452,7 @@ Add end-to-end tests for: 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, notification ID header, unique callback handling, and - phone API-key scoping. +- Callback payload keys, callback processing, and phone API-key scoping. Run: @@ -508,7 +493,7 @@ delivery. Initial rollout should watch: - callback latency; - transport failures; - message expiration after a successful HTTP wake-up; -- duplicate notification IDs observed by test adapters. +- 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. diff --git a/tests/adapter-emulator/emulator.go b/tests/adapter-emulator/emulator.go index badba9ed..13d0568d 100644 --- a/tests/adapter-emulator/emulator.go +++ b/tests/adapter-emulator/emulator.go @@ -2,7 +2,6 @@ package main import ( "net/http" - "sort" "strings" "sync" ) @@ -13,14 +12,12 @@ type gateway struct { } type notificationRecord struct { - NotificationID string `json:"notification_id"` - GatewayID string `json:"gateway_id"` - Data map[string]string `json:"data"` - MessageID string `json:"message_id,omitempty"` - Kind string `json:"kind"` - Attempts int `json:"attempts"` - Processed bool `json:"processed"` - Error string `json:"error,omitempty"` + 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 { @@ -28,7 +25,7 @@ type emulator struct { client *http.Client mu sync.RWMutex gateways map[string]gateway - records map[string]*notificationRecord + records []*notificationRecord } func newEmulator(apiBaseURL string, client *http.Client) *emulator { @@ -36,7 +33,6 @@ func newEmulator(apiBaseURL string, client *http.Client) *emulator { apiBaseURL: strings.TrimRight(apiBaseURL, "/"), client: client, gateways: make(map[string]gateway), - records: make(map[string]*notificationRecord), } } @@ -58,56 +54,40 @@ func (instance *emulator) loadGateway(gatewayID string) (gateway, bool) { return registeredGateway, ok } -func (instance *emulator) beginNotification( - notificationID string, +func (instance *emulator) recordNotification( gatewayID string, data map[string]string, kind string, messageID string, -) (*notificationRecord, bool) { +) *notificationRecord { instance.mu.Lock() defer instance.mu.Unlock() - if record, ok := instance.records[notificationID]; ok { - record.Attempts++ - if record.Processed || record.Error == "" { - return copyNotificationRecord(record), false - } - record.Error = "" - return copyNotificationRecord(record), true - } - record := ¬ificationRecord{ - NotificationID: notificationID, - GatewayID: gatewayID, - Data: copyStringMap(data), - MessageID: messageID, - Kind: kind, - Attempts: 1, + GatewayID: gatewayID, + Data: copyStringMap(data), + MessageID: messageID, + Kind: kind, } - instance.records[notificationID] = record + instance.records = append(instance.records, record) - return copyNotificationRecord(record), true + return record } -func (instance *emulator) markNotificationProcessed(notificationID string) { +func (instance *emulator) markNotificationProcessed(record *notificationRecord) { instance.mu.Lock() defer instance.mu.Unlock() - if record, ok := instance.records[notificationID]; ok { - record.Processed = true - record.Error = "" - } + record.Processed = true + record.Error = "" } -func (instance *emulator) markNotificationFailed(notificationID string, err error) { +func (instance *emulator) markNotificationFailed(record *notificationRecord, err error) { instance.mu.Lock() defer instance.mu.Unlock() - if record, ok := instance.records[notificationID]; ok { - record.Processed = false - record.Error = err.Error() - } + record.Processed = false + record.Error = err.Error() } func (instance *emulator) listGatewayRecords(gatewayID string) []notificationRecord { @@ -120,9 +100,6 @@ func (instance *emulator) listGatewayRecords(gatewayID string) []notificationRec records = append(records, *copyNotificationRecord(record)) } } - sort.Slice(records, func(left int, right int) bool { - return records[left].NotificationID < records[right].NotificationID - }) return records } diff --git a/tests/adapter-emulator/emulator_test.go b/tests/adapter-emulator/emulator_test.go index d1348edf..b8be3c2b 100644 --- a/tests/adapter-emulator/emulator_test.go +++ b/tests/adapter-emulator/emulator_test.go @@ -11,7 +11,7 @@ import ( "testing" ) -func TestBeginNotificationDeduplicatesAndCopiesRecords(t *testing.T) { +func TestRecordNotificationCopiesRecords(t *testing.T) { t.Parallel() instance := newEmulator("http://api.example", http.DefaultClient) @@ -20,52 +20,30 @@ func TestBeginNotificationDeduplicatesAndCopiesRecords(t *testing.T) { PhoneAPIKey: "phone-key", }) - record, firstDelivery := instance.beginNotification( - "notification-1", + record := instance.recordNotification( "gateway-1", map[string]string{"KEY_MESSAGE_ID": "message-1"}, "message", "message-1", ) - if !firstDelivery { - t.Fatal("first delivery was treated as a duplicate") - } - if record.Attempts != 1 { - t.Fatalf("first delivery attempts = %d, want 1", record.Attempts) - } - - instance.markNotificationProcessed("notification-1") - _, firstDelivery = instance.beginNotification( - "notification-1", - "gateway-1", - map[string]string{"KEY_MESSAGE_ID": "message-1"}, - "message", - "message-1", - ) - if firstDelivery { - t.Fatal("duplicate delivery was treated as the first delivery") - } + instance.markNotificationProcessed(record) records := instance.listGatewayRecords("gateway-1") if len(records) != 1 { t.Fatalf("record count = %d, want 1", len(records)) } - if records[0].Attempts != 2 { - t.Fatalf("duplicate attempts = %d, want 2", records[0].Attempts) - } if !records[0].Processed { t.Fatal("processed state was not retained") } records[0].Data["KEY_MESSAGE_ID"] = "mutated" - records[0].Attempts = 99 fresh := instance.listGatewayRecords("gateway-1") - if fresh[0].Data["KEY_MESSAGE_ID"] != "message-1" || fresh[0].Attempts != 2 { + if fresh[0].Data["KEY_MESSAGE_ID"] != "message-1" { t.Fatalf("record list returned mutable state: %#v", fresh[0]) } } -func TestNotificationHandlerProcessesMessageOnce(t *testing.T) { +func TestNotificationHandlerProcessesMessage(t *testing.T) { t.Parallel() var mu sync.Mutex @@ -115,18 +93,15 @@ func TestNotificationHandlerProcessesMessageOnce(t *testing.T) { }) body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) - for range 2 { - request := httptest.NewRequest( - http.MethodPost, - "/notifications/gateway-1", - bytes.NewReader(body), - ) - request.Header.Set("X-httpSMS-Notification-ID", "notification-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()) - } + 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() @@ -143,7 +118,7 @@ func TestNotificationHandlerProcessesMessageOnce(t *testing.T) { t.Fatalf("record count = %d, want 1", len(records)) } record := records[0] - if record.Kind != "message" || record.MessageID != "message-1" || !record.Processed || record.Attempts != 2 { + if record.Kind != "message" || record.MessageID != "message-1" || !record.Processed { t.Fatalf("unexpected message record: %#v", record) } } @@ -178,7 +153,6 @@ func TestNotificationHandlerStoresHeartbeat(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(callbackBody(t, map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"})), ) - request.Header.Set("X-httpSMS-Notification-ID", "notification-1") response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusNoContent { @@ -217,7 +191,6 @@ func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), ) - request.Header.Set("X-httpSMS-Notification-ID", "notification-1") response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { @@ -236,7 +209,7 @@ func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { } } -func TestNotificationHandlerRetriesFailedDeliveryWithSameID(t *testing.T) { +func TestNotificationHandlerProcessesRetryAfterFailure(t *testing.T) { t.Parallel() var mu sync.Mutex @@ -282,15 +255,12 @@ func TestNotificationHandlerRetriesFailedDeliveryWithSameID(t *testing.T) { body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) firstRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) - firstRequest.Header.Set("X-httpSMS-Notification-ID", "notification-1") 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)) - secondRequest.Header.Set("X-httpSMS-Notification-ID", "notification-1") secondResponse := httptest.NewRecorder() handler.ServeHTTP(secondResponse, secondRequest) if secondResponse.Code != http.StatusNoContent { @@ -307,11 +277,14 @@ func TestNotificationHandlerRetriesFailedDeliveryWithSameID(t *testing.T) { } records := instance.listGatewayRecords("gateway-1") - if len(records) != 1 { - t.Fatalf("record count = %d, want 1", len(records)) + 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[0].Attempts != 2 || !records[0].Processed || records[0].Error != "" { - t.Fatalf("unexpected retried record: %#v", records[0]) + if !records[1].Processed || records[1].Error != "" { + t.Fatalf("unexpected successful retry record: %#v", records[1]) } } @@ -396,15 +369,13 @@ func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) { PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", }) - instance.beginNotification( - "notification-1", + instance.recordNotification( "gateway-1", map[string]string{"KEY_MESSAGE_ID": "message-1"}, "message", "message-1", ) - instance.beginNotification( - "notification-2", + instance.recordNotification( "gateway-1", map[string]string{"KEY_MESSAGE_ID": "message-2"}, "message", diff --git a/tests/adapter-emulator/notification_handler.go b/tests/adapter-emulator/notification_handler.go index e8c74a9b..e4108eea 100644 --- a/tests/adapter-emulator/notification_handler.go +++ b/tests/adapter-emulator/notification_handler.go @@ -31,12 +31,6 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request return } - notificationID := strings.TrimSpace(request.Header.Get("X-httpSMS-Notification-ID")) - if notificationID == "" { - http.Error(writer, "missing X-httpSMS-Notification-ID", http.StatusBadRequest) - return - } - request.Body = http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes) var envelope callbackEnvelope if err := json.NewDecoder(request.Body).Decode(&envelope); err != nil { @@ -45,26 +39,19 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request } kind, messageID, validationErr := notificationKind(envelope.Message.Data) - _, firstDelivery := instance.beginNotification( - notificationID, + record := instance.recordNotification( gatewayID, envelope.Message.Data, kind, messageID, ) log.Printf( - "[ADAPTER] callback notification=%s gateway=%s data=%v should_process=%t", - notificationID, + "[ADAPTER] callback gateway=%s data=%v", gatewayID, envelope.Message.Data, - firstDelivery, ) - if !firstDelivery { - writer.WriteHeader(http.StatusNoContent) - return - } if validationErr != nil { - instance.markNotificationFailed(notificationID, validationErr) + instance.markNotificationFailed(record, validationErr) http.Error(writer, validationErr.Error(), http.StatusBadRequest) return } @@ -83,14 +70,14 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request processingErr = instance.storeHeartbeat(request.Context(), registeredGateway) } if processingErr != nil { - instance.markNotificationFailed(notificationID, processingErr) - log.Printf("[ADAPTER] notification %s failed: %v", notificationID, processingErr) + instance.markNotificationFailed(record, processingErr) + log.Printf("[ADAPTER] notification failed: %v", processingErr) http.Error(writer, "notification processing failed", http.StatusInternalServerError) return } - instance.markNotificationProcessed(notificationID) - log.Printf("[ADAPTER] notification %s processed as %s", notificationID, kind) + instance.markNotificationProcessed(record) + log.Printf("[ADAPTER] notification processed as %s", kind) writer.WriteHeader(http.StatusNoContent) } diff --git a/tests/adapter_integration_test.go b/tests/adapter_integration_test.go index 11907059..6dea41f1 100644 --- a/tests/adapter_integration_test.go +++ b/tests/adapter_integration_test.go @@ -37,7 +37,6 @@ func TestAdapterGatewayOutgoingMessage(t *testing.T) { assert.Equal(t, "message", records[0].Kind) assert.True(t, records[0].Processed) assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"]) - assert.NotEmpty(t, records[0].NotificationID) } func TestAdapterGatewayIncomingMessage(t *testing.T) { diff --git a/tests/helpers_test.go b/tests/helpers_test.go index 45be9ddd..a0c84c41 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -45,14 +45,12 @@ type adapterTestPhone struct { } type notificationRecord struct { - NotificationID string `json:"notification_id"` - GatewayID string `json:"gateway_id"` - Data map[string]string `json:"data"` - MessageID string `json:"message_id,omitempty"` - Kind string `json:"kind"` - Attempts int `json:"attempts"` - Processed bool `json:"processed"` - Error string `json:"error,omitempty"` + 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 { From 63b4934f0141f114be3b0713cc7b72a69c055e8d Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Fri, 4 Sep 2026 08:09:10 +0300 Subject: [PATCH 22/22] fix(api): restore phone request logs Undo request-log scrubbing so phone handler failures retain the original URL and complete request parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --- api/pkg/handlers/phone_handler.go | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index 3a3fcfaf..c81ef4dd 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -114,26 +114,18 @@ func (h *PhoneHandler) Upsert(c fiber.Ctx) error { var request requests.PhoneUpsert if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagatef(err, "cannot unmarshal phone update request into %T", request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateUpsert(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewErrorf( - "validation errors [%s], while updating phone request [%s]", - spew.Sdump(errors), - c.Body(), - )) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } phone, err := h.service.Upsert(ctx, request.ToUpsertParams(h.userFromContext(c), c.OriginalURL(), c.Body())) if err != nil { - ctxLogger.Error(stacktrace.Propagatef( - err, - "cannot update phone with request [%s]", - c.Body(), - )) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot update phones with params [%+#v]", request)) return h.responseInternalServerError(c) } @@ -200,26 +192,18 @@ func (h *PhoneHandler) UpsertFCMToken(c fiber.Ctx) error { var request requests.PhoneFCMToken if err := c.Bind().Body(&request); err != nil { - ctxLogger.Warn(stacktrace.Propagatef(err, "cannot unmarshal phone token update request into %T", request)) + ctxLogger.Warn(stacktrace.Propagatef(err, "cannot marshall params [%s] into %T", c.OriginalURL(), request)) return h.responseBadRequest(c, err) } if errors := h.validator.ValidateFCMToken(ctx, request.Sanitize()); len(errors) != 0 { - ctxLogger.Warn(stacktrace.NewErrorf( - "validation errors [%s], while updating phone token request [%s]", - spew.Sdump(errors), - c.Body(), - )) + ctxLogger.Warn(stacktrace.NewErrorf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)) return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") } phone, err := h.service.UpsertFCMToken(ctx, request.ToPhoneFCMTokenParams(h.userFromContext(c), c.OriginalURL())) if err != nil { - ctxLogger.Error(stacktrace.Propagatef( - err, - "cannot update phone token with request [%s]", - c.Body(), - )) + ctxLogger.Error(stacktrace.Propagatef(err, "cannot delete phones with params [%+#v]", request)) return h.responseInternalServerError(c) }