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>
271 lines
8.4 KiB
Go
271 lines
8.4 KiB
Go
package push
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 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{
|
|
projectID: "test-project",
|
|
endpoint: serverURL,
|
|
httpClient: &http.Client{},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
resp := fcmV1Response{Name: "projects/test-project/messages/0:12345"}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
}
|
|
|
|
func TestFCMV1Send_Success_SingleToken(t *testing.T) {
|
|
server := serveFCMV1Success(t)
|
|
defer server.Close()
|
|
|
|
client := newTestFCMClient(server.URL)
|
|
err := client.Send(context.Background(), []string{"token-aaa-111"}, "Title", "Body", nil)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestFCMV1Send_Success_MultipleTokens(t *testing.T) {
|
|
var mu sync.Mutex
|
|
receivedTokens := make([]string, 0)
|
|
|
|
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 := client.Send(context.Background(), tokens, "Title", "Body", map[string]string{"key": "value"})
|
|
assert.NoError(t, err)
|
|
assert.ElementsMatch(t, tokens, receivedTokens)
|
|
}
|
|
|
|
func TestFCMV1Send_EmptyTokens_ReturnsNil(t *testing.T) {
|
|
client := &FCMClient{
|
|
projectID: "test-project",
|
|
endpoint: "http://unused",
|
|
httpClient: &http.Client{},
|
|
}
|
|
|
|
err := client.Send(context.Background(), []string{}, "Title", "Body", nil)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
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{"bad-token-1", "bad-token-2"}
|
|
|
|
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())
|
|
})
|
|
}
|
|
}
|