Harden API security: input validation, safe auth extraction, new tests, and deploy config
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>
This commit is contained in:
186
internal/push/fcm_test.go
Normal file
186
internal/push/fcm_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user