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