diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml new file mode 100644 index 00000000..aa7f20d7 --- /dev/null +++ b/.github/workflows/api.yml @@ -0,0 +1,99 @@ +name: api + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + Test: + runs-on: ubuntu-latest + steps: + - name: Checkout πŸ›Ž + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + + - name: Generate Firebase credentials + run: | + bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json + echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV + + - name: Start Services + working-directory: ./tests + run: docker compose up -d --build + + - name: Wait for services to be healthy + working-directory: ./tests + run: | + echo "Waiting for API to be healthy..." + for i in $(seq 1 40); do + if docker compose exec api curl -sf http://localhost:8000/health >/dev/null 2>&1; then + echo "API is healthy!" + break + fi + if [ $i -eq 40 ]; then + echo "API failed to become healthy" + docker compose logs api + exit 1 + fi + echo "Attempt $i/40 - waiting 5s..." + sleep 5 + done + + - name: Seed Database + working-directory: ./tests + run: | + echo "Waiting for seed container to finish..." + docker compose wait seed || true + sleep 2 + + - name: Run Integration Tests + working-directory: ./tests + run: go test -v -timeout 300s ./... + + - name: Collect Logs on Failure + if: failure() + working-directory: ./tests + run: | + docker compose logs --tail 200 + + - name: Stop Services + if: always() + working-directory: ./tests + run: docker compose down -v + + Deploy: + runs-on: ubuntu-latest + needs: Test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v3 + + - name: Trigger Cloud Build Deploy πŸš€ + run: | + BUILD_ID=$(gcloud builds triggers run api-httpsms-com \ + --region=global \ + --project=httpsms-86c51 \ + --sha=${{ github.sha }} \ + --format="value(metadata.build.id)") + echo "Build ID: $BUILD_ID" + echo "Streaming build logs..." + gcloud builds log "$BUILD_ID" --region=global --project=httpsms-86c51 --stream diff --git a/.github/workflows/ci.yml b/.github/workflows/web.yml similarity index 97% rename from .github/workflows/ci.yml rename to .github/workflows/web.yml index 8c1190b5..1d9133d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/web.yml @@ -1,4 +1,4 @@ -name: ci +name: web on: push: @@ -25,7 +25,7 @@ jobs: - uses: pnpm/action-setup@v6 name: Install pnpm with: - version: 9 + version: 10 - name: Install dependencies πŸ“¦ run: pnpm install diff --git a/.gitignore b/.gitignore index b114cdd0..edc89ca7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ android/app/debug/ *main.exe* android/app/release/ + +tests/firebase-credentials.json +tests/emulator/emulator.exe diff --git a/README.md b/README.md index 84b77a40..14690530 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # httpSMS -[![Build](https://github.com/NdoleStudio/httpsms/actions/workflows/ci.yml/badge.svg)](https://github.com/NdoleStudio/httpsms/actions/workflows/ci.yml) +[![Web](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml/badge.svg)](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml) +[![API](https://github.com/NdoleStudio/httpsms/actions/workflows/api.yml/badge.svg)](https://github.com/NdoleStudio/httpsms/actions/workflows/api.yml) [![GitHub contributors](https://img.shields.io/github/contributors/NdoleStudio/httpsms)](https://github.com/NdoleStudio/httpsms/graphs/contributors) [![GitHub license](https://img.shields.io/github/license/NdoleStudio/httpsms?color=brightgreen)](https://github.com/NdoleStudio/httpsms/blob/master/LICENSE) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) @@ -43,6 +44,7 @@ Quick Start Guide πŸ‘‰ [https://docs.httpsms.com](https://docs.httpsms.com) - [6. Build and Run](#6-build-and-run) - [7. Create the System User](#7-create-the-system-user) - [8. Build the Android App.](#8-build-the-android-app) +- [Integration Testing](#integration-testing) - [License](#license) @@ -255,6 +257,26 @@ docker compose up --build - Before building the Android app in [Android Studio](https://developer.android.com/studio), you need to replace the `google-services.json` file in the `android/app` directory with the file which you got from step 1. You need to do this for the firebase FCM messages to work properly. +## Integration Testing + +The project includes end-to-end integration tests that validate the complete SMS send/receive lifecycle. Tests run the full stack (API, PostgreSQL, Redis) in Docker alongside a phone emulator that simulates an Android device. + +πŸ“– **Full documentation:** [`tests/README.md`](tests/README.md) + +**Quick run:** + +```bash +cd tests +bash generate-firebase-credentials.sh +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 ./... +docker compose down -v +``` + +Integration tests also run automatically in CI on every push/PR to `main`. + ## License This project is licensed under the GNU AFFERO GENERAL PUBLIC LICENSE Version 3 - see the [LICENSE](LICENSE) file for details diff --git a/api/Dockerfile b/api/Dockerfile index 8e0206a2..6e6423b1 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -21,7 +21,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=$GI FROM alpine:latest -RUN addgroup -S http-sms && adduser -S http-sms -G http-sms +RUN apk add --no-cache curl && addgroup -S http-sms && adduser -S http-sms -G http-sms USER http-sms WORKDIR /home/http-sms diff --git a/api/cmd/fcm/main.go b/api/cmd/fcm/main.go index 4906142b..470397cf 100644 --- a/api/cmd/fcm/main.go +++ b/api/cmd/fcm/main.go @@ -18,7 +18,7 @@ func main() { } container := di.NewContainer(os.Getenv("GCP_PROJECT_ID"), "") - client := container.FirebaseMessagingClient() + client := container.FCMClient() result, err := client.Send(context.Background(), &messaging.Message{ Data: map[string]string{ diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index b27cc37c..ae5e15ec 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -47,7 +47,6 @@ import ( "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.10.0" - "firebase.google.com/go/messaging" "github.com/hirosassa/zerodriver" "github.com/rs/zerolog" "go.opentelemetry.io/otel/sdk/trace" @@ -176,6 +175,11 @@ func (container *Container) App() (app *fiber.App) { app = fiber.New() + // Health check endpoint registered before middleware for reliable Docker health checks + app.Get("/health", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + if os.Getenv("USE_HTTP_LOGGER") == "true" { app.Use(fiberLogger.New()) } @@ -397,7 +401,8 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK ( // FirebaseApp creates a new instance of firebase.App func (container *Container) FirebaseApp() (app *firebase.App) { container.logger.Debug(fmt.Sprintf("creating %T", app)) - app, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials())) + + app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials())) if err != nil { msg := "cannot initialize firebase application" container.logger.Fatal(stacktrace.Propagate(err, msg)) @@ -419,8 +424,10 @@ func (container *Container) Cache() cache.Cache { if err != nil { container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot parse redis url [%s]", os.Getenv("REDIS_URL")))) } - opt.TLSConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, + if strings.HasPrefix(os.Getenv("REDIS_URL"), "rediss://") { + opt.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } } redisClient := redis.NewClient(opt) @@ -506,15 +513,27 @@ func (container *Container) CloudTaskEventsQueue() (queue services.PushQueue) { ) } -// FirebaseMessagingClient creates a new instance of messaging.Client -func (container *Container) FirebaseMessagingClient() (client *messaging.Client) { - container.logger.Debug(fmt.Sprintf("creating %T", client)) +// FCMClient creates the appropriate FCM client based on configuration. +// When FCM_ENDPOINT is set, it returns an EmulatorFCMClient that sends +// notifications directly to the phone emulator via HTTP. +// Otherwise, it returns a FirebaseFCMClient that uses the real Firebase SDK. +func (container *Container) FCMClient() services.FCMClient { + if fcmEndpoint := os.Getenv("FCM_ENDPOINT"); fcmEndpoint != "" { + container.logger.Info(fmt.Sprintf("using emulator FCM client with endpoint: %s", fcmEndpoint)) + return services.NewEmulatorFCMClient( + container.HTTPClient("emulator_fcm"), + fcmEndpoint, + container.Logger(), + ) + } + + container.logger.Debug("creating FirebaseFCMClient") messagingClient, err := container.FirebaseApp().Messaging(context.Background()) if err != nil { msg := "cannot initialize firebase messaging client" container.logger.Fatal(stacktrace.Propagate(err, msg)) } - return messagingClient + return services.NewFirebaseFCMClient(messagingClient) } // FirebaseCredentials returns firebase credentials as bytes. @@ -1588,7 +1607,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi return services.NewNotificationService( container.Logger(), container.Tracer(), - container.FirebaseMessagingClient(), + container.FCMClient(), container.PhoneRepository(), container.PhoneNotificationRepository(), container.MessageSendScheduleRepository(), diff --git a/api/pkg/services/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go new file mode 100644 index 00000000..85060bb4 --- /dev/null +++ b/api/pkg/services/emulator_fcm_client.go @@ -0,0 +1,100 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "firebase.google.com/go/messaging" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/palantir/stacktrace" +) + +// EmulatorFCMClient sends FCM messages to the phone emulator via HTTP. +type EmulatorFCMClient struct { + httpClient *http.Client + endpoint string + logger telemetry.Logger +} + +// NewEmulatorFCMClient creates a new EmulatorFCMClient. +func NewEmulatorFCMClient(httpClient *http.Client, endpoint string, logger telemetry.Logger) *EmulatorFCMClient { + return &EmulatorFCMClient{ + httpClient: httpClient, + endpoint: endpoint, + logger: logger, + } +} + +// emulatorFCMRequest is the payload sent to the emulator's FCM endpoint. +type emulatorFCMRequest struct { + Message *emulatorFCMMessage `json:"message"` +} + +type emulatorFCMMessage struct { + Token string `json:"token"` + Data map[string]string `json:"data,omitempty"` + Android *emulatorAndroid `json:"android,omitempty"` +} + +type emulatorAndroid struct { + Priority string `json:"priority,omitempty"` +} + +// emulatorFCMResponse is the response from the emulator. +type emulatorFCMResponse struct { + Name string `json:"name"` +} + +// Send sends a message to the emulator's FCM endpoint. +func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { + payload := &emulatorFCMRequest{ + Message: &emulatorFCMMessage{ + Token: message.Token, + Data: message.Data, + }, + } + if message.Android != nil { + payload.Message.Android = &emulatorAndroid{ + Priority: message.Android.Priority, + } + } + + body, err := json.Marshal(payload) + if err != nil { + return "", stacktrace.Propagate(err, "cannot marshal FCM request for emulator") + } + + url := fmt.Sprintf("%s/v1/projects/httpsms-test/messages:send", c.endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", stacktrace.Propagate(err, "cannot create HTTP request for emulator FCM") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", stacktrace.Propagate(err, fmt.Sprintf("cannot send FCM to emulator at [%s]", url)) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", stacktrace.Propagate(err, "cannot read emulator FCM response body") + } + + if resp.StatusCode != http.StatusOK { + return "", stacktrace.NewError("emulator FCM returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var result emulatorFCMResponse + if err = json.Unmarshal(respBody, &result); err != nil { + return "", stacktrace.Propagate(err, "cannot decode emulator FCM response") + } + + c.logger.Info(fmt.Sprintf("emulator FCM sent successfully: %s", result.Name)) + return result.Name, nil +} diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go new file mode 100644 index 00000000..4e56f316 --- /dev/null +++ b/api/pkg/services/fcm_client.go @@ -0,0 +1,28 @@ +package services + +import ( + "context" + + "firebase.google.com/go/messaging" +) + +// FCMClient is the interface for sending Firebase Cloud Messaging notifications. +type FCMClient interface { + // Send sends a message via FCM and returns the message name on success. + Send(ctx context.Context, message *messaging.Message) (string, error) +} + +// FirebaseFCMClient wraps the real Firebase messaging.Client. +type FirebaseFCMClient struct { + client *messaging.Client +} + +// NewFirebaseFCMClient creates a new FirebaseFCMClient. +func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient { + return &FirebaseFCMClient{client: client} +} + +// Send sends a message via the real Firebase SDK. +func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { + return c.client.Send(ctx, message) +} diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 4b0bf893..79b43037 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -26,7 +26,7 @@ type PhoneNotificationService struct { phoneNotificationRepository repositories.PhoneNotificationRepository phoneRepository repositories.PhoneRepository messageSendScheduleRepository repositories.MessageSendScheduleRepository - messagingClient *messaging.Client + messagingClient FCMClient eventDispatcher *EventDispatcher } @@ -34,7 +34,7 @@ type PhoneNotificationService struct { func NewNotificationService( logger telemetry.Logger, tracer telemetry.Tracer, - messagingClient *messaging.Client, + messagingClient FCMClient, phoneRepository repositories.PhoneRepository, phoneNotificationRepository repositories.PhoneNotificationRepository, messageSendScheduleRepository repositories.MessageSendScheduleRepository, diff --git a/docs/superpowers/specs/2026-05-05-integration-tests-wiremock-design.md b/docs/superpowers/specs/2026-05-05-integration-tests-wiremock-design.md new file mode 100644 index 00000000..f2383bff --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-integration-tests-wiremock-design.md @@ -0,0 +1,304 @@ +# Integration Tests: WireMock + httpsms-go Client Refactor + +## Problem + +The current integration tests use raw `net/http` calls and a custom emulator (120+ lines of Go) to simulate phone behavior. This makes tests harder to maintain and doesn't cover encryption, rate limiting, or webhook verification. We need to: + +1. Refactor tests to use the official `httpsms-go` client SDK +2. Replace the custom emulator with WireMock (stub server + request journal) +3. Add E2E encryption tests (outgoing + incoming) +4. Add rate-limit verification test +5. Assert webhook delivery with JWT authentication in all tests + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Docker Compose (tests/docker-compose.yml) β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚PostgreSQLβ”‚ β”‚ Redis β”‚ β”‚ API β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚FCM push β”‚ +β”‚ β”‚Webhook calls β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ WireMock 3.x (:8080) β”‚ β”‚ +β”‚ β”‚ - Fake FCM endpoint β”‚ β”‚ +β”‚ β”‚ - Fake OAuth token β”‚ β”‚ +β”‚ β”‚ - Webhook receiver β”‚ β”‚ +β”‚ β”‚ - Request journal β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² + β”‚ httpsms-go client + go-wiremock client +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Test Runner (Go) β”‚ +β”‚ go test ./... β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Key Design Decisions + +- **WireMock replaces the custom emulator entirely**. It serves as both the fake FCM endpoint (receives push notifications from the API) and the webhook receiver (captures webhook events). +- **Tests fire SENT/DELIVERED events directly** to the API via HTTP. No WireMock callbacks needed β€” the test controls the flow deterministically. +- **Each test creates its own phone** with a random phone number for parallel test isolation. +- **go-wiremock** (`github.com/wiremock/go-wiremock`) is used to configure stubs and query the request journal from test code. + +## Test Flow (per test) + +``` +1. SETUP + β”œβ”€ Create phone (random number, test-specific messages_per_minute) + β”œβ”€ Create phone API key for that phone + β”œβ”€ Create webhook pointing to WireMock with a signing key + └─ Configure WireMock stubs (if not pre-loaded) + +2. ACT + β”œβ”€ Send/receive message via httpsms-go client + └─ (For send tests) Query WireMock journal β†’ extract KEY_MESSAGE_ID from FCM push + +3. SIMULATE PHONE + β”œβ”€ Fire SENT event to API (POST /v1/messages/{id}/events) + └─ Fire DELIVERED event to API + +4. ASSERT + β”œβ”€ Verify message reached expected status via httpsms-go client + β”œβ”€ Query WireMock journal for webhook events + β”œβ”€ Validate JWT token: signature (HMAC-SHA256), issuer, subject, audience, expiry + └─ Validate webhook payload contains correct event type and message data +``` + +## Components + +### 1. Docker Compose Changes + +**Remove:** + +- `tests/emulator/` directory entirely (Dockerfile, Go source, go.mod) + +**Replace with WireMock:** + +```yaml +wiremock: + image: wiremock/wiremock:3x + ports: + - "8080:8080" + volumes: + - ./wiremock/mappings:/home/wiremock/mappings:ro + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"] + interval: 5s + timeout: 5s + retries: 10 +``` + +**Pre-loaded WireMock mappings** (`tests/wiremock/mappings/`): + +- `fcm-send.json` β€” Stub for `POST /v1/projects/*/messages:send` β†’ returns `{"name": "projects/httpsms-test/messages/fake-id"}` +- `oauth-token.json` β€” Stub for `POST /token` β†’ returns `{"access_token": "fake-access-token", "token_type": "Bearer", "expires_in": 3600}` +- `webhook-receiver.json` β€” Stub for `POST /webhooks/test` β†’ returns 200 (catches all webhook calls) + +### 2. API Configuration Updates + +**`.env.test` changes:** + +- `FCM_ENDPOINT=http://wiremock:8080` (was `http://emulator:9090`) + +**Firebase credentials** `token_uri` points to `http://wiremock:8080/token` + +### 3. Seed SQL (simplified) + +Only seeds: + +- Test user (`test-user-id`, `api_key='test-user-api-key'`) +- System user (`system-user-id`, for event queue auth) + +Phones, phone API keys, and webhooks are created per-test via the API. + +### 4. httpsms-go Client Additions + +New services to add to `github.com/NdoleStudio/httpsms-go`: + +#### `PhoneService` + +```go +type PhoneUpsertParams struct { + PhoneNumber string `json:"phone_number"` + FcmToken string `json:"fcm_token"` + MessagesPerMinute uint `json:"messages_per_minute"` + MaxSendAttempts uint `json:"max_send_attempts"` + MessageExpirationSeconds uint `json:"message_expiration_seconds"` + SIM string `json:"sim"` +} + +func (service *PhoneService) Upsert(ctx, params) β†’ (*PhoneResponse, *Response, error) +// PUT /v1/phones β€” authenticated with user API key +``` + +#### `PhoneService` (FCM Token binding) + +```go +type PhoneFCMTokenParams struct { + PhoneNumber string `json:"phone_number"` + FcmToken string `json:"fcm_token"` + SIM string `json:"sim"` +} + +func (service *PhoneService) UpsertFCMToken(ctx, params) β†’ (*PhoneResponse, *Response, error) +// PUT /v1/phones/fcm-token β€” authenticated with phone API key +// This binds the phone to the phone API key via the auth context +``` + +#### `PhoneAPIKeyService` + +```go +type PhoneAPIKeyStoreParams struct { + Name string `json:"name"` +} + +func (service *PhoneAPIKeyService) Store(ctx, params) β†’ (*PhoneAPIKeyResponse, *Response, error) +// POST /v1/phone-api-keys/ β€” authenticated with user API key +// Returns the created phone API key including its api_key value +``` + +#### `WebhookService` + +```go +type WebhookStoreParams struct { + SigningKey string `json:"signing_key"` + URL string `json:"url"` + PhoneNumbers []string `json:"phone_numbers"` + Events []string `json:"events"` +} + +func (service *WebhookService) Store(ctx, params) β†’ (*WebhookResponse, *Response, error) +// POST /v1/webhooks β€” authenticated with user API key +``` + +#### Phone Setup Flow (per test) + +The real Android phone registers via this flow, and tests must replicate it: + +1. `PUT /v1/phones` (user API key) β€” creates phone with phone_number + fcm_token + messages_per_minute +2. `POST /v1/phone-api-keys/` (user API key) β€” creates a phone API key, returns the `api_key` value +3. `PUT /v1/phones/fcm-token` (phone API key) β€” re-registers FCM token, which binds the phone to the API key via `PhoneAPIKeyListener.onPhoneUpdated` + +After step 3, the phone API key is authorized to act on behalf of that phone (fire events, receive messages, etc.). + +### 5. Test Cases + +#### `TestSendSMS_Encrypted` + +1. Generate random encryption key +2. Create phone + phone API key + webhook +3. Encrypt plaintext using `client.Cipher.Encrypt(key, "secret message")` +4. Send message with `Encrypted: true` and encrypted content +5. Query WireMock journal β†’ verify FCM push arrived with `KEY_MESSAGE_ID` (FCM only carries the message ID, not content) +6. Call `GET /v1/messages/outstanding?message_id={id}` (phone API key) β€” verify response has `encrypted: true` and content is ciphertext (not plaintext) +7. Fire SENT + DELIVERED events +8. Fetch message via user API key β†’ verify `encrypted: true`, content is ciphertext +9. Decrypt with `client.Cipher.Decrypt(key, content)` β†’ assert equals original plaintext +10. Verify webhook event in WireMock with valid JWT + +#### `TestReceiveSMS_Encrypted` + +1. Generate random encryption key +2. Create phone + phone API key + webhook +3. Encrypt plaintext using `client.Cipher.Encrypt(key, "incoming secret")` +4. Simulate receiving an encrypted SMS (POST /v1/messages/receive with phone API key) +5. Fetch message via user API key β†’ verify `encrypted: true` +6. Decrypt content β†’ assert equals original plaintext +7. Verify webhook event (`message.phone.received`) in WireMock with valid JWT + +#### `TestSendSMS_RateLimit` + +1. Create phone with `messages_per_minute: 10` (= 6s gap) +2. Create phone API key + webhook +3. Send 2 messages simultaneously +4. Query WireMock journal for FCM pushes (correlate by message IDs from send responses) +5. Assert the timestamps of the two FCM pushes have β‰₯6 second gap +6. Fire SENT + DELIVERED for both messages +7. Verify both messages reach `delivered` status +8. Verify webhook events for both messages + +#### `TestSendSMS_OutstandingFlow` + +Validates the real phone flow (`/v1/messages/outstanding`): + +1. Create phone + phone API key + webhook +2. Send message via httpsms-go client +3. Query WireMock journal β†’ extract `KEY_MESSAGE_ID` from FCM push +4. Call `GET /v1/messages/outstanding?message_id={id}` (phone API key) β€” assert returns the message with correct content, owner, contact +5. Fire SENT + DELIVERED events +6. Verify message reaches `delivered` status +7. Verify webhook events + +#### Webhook Verification (shared helper) + +For all tests, a helper function: + +```go +func assertWebhookEvent(t *testing.T, wiremockClient *wiremock.Client, signingKey string, expectedEventType string) { + // 1. Query WireMock journal for POST /webhooks/test requests + // 2. Find request with X-Event-Type header matching expectedEventType + // 3. Extract Authorization header β†’ parse JWT + // 4. Validate signature with signingKey (HMAC-SHA256) + // 5. Assert claims: + // - Issuer == "api.httpsms.com" + // - Subject == "test-user-id" + // - Audience contains webhook URL + // - ExpiresAt is in the future + // - NotBefore is in the past +} +``` + +### 6. Test Helper Structure + +``` +tests/ +β”œβ”€β”€ docker-compose.yml (updated: wiremock replaces emulator) +β”œβ”€β”€ wiremock/ +β”‚ └── mappings/ +β”‚ β”œβ”€β”€ fcm-send.json +β”‚ β”œβ”€β”€ oauth-token.json +β”‚ └── webhook-receiver.json +β”œβ”€β”€ seed.sql (simplified: user + system user only) +β”œβ”€β”€ .env.test (updated: FCM_ENDPOINT β†’ wiremock) +β”œβ”€β”€ go.mod (add httpsms-go, go-wiremock, golang-jwt) +β”œβ”€β”€ helpers_test.go (shared constants, setup helpers) +β”œβ”€β”€ webhook_helpers_test.go (JWT verification helpers) +β”œβ”€β”€ integration_test.go (all test cases) +└── README.md +``` + +### 7. Dependencies + +**Test module (`tests/go.mod`):** + +- `github.com/NdoleStudio/httpsms-go` β€” API client +- `github.com/wiremock/go-wiremock` β€” WireMock stub configuration + journal queries +- `github.com/golang-jwt/jwt/v5` β€” JWT parsing and validation +- `github.com/stretchr/testify` β€” assertions (already present) + +### 8. Parallel Test Execution & Request Correlation + +Each test creates its own phone with a unique random number (e.g. `+1800555XXXX` where XXXX is random). This ensures: + +- No message cross-contamination between tests +- Webhooks scoped to specific phone numbers don't fire for other tests + +**Correlation strategy for WireMock journal queries:** + +- **FCM pushes**: Correlate by message ID. The test gets the message ID from the send response, then searches WireMock journal for FCM push requests containing that `KEY_MESSAGE_ID` in the JSON body. +- **Webhook events**: Each test uses a **unique webhook URL path** (e.g. `/webhooks/{testUUID}`). This ensures journal queries for webhook assertions only match events for that specific test. Additionally, match on `X-Event-Type` header and message ID in payload body. +- **Unique FCM token per phone**: Each test generates a unique `fcm_token` string. Since WireMock captures the FCM push including the `token` field, this can be used as a secondary correlation key if needed. + +Tests use `t.Parallel()` where safe (encryption tests can run in parallel; rate-limit test may need serial execution due to timing assertions). + +## Migration Notes + +- The `tests/emulator/` directory is deleted entirely +- The CI workflow (`.github/workflows/integration-test.yml`) needs updating to remove emulator references +- Firebase credentials `token_uri` must point to `http://wiremock:8080/token` +- WireMock image is Java-based (~300MB) vs the old Alpine emulator (~15MB), but eliminates maintenance of custom code diff --git a/tests/.env.test b/tests/.env.test new file mode 100644 index 00000000..a95703f0 --- /dev/null +++ b/tests/.env.test @@ -0,0 +1,30 @@ +ENV=production +GCP_PROJECT_ID=httpsms-test +USE_HTTP_LOGGER=true +ENTITLEMENT_ENABLED=false +EVENTS_QUEUE_TYPE=emulator +EVENTS_QUEUE_NAME=events-local +EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events +EVENTS_QUEUE_USER_API_KEY=system-user-api-key +EVENTS_QUEUE_USER_ID=system-user-id +FCM_ENDPOINT=http://wiremock:8080 +DATABASE_URL=postgresql://dbusername:dbpassword@postgres:5432/httpsms +DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms +REDIS_URL=redis://@redis:6379 +APP_PORT=8000 +APP_NAME=httpSMS +APP_URL=http://localhost:8000 +SWAGGER_HOST=localhost:8000 +SMTP_FROM_NAME=httpSMS +SMTP_FROM_EMAIL=test@httpsms.com +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_HOST=localhost +SMTP_PORT=2525 +PUSHER_APP_ID= +PUSHER_KEY= +PUSHER_SECRET= +PUSHER_CLUSTER= +GCS_BUCKET_NAME= +UPTRACE_DSN= +CLOUDFLARE_TURNSTILE_SECRET_KEY= diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..c914982d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,214 @@ +# 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. + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” HTTP β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Test Runner │─────────────▢│ API (Go) β”‚ +β”‚ (Go test) β”‚ β”‚ Port 8000 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + FCM Push β”‚ Events + (HTTP) β”‚ (HTTP) + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Emulator β”‚ + β”‚ (Fiber v3) β”‚ + β”‚ Port 9090 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” + β”‚ PostgreSQL β”‚ β”‚ Redis β”‚ + β”‚ Port 5435 β”‚ β”‚ Port 6379 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Components + +| Component | Description | +| --------------- | ------------------------------------------------------- | +| **API** | The httpSMS Go API server running in Docker | +| **Emulator** | A Fiber v3 Go service that simulates an Android phone | +| **PostgreSQL** | Database for the API | +| **Redis** | Cache and queue backend | +| **Seed** | One-shot container that seeds test data into PostgreSQL | +| **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 + +## 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 + +## 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) + +## Running Locally + +### 1. Generate Firebase Credentials + +The integration tests use a fake Firebase service account. Generate it with: + +```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 +export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) +``` + +### 3. Start the Stack + +```bash +docker compose up -d --build --wait +``` + +This starts PostgreSQL, Redis, the API, and the emulator. The `--wait` flag blocks until all health checks pass. + +### 4. Wait for Seeding + +```bash +docker compose wait seed +sleep 2 +``` + +The seed container inserts test users, phones, and API keys into PostgreSQL after the API has run its GORM migrations. + +### 5. Run Tests + +```bash +go test -v -timeout 120s ./... +``` + +### 6. Tear Down + +```bash +docker compose down -v +``` + +The `-v` flag removes volumes (database data) for a clean slate next run. + +### One-Liner + +```bash +cd tests && \ + bash generate-firebase-credentials.sh && \ + 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 ./... ; \ + 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 + +## 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` | + +See [`seed.sql`](./seed.sql) for the complete seed data. + +## Project Structure + +``` +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) +``` + +## Troubleshooting + +### API fails to start + +Check the API logs: + +```bash +docker compose logs api +``` + +Common issues: + +- `FIREBASE_CREDENTIALS` env var not set or malformed +- PostgreSQL not ready (increase `start_period` in healthcheck) + +### Tests timeout waiting for `delivered` status + +Check the emulator logs: + +```bash +docker compose logs emulator +``` + +The emulator should show: + +1. `[FCM]` β€” Receiving the push notification +2. `[EVENTS]` β€” Fetching outstanding messages and firing events + +If no `[FCM]` entries appear, the API isn't reaching the emulator (check `FCM_ENDPOINT` in `.env.test`). + +### Seed container fails + +```bash +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 diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 00000000..a4d6b9dc --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,88 @@ +services: + postgres: + image: postgres:alpine + environment: + POSTGRES_DB: httpsms + POSTGRES_PASSWORD: dbpassword + POSTGRES_USER: dbusername + ports: + - "5435:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dbusername -d httpsms"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + + redis: + image: redis:latest + command: redis-server + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + + wiremock: + image: wiremock/wiremock:3x + ports: + - "8080:8080" + volumes: + - ./wiremock/mappings:/home/wiremock/mappings:ro + networks: + default: + aliases: + - wiremock.local + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: ../api + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + wiremock: + condition: service_healthy + env_file: + - .env.test + environment: + FIREBASE_CREDENTIALS: "${FIREBASE_CREDENTIALS}" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 5s + timeout: 10s + retries: 20 + start_period: 30s + + seed: + image: postgres:alpine + depends_on: + api: + condition: service_healthy + environment: + PGPASSWORD: dbpassword + volumes: + - ./seed.sql:/seed.sql:ro + entrypoint: + [ + "psql", + "-h", + "postgres", + "-U", + "dbusername", + "-d", + "httpsms", + "-f", + "/seed.sql", + ] + restart: "no" diff --git a/tests/generate-firebase-credentials.sh b/tests/generate-firebase-credentials.sh new file mode 100644 index 00000000..70f47cd8 --- /dev/null +++ b/tests/generate-firebase-credentials.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Generates a fake Firebase service account JSON for integration tests. +# The RSA key is throwaway β€” it only needs to be valid so the Firebase SDK can sign JWTs. +# WireMock does not validate these tokens. + +set -e + +OUTFILE="${1:-firebase-credentials.json}" + +# Generate a 2048-bit RSA key +PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null) + +# Escape newlines for JSON embedding +PRIVATE_KEY_ESCAPED=$(echo "$PRIVATE_KEY" | awk '{printf "%s\\n", $0}') + +cat > "$OUTFILE" <= expectedCount { + return requests + } + time.Sleep(500 * time.Millisecond) + } + + requests := findWebhookRequests(t, webhookPath) + require.GreaterOrEqual(t, len(requests), expectedCount, "expected at least %d webhook events on %s, got %d", expectedCount, webhookPath, len(requests)) + return requests +} + +func waitForFCMPush(t *testing.T, messageID string, timeout time.Duration) []wmJournal.GetRequestResponse { + t.Helper() + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + requests := findFCMRequests(t, messageID) + if len(requests) >= 1 { + return requests + } + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("FCM push for message %s not found within %v", messageID, timeout) + return nil +} diff --git a/tests/integration_test.go b/tests/integration_test.go new file mode 100644 index 00000000..dc8c933c --- /dev/null +++ b/tests/integration_test.go @@ -0,0 +1,263 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSendSMS_Encrypted(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + + encryptionKey := randomEncryptionKey() + signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ + "message.phone.sent", + "message.phone.delivered", + }) + + client := newAPIClient() + plaintext := "Hello encrypted world " + randomEncryptionKey() + ciphertext, err := client.Cipher.Encrypt(encryptionKey, plaintext) + require.NoError(t, err) + require.NotEqual(t, plaintext, ciphertext) + + contactNumber := randomPhoneNumber() + sendResp, resp, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contactNumber, + Content: ciphertext, + Encrypted: true, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode) + + messageID := sendResp.Data.ID.String() + require.NotEmpty(t, messageID) + t.Logf("sent encrypted message: %s", messageID) + + fcmRequests := waitForFCMPush(t, messageID, 30*time.Second) + require.Len(t, fcmRequests, 1) + + outstanding := fetchOutstandingMessage(ctx, t, phone.PhoneAPIKey, messageID) + assert.Equal(t, true, outstanding["encrypted"]) + assert.Equal(t, ciphertext, outstanding["content"]) + assert.NotEqual(t, plaintext, outstanding["content"]) + + fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") + time.Sleep(200 * time.Millisecond) + fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED") + + msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) + assert.Equal(t, "delivered", msg.Status) + assert.True(t, msg.Encrypted) + assert.Equal(t, ciphertext, msg.Content) + + decrypted, err := client.Cipher.Decrypt(encryptionKey, msg.Content) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + + webhookReqs := waitForWebhookEvents(t, webhookPath, 2, 30*time.Second) + for _, req := range webhookReqs { + assertWebhookJWT(t, req.Request, signingKey) + } + + var eventTypes []string + for _, req := range webhookReqs { + if et, ok := req.Request.Headers["X-Event-Type"]; ok { + eventTypes = append(eventTypes, et) + } else if et, ok := req.Request.Headers["x-event-type"]; ok { + eventTypes = append(eventTypes, et) + } + } + assert.Contains(t, eventTypes, "message.phone.sent") + assert.Contains(t, eventTypes, "message.phone.delivered") +} + +func TestReceiveSMS_Encrypted(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + + encryptionKey := randomEncryptionKey() + signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ + "message.phone.received", + }) + + client := newAPIClient() + plaintext := "Incoming secret message " + randomEncryptionKey() + ciphertext, err := client.Cipher.Encrypt(encryptionKey, plaintext) + require.NoError(t, err) + + contactNumber := randomPhoneNumber() + receivePayload := map[string]interface{}{ + "from": contactNumber, + "to": phone.PhoneNumber, + "content": ciphertext, + "encrypted": true, + "sim": "SIM1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + body, err := json.Marshal(receivePayload) + require.NoError(t, err) + + url := apiBaseURL + "/v1/messages/receive" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", phone.PhoneAPIKey) + + httpResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer httpResp.Body.Close() + + respBody, err := io.ReadAll(httpResp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, httpResp.StatusCode, "receive response: %s", string(respBody)) + + var receiveResult httpsms.MessageResponse + require.NoError(t, json.Unmarshal(respBody, &receiveResult)) + messageID := receiveResult.Data.ID.String() + require.NotEmpty(t, messageID) + t.Logf("received encrypted message: %s", messageID) + + msg := pollMessageStatus(ctx, t, messageID, "received", 15*time.Second) + assert.Equal(t, "received", msg.Status) + assert.True(t, msg.Encrypted) + assert.Equal(t, ciphertext, msg.Content) + assert.NotEqual(t, plaintext, msg.Content) + + decrypted, err := client.Cipher.Decrypt(encryptionKey, msg.Content) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + + webhookReqs := waitForWebhookEvents(t, webhookPath, 1, 30*time.Second) + require.GreaterOrEqual(t, len(webhookReqs), 1) + assertWebhookJWT(t, webhookReqs[0].Request, signingKey) + + eventType := webhookReqs[0].Request.Headers["X-Event-Type"] + if eventType == "" { + eventType = webhookReqs[0].Request.Headers["x-event-type"] + } + assert.Equal(t, "message.phone.received", eventType) +} + +func TestSendSMS_RateLimit(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 10) + + signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ + "message.phone.sent", + "message.phone.delivered", + }) + + client := newAPIClient() + contactNumber := randomPhoneNumber() + + sendResp1, resp1, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contactNumber, + Content: "Rate limit test message 1", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp1.HTTPResponse.StatusCode) + msgID1 := sendResp1.Data.ID.String() + + sendResp2, resp2, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contactNumber, + Content: "Rate limit test message 2", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp2.HTTPResponse.StatusCode) + msgID2 := sendResp2.Data.ID.String() + + t.Logf("sent messages: %s, %s", msgID1, msgID2) + + fcm1 := waitForFCMPush(t, msgID1, 30*time.Second) + require.Len(t, fcm1, 1) + + fcm2 := waitForFCMPush(t, msgID2, 30*time.Second) + require.Len(t, fcm2, 1) + + time1 := fcm1[0].Request.LoggedDate + time2 := fcm2[0].Request.LoggedDate + gapMs := time2 - time1 + if gapMs < 0 { + gapMs = time1 - time2 + } + t.Logf("FCM push gap: %dms", gapMs) + assert.GreaterOrEqual(t, gapMs, int64(5500), "rate limit gap should be >= 5500ms (6s minus timing tolerance), got %dms", gapMs) + + fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT") + fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED") + fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "SENT") + fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "DELIVERED") + + msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second) + msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 15*time.Second) + assert.Equal(t, "delivered", msg1.Status) + assert.Equal(t, "delivered", msg2.Status) + + webhookReqs := waitForWebhookEvents(t, webhookPath, 4, 30*time.Second) + for _, req := range webhookReqs { + assertWebhookJWT(t, req.Request, signingKey) + } +} + +func TestSendSMS_OutstandingFlow(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + + signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ + "message.phone.sent", + "message.phone.delivered", + }) + + client := newAPIClient() + contactNumber := randomPhoneNumber() + content := "Outstanding flow test " + randomEncryptionKey() + + sendResp, resp, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ + From: phone.PhoneNumber, + To: contactNumber, + Content: content, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode) + + messageID := sendResp.Data.ID.String() + t.Logf("sent message: %s", messageID) + + fcmReqs := waitForFCMPush(t, messageID, 30*time.Second) + require.Len(t, fcmReqs, 1) + assert.Contains(t, fcmReqs[0].Request.Body, messageID) + assert.True(t, strings.Contains(fcmReqs[0].Request.URL, "/messages:send") || strings.Contains(fcmReqs[0].Request.AbsoluteURL, "/messages:send")) + + outstanding := fetchOutstandingMessage(ctx, t, phone.PhoneAPIKey, messageID) + assert.Equal(t, messageID, outstanding["id"]) + assert.Equal(t, content, outstanding["content"]) + assert.Equal(t, phone.PhoneNumber, outstanding["owner"]) + assert.Equal(t, contactNumber, outstanding["contact"]) + + fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") + time.Sleep(200 * time.Millisecond) + fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED") + + msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) + assert.Equal(t, "delivered", msg.Status) + assert.Equal(t, content, msg.Content) + + webhookReqs := waitForWebhookEvents(t, webhookPath, 2, 30*time.Second) + for _, req := range webhookReqs { + assertWebhookJWT(t, req.Request, signingKey) + } +} diff --git a/tests/seed.sql b/tests/seed.sql new file mode 100644 index 00000000..4ae41006 --- /dev/null +++ b/tests/seed.sql @@ -0,0 +1,26 @@ +-- Seed test data for integration tests +-- Run AFTER GORM has migrated the schema (i.e., after API starts) + +-- Test user +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'test-user-id', + 'test@httpsms.com', + 'test-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- System user (for event queue auth) +INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at) +VALUES ( + 'system-user-id', + 'system@httpsms.com', + 'system-user-api-key', + 'UTC', + 'pro-monthly', + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; diff --git a/tests/wiremock/mappings/fcm-send.json b/tests/wiremock/mappings/fcm-send.json new file mode 100644 index 00000000..7640fb7c --- /dev/null +++ b/tests/wiremock/mappings/fcm-send.json @@ -0,0 +1,15 @@ +{ + "request": { + "urlPathPattern": "/v1/projects/.*/messages:send", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "name": "projects/httpsms-test/messages/fake-message-id" + } + } +} diff --git a/tests/wiremock/mappings/oauth-token.json b/tests/wiremock/mappings/oauth-token.json new file mode 100644 index 00000000..9518f4fe --- /dev/null +++ b/tests/wiremock/mappings/oauth-token.json @@ -0,0 +1,17 @@ +{ + "request": { + "urlPathPattern": "/token", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + } + } +} diff --git a/tests/wiremock/mappings/webhook-receiver.json b/tests/wiremock/mappings/webhook-receiver.json new file mode 100644 index 00000000..79966b64 --- /dev/null +++ b/tests/wiremock/mappings/webhook-receiver.json @@ -0,0 +1,15 @@ +{ + "request": { + "urlPathPattern": "/webhooks/.*", + "method": "POST" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "status": "received" + } + } +}