Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/api.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

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

Check warning on line 82 in .github/workflows/api.yml

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

.github/workflows/api.yml#L82

An action sourced from a third-party repository on GitHub is not pinned to a full length commit SHA. Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release.
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

Check warning on line 88 in .github/workflows/api.yml

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

.github/workflows/api.yml#L88

An action sourced from a third-party repository on GitHub is not pinned to a full length commit SHA. Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release.

- 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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml → .github/workflows/web.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: ci
name: web

on:
push:
Expand All @@ -25,7 +25,7 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 9
version: 10

- name: Install dependencies 📦
run: pnpm install
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@
android/app/debug/
*main.exe*
android/app/release/

tests/firebase-credentials.json
tests/emulator/emulator.exe
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/cmd/fcm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
37 changes: 28 additions & 9 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down
100 changes: 100 additions & 0 deletions api/pkg/services/emulator_fcm_client.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions api/pkg/services/fcm_client.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading