Fix 113 hardening issues across entire Go backend

Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-18 23:14:13 -05:00
parent 3b86d0aae1
commit 42a5533a56
95 changed files with 2892 additions and 1783 deletions

View File

@@ -38,16 +38,15 @@ func NewAPNsClient(cfg *config.PushConfig) (*APNsClient, error) {
TeamID: cfg.APNSTeamID,
}
// Create client - production or sandbox
// Use APNSProduction if set, otherwise fall back to inverse of APNSSandbox
// Create client - sandbox if APNSSandbox is true, production otherwise.
// APNSSandbox is the single source of truth (defaults to true for safety).
var client *apns2.Client
useProduction := cfg.APNSProduction || !cfg.APNSSandbox
if useProduction {
client = apns2.NewTokenClient(authToken).Production()
log.Info().Msg("APNs client configured for PRODUCTION")
} else {
if cfg.APNSSandbox {
client = apns2.NewTokenClient(authToken).Development()
log.Info().Msg("APNs client configured for DEVELOPMENT/SANDBOX")
} else {
client = apns2.NewTokenClient(authToken).Production()
log.Info().Msg("APNs client configured for PRODUCTION")
}
return &APNsClient{

View File

@@ -38,17 +38,17 @@ func NewClient(cfg *config.PushConfig, enabled bool) (*Client, error) {
log.Warn().Msg("APNs not configured - iOS push disabled")
}
// Initialize FCM client (Android)
if cfg.FCMServerKey != "" {
// Initialize FCM client (Android) - requires project ID + service account credentials
if cfg.FCMProjectID != "" && (cfg.FCMServiceAccountPath != "" || cfg.FCMServiceAccountJSON != "") {
fcmClient, err := NewFCMClient(cfg)
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize FCM client - Android push disabled")
log.Warn().Err(err).Msg("Failed to initialize FCM v1 client - Android push disabled")
} else {
client.fcm = fcmClient
log.Info().Msg("FCM client initialized successfully")
log.Info().Msg("FCM v1 client initialized successfully")
}
} else {
log.Warn().Msg("FCM not configured - Android push disabled")
log.Warn().Msg("FCM not configured (need FCM_PROJECT_ID + FCM_SERVICE_ACCOUNT_PATH or FCM_SERVICE_ACCOUNT_JSON) - Android push disabled")
}
return client, nil

View File

@@ -5,138 +5,304 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2/google"
"github.com/treytartt/honeydue-api/internal/config"
)
const fcmEndpoint = "https://fcm.googleapis.com/fcm/send"
const (
// fcmV1EndpointFmt is the FCM HTTP v1 API endpoint template.
fcmV1EndpointFmt = "https://fcm.googleapis.com/v1/projects/%s/messages:send"
// FCMClient handles direct communication with Firebase Cloud Messaging
// fcmScope is the OAuth 2.0 scope required for FCM HTTP v1 API.
fcmScope = "https://www.googleapis.com/auth/firebase.messaging"
)
// FCMClient handles communication with Firebase Cloud Messaging
// using the HTTP v1 API and OAuth 2.0 service account authentication.
type FCMClient struct {
serverKey string
projectID string
endpoint string
httpClient *http.Client
}
// FCMMessage represents an FCM message payload
type FCMMessage struct {
To string `json:"to,omitempty"`
RegistrationIDs []string `json:"registration_ids,omitempty"`
Notification *FCMNotification `json:"notification,omitempty"`
Data map[string]string `json:"data,omitempty"`
Priority string `json:"priority,omitempty"`
ContentAvailable bool `json:"content_available,omitempty"`
// --- Request types (FCM v1 API) ---
// fcmV1Request is the top-level request body for the FCM v1 API.
type fcmV1Request struct {
Message *fcmV1Message `json:"message"`
}
// FCMNotification represents the notification payload
// fcmV1Message represents a single FCM v1 message.
type fcmV1Message struct {
Token string `json:"token"`
Notification *FCMNotification `json:"notification,omitempty"`
Data map[string]string `json:"data,omitempty"`
Android *fcmAndroidConfig `json:"android,omitempty"`
}
// FCMNotification represents the notification payload.
type FCMNotification struct {
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Sound string `json:"sound,omitempty"`
Badge string `json:"badge,omitempty"`
Icon string `json:"icon,omitempty"`
}
// FCMResponse represents the FCM API response
type FCMResponse struct {
MulticastID int64 `json:"multicast_id"`
Success int `json:"success"`
Failure int `json:"failure"`
CanonicalIDs int `json:"canonical_ids"`
Results []FCMResult `json:"results"`
// fcmAndroidConfig provides Android-specific message configuration.
type fcmAndroidConfig struct {
Priority string `json:"priority,omitempty"`
}
// FCMResult represents a single result in the FCM response
type FCMResult struct {
MessageID string `json:"message_id,omitempty"`
RegistrationID string `json:"registration_id,omitempty"`
Error string `json:"error,omitempty"`
// --- Response types (FCM v1 API) ---
// fcmV1Response is the successful response from the FCM v1 API.
type fcmV1Response struct {
Name string `json:"name"` // e.g. "projects/myproject/messages/0:1234567890"
}
// NewFCMClient creates a new FCM client
// fcmV1ErrorResponse is the error response from the FCM v1 API.
type fcmV1ErrorResponse struct {
Error fcmV1Error `json:"error"`
}
// fcmV1Error contains the structured error details.
type fcmV1Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
Details json.RawMessage `json:"details,omitempty"`
}
// --- Error types ---
// FCMErrorCode represents well-known FCM v1 error codes for programmatic handling.
type FCMErrorCode string
const (
FCMErrUnregistered FCMErrorCode = "UNREGISTERED"
FCMErrQuotaExceeded FCMErrorCode = "QUOTA_EXCEEDED"
FCMErrUnavailable FCMErrorCode = "UNAVAILABLE"
FCMErrInternal FCMErrorCode = "INTERNAL"
FCMErrInvalidArgument FCMErrorCode = "INVALID_ARGUMENT"
FCMErrSenderIDMismatch FCMErrorCode = "SENDER_ID_MISMATCH"
FCMErrThirdPartyAuth FCMErrorCode = "THIRD_PARTY_AUTH_ERROR"
)
// FCMSendError is a structured error returned when an individual FCM send fails.
type FCMSendError struct {
Token string
StatusCode int
ErrorCode FCMErrorCode
Message string
}
func (e *FCMSendError) Error() string {
return fmt.Sprintf("FCM send failed for token %s: %s (status %d, code %s)",
truncateToken(e.Token), e.Message, e.StatusCode, e.ErrorCode)
}
// IsUnregistered returns true if the device token is no longer valid and should be removed.
func (e *FCMSendError) IsUnregistered() bool {
return e.ErrorCode == FCMErrUnregistered
}
// --- OAuth 2.0 transport ---
// oauth2BearerTransport is an http.RoundTripper that attaches an OAuth 2.0 Bearer
// token to every outgoing request. The token source handles refresh automatically.
type oauth2BearerTransport struct {
base http.RoundTripper
getToken func() (string, error)
}
func (t *oauth2BearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
accessToken, err := t.getToken()
if err != nil {
return nil, fmt.Errorf("failed to obtain OAuth 2.0 token for FCM: %w", err)
}
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+accessToken)
return t.base.RoundTrip(r)
}
// --- Client construction ---
// NewFCMClient creates a new FCM client using the HTTP v1 API with OAuth 2.0
// service account authentication. It accepts either a path to a service account
// JSON file or the raw JSON content directly via config.
func NewFCMClient(cfg *config.PushConfig) (*FCMClient, error) {
if cfg.FCMServerKey == "" {
return nil, fmt.Errorf("FCM server key not configured")
if cfg.FCMProjectID == "" {
return nil, fmt.Errorf("FCM project ID not configured (set FCM_PROJECT_ID)")
}
return &FCMClient{
serverKey: cfg.FCMServerKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
credJSON, err := resolveServiceAccountJSON(cfg)
if err != nil {
return nil, err
}
// Create OAuth 2.0 credentials with the FCM messaging scope.
// The google library handles automatic token refresh.
creds, err := google.CredentialsFromJSON(context.Background(), credJSON, fcmScope)
if err != nil {
return nil, fmt.Errorf("failed to parse FCM service account credentials: %w", err)
}
// Build an HTTP client that automatically attaches and refreshes OAuth tokens.
transport := &oauth2BearerTransport{
base: http.DefaultTransport,
getToken: func() (string, error) {
tok, err := creds.TokenSource.Token()
if err != nil {
return "", err
}
return tok.AccessToken, nil
},
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
}
endpoint := fmt.Sprintf(fcmV1EndpointFmt, cfg.FCMProjectID)
log.Info().
Str("project_id", cfg.FCMProjectID).
Str("endpoint", endpoint).
Msg("FCM v1 client initialized with OAuth 2.0")
return &FCMClient{
projectID: cfg.FCMProjectID,
endpoint: endpoint,
httpClient: httpClient,
}, nil
}
// Send sends a push notification to Android devices
// resolveServiceAccountJSON returns the service account JSON bytes from config.
func resolveServiceAccountJSON(cfg *config.PushConfig) ([]byte, error) {
if cfg.FCMServiceAccountJSON != "" {
return []byte(cfg.FCMServiceAccountJSON), nil
}
if cfg.FCMServiceAccountPath != "" {
data, err := os.ReadFile(cfg.FCMServiceAccountPath)
if err != nil {
return nil, fmt.Errorf("failed to read FCM service account file %s: %w", cfg.FCMServiceAccountPath, err)
}
return data, nil
}
return nil, fmt.Errorf("FCM service account not configured (set FCM_SERVICE_ACCOUNT_PATH or FCM_SERVICE_ACCOUNT_JSON)")
}
// --- Sending ---
// Send sends a push notification to Android devices via the FCM HTTP v1 API.
// The v1 API requires one request per device token, so this iterates over all tokens.
// The method signature is kept identical to the previous legacy implementation
// so callers do not need to change.
func (c *FCMClient) Send(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
if len(tokens) == 0 {
return nil
}
msg := FCMMessage{
RegistrationIDs: tokens,
Notification: &FCMNotification{
Title: title,
Body: message,
Sound: "default",
},
Data: data,
Priority: "high",
}
var sendErrors []error
successCount := 0
body, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal FCM message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", fcmEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create FCM request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "key="+c.serverKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send FCM request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("FCM returned status %d", resp.StatusCode)
}
var fcmResp FCMResponse
if err := json.NewDecoder(resp.Body).Decode(&fcmResp); err != nil {
return fmt.Errorf("failed to decode FCM response: %w", err)
}
// Log individual results
for i, result := range fcmResp.Results {
if i >= len(tokens) {
break
}
if result.Error != "" {
for _, token := range tokens {
err := c.sendOne(ctx, token, title, message, data)
if err != nil {
log.Error().
Str("token", truncateToken(tokens[i])).
Str("error", result.Error).
Msg("FCM notification failed")
Err(err).
Str("token", truncateToken(token)).
Msg("FCM v1 notification failed")
sendErrors = append(sendErrors, err)
continue
}
successCount++
log.Debug().
Str("token", truncateToken(token)).
Msg("FCM v1 notification sent successfully")
}
log.Info().
Int("total", len(tokens)).
Int("success", fcmResp.Success).
Int("failure", fcmResp.Failure).
Msg("FCM batch send complete")
Int("success", successCount).
Int("failed", len(sendErrors)).
Msg("FCM v1 batch send complete")
if fcmResp.Success == 0 && fcmResp.Failure > 0 {
return fmt.Errorf("all FCM notifications failed")
if len(sendErrors) > 0 && successCount == 0 {
return fmt.Errorf("all FCM notifications failed: first error: %w", sendErrors[0])
}
return nil
}
// sendOne sends a single FCM v1 message to one device token.
func (c *FCMClient) sendOne(ctx context.Context, token, title, message string, data map[string]string) error {
reqBody := fcmV1Request{
Message: &fcmV1Message{
Token: token,
Notification: &FCMNotification{
Title: title,
Body: message,
},
Data: data,
Android: &fcmAndroidConfig{
Priority: "HIGH",
},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal FCM v1 message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create FCM v1 request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send FCM v1 request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read FCM v1 response body: %w", err)
}
if resp.StatusCode == http.StatusOK {
return nil
}
// Parse the error response for structured error information.
return parseFCMV1Error(token, resp.StatusCode, respBody)
}
// parseFCMV1Error extracts a structured FCMSendError from the v1 API error response.
func parseFCMV1Error(token string, statusCode int, body []byte) *FCMSendError {
var errResp fcmV1ErrorResponse
if err := json.Unmarshal(body, &errResp); err != nil {
return &FCMSendError{
Token: token,
StatusCode: statusCode,
Message: fmt.Sprintf("unparseable error response: %s", string(body)),
}
}
return &FCMSendError{
Token: token,
StatusCode: statusCode,
ErrorCode: FCMErrorCode(errResp.Error.Status),
Message: errResp.Error.Message,
}
}

View File

@@ -5,182 +5,266 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestFCMClient creates an FCMClient pointing at the given test server URL.
// newTestFCMClient creates an FCMClient whose endpoint points at the given test
// server URL. The HTTP client uses no OAuth transport so tests can run without
// real Google credentials.
func newTestFCMClient(serverURL string) *FCMClient {
return &FCMClient{
serverKey: "test-server-key",
httpClient: http.DefaultClient,
projectID: "test-project",
endpoint: serverURL,
httpClient: &http.Client{},
}
}
// serveFCMResponse creates an httptest.Server that returns the given FCMResponse as JSON.
func serveFCMResponse(t *testing.T, resp FCMResponse) *httptest.Server {
// serveFCMV1Success creates a test server that returns a successful v1 response
// for every request.
func serveFCMV1Success(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify it looks like a v1 request.
var req fcmV1Request
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
require.NotNil(t, req.Message)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
resp := fcmV1Response{Name: "projects/test-project/messages/0:12345"}
_ = json.NewEncoder(w).Encode(resp)
}))
}
// sendWithEndpoint is a helper that sends an FCM notification using a custom endpoint
// (the test server) instead of the real FCM endpoint. This avoids modifying the
// production code to be testable and instead temporarily overrides the client's HTTP
// transport to redirect requests to our test server.
func sendWithEndpoint(client *FCMClient, server *httptest.Server, ctx context.Context, tokens []string, title, message string, data map[string]string) error {
// Override the HTTP client to redirect all requests to the test server
client.httpClient = server.Client()
// We need to intercept the request and redirect it to our test server.
// Use a custom RoundTripper that rewrites the URL.
originalTransport := server.Client().Transport
client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
// Rewrite the URL to point to the test server
req.URL.Scheme = "http"
req.URL.Host = server.Listener.Addr().String()
if originalTransport != nil {
return originalTransport.RoundTrip(req)
// serveFCMV1Error creates a test server that returns the given status code and
// a structured v1 error response for every request.
func serveFCMV1Error(t *testing.T, statusCode int, errStatus string, errMessage string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
resp := fcmV1ErrorResponse{
Error: fcmV1Error{
Code: statusCode,
Message: errMessage,
Status: errStatus,
},
}
return http.DefaultTransport.RoundTrip(req)
})
return client.Send(ctx, tokens, title, message, data)
_ = json.NewEncoder(w).Encode(resp)
}))
}
// roundTripFunc is a function that implements http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFCMSend_MoreResultsThanTokens_NoPanic(t *testing.T) {
// FCM returns 5 results but we only sent 2 tokens.
// Before the bounds check fix, this would panic with index out of range.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 2,
Failure: 3,
Results: []FCMResult{
{MessageID: "msg1"},
{MessageID: "msg2"},
{Error: "InvalidRegistration"},
{Error: "NotRegistered"},
{Error: "InvalidRegistration"},
},
}
server := serveFCMResponse(t, fcmResp)
func TestFCMV1Send_Success_SingleToken(t *testing.T) {
server := serveFCMV1Success(t)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111", "token-bbb-222"}
// This must not panic
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
err := client.Send(context.Background(), []string{"token-aaa-111"}, "Title", "Body", nil)
assert.NoError(t, err)
}
func TestFCMSend_FewerResultsThanTokens_NoPanic(t *testing.T) {
// FCM returns fewer results than tokens we sent.
// This is also a malformed response but should not panic.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 1,
Failure: 0,
Results: []FCMResult{
{MessageID: "msg1"},
},
}
func TestFCMV1Send_Success_MultipleTokens(t *testing.T) {
var mu sync.Mutex
receivedTokens := make([]string, 0)
server := serveFCMResponse(t, fcmResp)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req fcmV1Request
_ = json.NewDecoder(r.Body).Decode(&req)
mu.Lock()
receivedTokens = append(receivedTokens, req.Message.Token)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := fcmV1Response{Name: "projects/test-project/messages/0:12345"}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111", "token-bbb-222", "token-ccc-333"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
err := client.Send(context.Background(), tokens, "Title", "Body", map[string]string{"key": "value"})
assert.NoError(t, err)
assert.ElementsMatch(t, tokens, receivedTokens)
}
func TestFCMSend_EmptyResponse_NoPanic(t *testing.T) {
// FCM returns an empty Results slice.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 0,
Failure: 0,
Results: []FCMResult{},
}
server := serveFCMResponse(t, fcmResp)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
// No panic expected. The function returns nil because fcmResp.Success == 0
// and fcmResp.Failure == 0 (the "all failed" check requires Failure > 0).
assert.NoError(t, err)
}
func TestFCMSend_NilResultsSlice_NoPanic(t *testing.T) {
// FCM returns a response with nil Results (e.g., malformed JSON).
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 0,
Failure: 1,
}
server := serveFCMResponse(t, fcmResp)
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
// Should return error because Success == 0 and Failure > 0
assert.Error(t, err)
assert.Contains(t, err.Error(), "all FCM notifications failed")
}
func TestFCMSend_EmptyTokens_ReturnsNil(t *testing.T) {
// Verify the early return for empty tokens.
func TestFCMV1Send_EmptyTokens_ReturnsNil(t *testing.T) {
client := &FCMClient{
serverKey: "test-key",
httpClient: http.DefaultClient,
projectID: "test-project",
endpoint: "http://unused",
httpClient: &http.Client{},
}
err := client.Send(context.Background(), []string{}, "Test", "Body", nil)
err := client.Send(context.Background(), []string{}, "Title", "Body", nil)
assert.NoError(t, err)
}
func TestFCMSend_ResultsWithErrorsMatchTokens(t *testing.T) {
// Normal case: results count matches tokens count, all with errors.
fcmResp := FCMResponse{
MulticastID: 12345,
Success: 0,
Failure: 2,
Results: []FCMResult{
{Error: "InvalidRegistration"},
{Error: "NotRegistered"},
},
}
server := serveFCMResponse(t, fcmResp)
func TestFCMV1Send_AllFail_ReturnsError(t *testing.T) {
server := serveFCMV1Error(t, http.StatusNotFound, "UNREGISTERED", "The registration token is not registered")
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"token-aaa-111", "token-bbb-222"}
tokens := []string{"bad-token-1", "bad-token-2"}
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
err := client.Send(context.Background(), tokens, "Title", "Body", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "all FCM notifications failed")
}
func TestFCMV1Send_PartialFailure_ReturnsNil(t *testing.T) {
var mu sync.Mutex
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
callCount++
n := callCount
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if n == 1 {
// First token succeeds
w.WriteHeader(http.StatusOK)
resp := fcmV1Response{Name: "projects/test-project/messages/0:12345"}
_ = json.NewEncoder(w).Encode(resp)
} else {
// Second token fails
w.WriteHeader(http.StatusNotFound)
resp := fcmV1ErrorResponse{
Error: fcmV1Error{Code: 404, Message: "not registered", Status: "UNREGISTERED"},
}
_ = json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
client := newTestFCMClient(server.URL)
tokens := []string{"good-token", "bad-token"}
// Partial failure: at least one succeeded, so no error returned.
err := client.Send(context.Background(), tokens, "Title", "Body", nil)
assert.NoError(t, err)
}
func TestFCMV1Send_UnregisteredError(t *testing.T) {
server := serveFCMV1Error(t, http.StatusNotFound, "UNREGISTERED", "The registration token is not registered")
defer server.Close()
client := newTestFCMClient(server.URL)
err := client.sendOne(context.Background(), "stale-token", "Title", "Body", nil)
require.Error(t, err)
var sendErr *FCMSendError
require.ErrorAs(t, err, &sendErr)
assert.True(t, sendErr.IsUnregistered())
assert.Equal(t, FCMErrUnregistered, sendErr.ErrorCode)
assert.Equal(t, http.StatusNotFound, sendErr.StatusCode)
}
func TestFCMV1Send_QuotaExceededError(t *testing.T) {
server := serveFCMV1Error(t, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Sending quota exceeded")
defer server.Close()
client := newTestFCMClient(server.URL)
err := client.sendOne(context.Background(), "some-token", "Title", "Body", nil)
require.Error(t, err)
var sendErr *FCMSendError
require.ErrorAs(t, err, &sendErr)
assert.Equal(t, FCMErrQuotaExceeded, sendErr.ErrorCode)
assert.Equal(t, http.StatusTooManyRequests, sendErr.StatusCode)
}
func TestFCMV1Send_UnparseableErrorResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("not json at all"))
}))
defer server.Close()
client := newTestFCMClient(server.URL)
err := client.sendOne(context.Background(), "some-token", "Title", "Body", nil)
require.Error(t, err)
var sendErr *FCMSendError
require.ErrorAs(t, err, &sendErr)
assert.Equal(t, http.StatusInternalServerError, sendErr.StatusCode)
assert.Contains(t, sendErr.Message, "unparseable error response")
}
func TestFCMV1Send_RequestPayloadFormat(t *testing.T) {
var receivedReq fcmV1Request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify Content-Type header
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.Equal(t, http.MethodPost, r.Method)
err := json.NewDecoder(r.Body).Decode(&receivedReq)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(fcmV1Response{Name: "projects/test-project/messages/0:12345"})
}))
defer server.Close()
client := newTestFCMClient(server.URL)
data := map[string]string{"task_id": "42", "action": "complete"}
err := client.Send(context.Background(), []string{"device-token-xyz"}, "Task Due", "Your task is due today", data)
require.NoError(t, err)
// Verify the v1 message structure
require.NotNil(t, receivedReq.Message)
assert.Equal(t, "device-token-xyz", receivedReq.Message.Token)
assert.Equal(t, "Task Due", receivedReq.Message.Notification.Title)
assert.Equal(t, "Your task is due today", receivedReq.Message.Notification.Body)
assert.Equal(t, "42", receivedReq.Message.Data["task_id"])
assert.Equal(t, "complete", receivedReq.Message.Data["action"])
assert.Equal(t, "HIGH", receivedReq.Message.Android.Priority)
}
func TestFCMSendError_Error(t *testing.T) {
sendErr := &FCMSendError{
Token: "abcdef1234567890",
StatusCode: 404,
ErrorCode: FCMErrUnregistered,
Message: "token not registered",
}
errStr := sendErr.Error()
assert.Contains(t, errStr, "abcdef12...")
assert.Contains(t, errStr, "token not registered")
assert.Contains(t, errStr, "404")
assert.Contains(t, errStr, "UNREGISTERED")
}
func TestFCMSendError_IsUnregistered(t *testing.T) {
tests := []struct {
name string
code FCMErrorCode
expected bool
}{
{"unregistered", FCMErrUnregistered, true},
{"quota_exceeded", FCMErrQuotaExceeded, false},
{"internal", FCMErrInternal, false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &FCMSendError{ErrorCode: tt.code}
assert.Equal(t, tt.expected, err.IsUnregistered())
})
}
}