Comprehensive security hardening from audit findings: - Add validation tags to all DTO request structs (max lengths, ranges, enums) - Replace unsafe type assertions with MustGetAuthUser helper across all handlers - Remove query-param token auth from admin middleware (prevents URL token leakage) - Add request validation calls in handlers that were missing c.Validate() - Remove goroutines in handlers (timezone update now synchronous) - Add sanitize middleware and path traversal protection (path_utils) - Stop resetting admin passwords on migration restart - Warn on well-known default SECRET_KEY - Add ~30 new test files covering security regressions, auth safety, repos, and services - Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
187 lines
5.6 KiB
Go
187 lines
5.6 KiB
Go
package push
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newTestFCMClient creates an FCMClient pointing at the given test server URL.
|
|
func newTestFCMClient(serverURL string) *FCMClient {
|
|
return &FCMClient{
|
|
serverKey: "test-server-key",
|
|
httpClient: http.DefaultClient,
|
|
}
|
|
}
|
|
|
|
// serveFCMResponse creates an httptest.Server that returns the given FCMResponse as JSON.
|
|
func serveFCMResponse(t *testing.T, resp FCMResponse) *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(http.StatusOK)
|
|
err := json.NewEncoder(w).Encode(resp)
|
|
require.NoError(t, err)
|
|
}))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
return http.DefaultTransport.RoundTrip(req)
|
|
})
|
|
|
|
return client.Send(ctx, tokens, title, message, data)
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
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"},
|
|
},
|
|
}
|
|
|
|
server := serveFCMResponse(t, fcmResp)
|
|
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)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
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.
|
|
client := &FCMClient{
|
|
serverKey: "test-key",
|
|
httpClient: http.DefaultClient,
|
|
}
|
|
|
|
err := client.Send(context.Background(), []string{}, "Test", "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)
|
|
defer server.Close()
|
|
|
|
client := newTestFCMClient(server.URL)
|
|
tokens := []string{"token-aaa-111", "token-bbb-222"}
|
|
|
|
err := sendWithEndpoint(client, server, context.Background(), tokens, "Test", "Body", nil)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "all FCM notifications failed")
|
|
}
|