mirror of
https://github.com/hanzo-fi/go-libs.git
synced 2026-10-30 20:53:44 +00:00
1443 lines
40 KiB
Go
1443 lines
40 KiB
Go
package httpaudit
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ThreeDotsLabs/watermill/message"
|
|
"github.com/spf13/pflag"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/audit"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/messaging/publish"
|
|
logging "github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
)
|
|
|
|
func TestMiddleware_DisabledByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
var downstreamHeaderValue string
|
|
handler := Middleware(pub, topic, "test-app", nil)(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
downstreamHeaderValue = r.Header.Get(audit.HandledHeader)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("POST", "/api/test", strings.NewReader(`{"key":"value"}`))
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Equal(t, "ok", rr.Body.String())
|
|
assert.Empty(t, downstreamHeaderValue)
|
|
assert.Empty(t, pub.AllMessages()[topic])
|
|
}
|
|
|
|
func TestMiddleware_WithConfigFromFlagsEnablesAudit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
|
audit.AddFlags(flags)
|
|
require.NoError(t, flags.Set(audit.AuditEnabledFlag, "true"))
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
cfg, err := audit.ConfigFromFlags(flags)
|
|
require.NoError(t, err)
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithConfig(cfg))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Len(t, pub.AllMessages()[topic], 1)
|
|
}
|
|
|
|
func TestMiddleware_BasicCapture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("POST", "/api/test", strings.NewReader(`{"key":"value"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Equal(t, `{"status":"ok"}`, rr.Body.String())
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
assert.Equal(t, "test-app", event.App)
|
|
assert.Equal(t, audit.EventVersion, event.Version)
|
|
assert.Equal(t, audit.EventTypeAudit, event.Type)
|
|
|
|
payloadBytes, err := json.Marshal(event.Payload)
|
|
require.NoError(t, err)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.NotEmpty(t, payload.ID)
|
|
assert.Equal(t, "POST", payload.HTTP.Request.Method)
|
|
assert.Equal(t, "/api/test", payload.HTTP.Request.Path)
|
|
assert.Equal(t, `{"key":"value"}`, payload.HTTP.Request.Body)
|
|
assert.Equal(t, http.StatusOK, payload.HTTP.Response.StatusCode)
|
|
assert.Equal(t, `{"status":"ok"}`, payload.HTTP.Response.Body)
|
|
assert.False(t, payload.HTTP.Request.BodyTruncated)
|
|
assert.False(t, payload.HTTP.Response.BodyTruncated)
|
|
}
|
|
|
|
func TestMiddleware_QueryParamsCapture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test?limit=10&status=pending&status=posted&empty=", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
payload := auditPayloadFromMessage(t, messages[0])
|
|
|
|
assert.Equal(t, "/api/test", payload.HTTP.Request.Path)
|
|
assert.Equal(t, url.Values{
|
|
"limit": {"10"},
|
|
"status": {"pending", "posted"},
|
|
"empty": {""},
|
|
}, payload.HTTP.Request.QueryParams)
|
|
assert.False(t, payload.HTTP.Request.QueryParamsTruncated)
|
|
}
|
|
|
|
func TestMiddleware_CapsQueryParamsAndFlagsTruncation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
const limit = 24
|
|
handler := Middleware(pub, topic, "test-app", nil,
|
|
WithEnabled(true),
|
|
WithMaxQueryParamsBytes(limit),
|
|
)(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test?first=one&large="+strings.Repeat("x", 100)+"&last=ignored", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
payload := auditPayloadFromMessage(t, messages[0])
|
|
|
|
assert.Equal(t, url.Values{
|
|
"first": {"one"},
|
|
"large": {strings.Repeat("x", 8)},
|
|
}, payload.HTTP.Request.QueryParams)
|
|
assert.True(t, payload.HTTP.Request.QueryParamsTruncated)
|
|
assert.NotContains(t, string(messages[0].Payload), "last")
|
|
assert.NotContains(t, string(messages[0].Payload), strings.Repeat("x", 20))
|
|
}
|
|
|
|
func TestMiddleware_CapsRequestBodyAndFlagsTruncation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
var received string
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
received = string(b)
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
prefix := strings.Repeat("a", DefaultMaxCapturedBodyBytes)
|
|
reqBody := prefix + strings.Repeat("b", 1024)
|
|
req := httptest.NewRequest("POST", "/api/test", strings.NewReader(reqBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
// The handler still sees the full request body.
|
|
assert.Equal(t, reqBody, received)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
payload := auditPayloadFromMessage(t, messages[0])
|
|
|
|
assert.Len(t, payload.HTTP.Request.Body, DefaultMaxCapturedBodyBytes)
|
|
assert.Equal(t, prefix, payload.HTTP.Request.Body)
|
|
assert.True(t, payload.HTTP.Request.BodyTruncated)
|
|
}
|
|
|
|
func TestMiddleware_CapsResponseBodyAndFlagsTruncation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
prefix := strings.Repeat("r", DefaultMaxCapturedBodyBytes)
|
|
responseBody := prefix + strings.Repeat("s", 1024)
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
// Write across the cap boundary in two chunks.
|
|
_, _ = w.Write([]byte(responseBody[:DefaultMaxCapturedBodyBytes/2]))
|
|
_, _ = w.Write([]byte(responseBody[DefaultMaxCapturedBodyBytes/2:]))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
// The client still receives the full response body.
|
|
assert.Equal(t, responseBody, rr.Body.String())
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
payload := auditPayloadFromMessage(t, messages[0])
|
|
|
|
assert.Len(t, payload.HTTP.Response.Body, DefaultMaxCapturedBodyBytes)
|
|
assert.Equal(t, prefix, payload.HTTP.Response.Body)
|
|
assert.True(t, payload.HTTP.Response.BodyTruncated)
|
|
}
|
|
|
|
func TestMiddleware_WithMaxBodyBytesOverridesDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
const limit = 16
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true), WithMaxBodyBytes(limit))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(strings.Repeat("z", 100)))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
assert.Equal(t, strings.Repeat("z", 100), rr.Body.String())
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
payload := auditPayloadFromMessage(t, messages[0])
|
|
|
|
assert.Len(t, payload.HTTP.Response.Body, limit)
|
|
assert.True(t, payload.HTTP.Response.BodyTruncated)
|
|
}
|
|
|
|
func TestMiddleware_DoesNotPoolOversizedCaptureBuffers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.True(t, shouldPoolCaptureBuffer(bytes.NewBuffer(make([]byte, 0, DefaultMaxCapturedBodyBytes))))
|
|
assert.False(t, shouldPoolCaptureBuffer(bytes.NewBuffer(make([]byte, 0, DefaultMaxCapturedBodyBytes+1))))
|
|
}
|
|
|
|
func auditPayloadFromMessage(t *testing.T, msg *message.Message) audit.Payload {
|
|
t.Helper()
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(msg.Payload, &event))
|
|
|
|
payloadBytes, err := json.Marshal(event.Payload)
|
|
require.NoError(t, err)
|
|
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
return payload
|
|
}
|
|
|
|
func TestMiddleware_AuthorizationHeaderStripped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set("Authorization", "Bearer secret-token")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Empty(t, payload.HTTP.Request.Header.Get("Authorization"))
|
|
}
|
|
|
|
func TestMiddleware_CookieHeadersStripped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Response-ID", "response-id")
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: "response-secret",
|
|
})
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set("Cookie", "session=request-secret")
|
|
req.Header.Set("X-Request-ID", "request-id")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Contains(t, rr.Header().Get("Set-Cookie"), "response-secret")
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
payload := auditPayloadFromMessage(t, messages[0])
|
|
|
|
assert.Empty(t, payload.HTTP.Request.Header.Get("Cookie"))
|
|
assert.Equal(t, "request-id", payload.HTTP.Request.Header.Get("X-Request-ID"))
|
|
assert.Empty(t, payload.HTTP.Response.Headers.Get("Set-Cookie"))
|
|
assert.Equal(t, "response-id", payload.HTTP.Response.Headers.Get("X-Response-ID"))
|
|
assert.NotContains(t, string(messages[0].Payload), "request-secret")
|
|
assert.NotContains(t, string(messages[0].Payload), "response-secret")
|
|
}
|
|
|
|
func TestMiddleware_SensitivePaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil,
|
|
WithEnabled(true),
|
|
WithSensitivePaths("/api/auth/oauth/token"),
|
|
)(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"access_token":"secret"}`))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("POST", "/api/auth/oauth/token?client_secret=query-secret", strings.NewReader(`grant_type=client_credentials`))
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, `{"access_token":"secret"}`, rr.Body.String())
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Empty(t, payload.HTTP.Response.Body)
|
|
assert.Empty(t, payload.HTTP.Request.Body)
|
|
assert.Empty(t, payload.HTTP.Request.QueryParams)
|
|
assert.False(t, payload.HTTP.Request.QueryParamsTruncated)
|
|
assert.NotContains(t, string(messages[0].Payload), "client_credentials")
|
|
assert.NotContains(t, string(messages[0].Payload), "access_token")
|
|
assert.NotContains(t, string(messages[0].Payload), "query-secret")
|
|
}
|
|
|
|
func TestMiddleware_SensitivePathsMatchPrefixAndPassRequestBodyThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
var receivedBody string
|
|
handler := Middleware(pub, topic, "test-app", nil,
|
|
WithEnabled(true),
|
|
WithSensitivePaths("/api/auth"),
|
|
)(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
receivedBody = string(body)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"access_token":"response-secret"}`))
|
|
}),
|
|
)
|
|
|
|
reqBody := `username=alice&password=request-secret`
|
|
req := httptest.NewRequest("POST", "/api/auth/oauth/token", strings.NewReader(reqBody))
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, reqBody, receivedBody)
|
|
require.Equal(t, `{"access_token":"response-secret"}`, rr.Body.String())
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
payload := auditPayloadFromMessage(t, messages[0])
|
|
|
|
assert.Empty(t, payload.HTTP.Request.Body)
|
|
assert.Empty(t, payload.HTTP.Response.Body)
|
|
assert.False(t, payload.HTTP.Request.BodyTruncated)
|
|
assert.False(t, payload.HTTP.Response.BodyTruncated)
|
|
assert.NotContains(t, string(messages[0].Payload), "request-secret")
|
|
assert.NotContains(t, string(messages[0].Payload), "response-secret")
|
|
}
|
|
|
|
func TestMiddleware_StreamRequestSkipsBodyCapture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("stream-data"))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("POST", "/api/ledger/v2/logs", strings.NewReader("stream-body"))
|
|
req.Header.Set("Content-Type", "application/vnd.formance.ledger-stream")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Empty(t, payload.HTTP.Request.Body)
|
|
}
|
|
|
|
func TestMiddleware_OctetStreamResponseNotCaptured(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("binary-data"))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/download", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Empty(t, payload.HTTP.Response.Body)
|
|
}
|
|
|
|
func TestMiddleware_OrganizationAndStackID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", []audit.Option{
|
|
audit.WithOrganizationID("org-123"),
|
|
audit.WithStackID("stack-456"),
|
|
}, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Equal(t, "org-123", payload.Actor.OrganizationID)
|
|
assert.Equal(t, "stack-456", payload.Actor.StackID)
|
|
}
|
|
|
|
func TestMiddleware_IPAddressExtraction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
headers map[string]string
|
|
remoteAddr string
|
|
expectedIP string
|
|
}{
|
|
{
|
|
name: "X-Forwarded-For",
|
|
headers: map[string]string{"X-Forwarded-For": "1.2.3.4, 5.6.7.8"},
|
|
remoteAddr: "9.9.9.9:1234",
|
|
expectedIP: "1.2.3.4",
|
|
},
|
|
{
|
|
name: "X-Real-IP",
|
|
headers: map[string]string{"X-Real-IP": "10.0.0.1"},
|
|
remoteAddr: "9.9.9.9:1234",
|
|
expectedIP: "10.0.0.1",
|
|
},
|
|
{
|
|
name: "RemoteAddr fallback",
|
|
headers: map[string]string{},
|
|
remoteAddr: "192.168.1.1:5678",
|
|
expectedIP: "192.168.1.1",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.RemoteAddr = tt.remoteAddr
|
|
for k, v := range tt.headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Equal(t, tt.expectedIP, payload.Actor.IPAddress)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMiddleware_StatusCodeCapture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
codes := []int{http.StatusCreated, http.StatusNotFound, http.StatusInternalServerError}
|
|
|
|
for _, code := range codes {
|
|
t.Run(http.StatusText(code), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(code)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Equal(t, code, payload.HTTP.Response.StatusCode)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMiddleware_RequestBodyPassedThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
var receivedBody string
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
receivedBody = string(body)
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("POST", "/api/test", strings.NewReader(`{"hello":"world"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, `{"hello":"world"}`, receivedBody)
|
|
}
|
|
|
|
func TestMiddleware_NoAuthByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set("Authorization", "Bearer some-token")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Nil(t, payload.Actor.Claims)
|
|
assert.Empty(t, payload.Actor.TokenValidationError)
|
|
}
|
|
|
|
func TestMiddleware_SkipsWhenHeaderPresent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set(audit.HandledHeader, "true")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Equal(t, "ok", rr.Body.String())
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
assert.Empty(t, messages)
|
|
}
|
|
|
|
func TestMiddleware_SecretConfiguredAuditsForgedHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
headerValue string
|
|
}{
|
|
{name: "legacy true value", headerValue: "true"},
|
|
{name: "arbitrary client value", headerValue: "x"},
|
|
{name: "wrong secret", headerValue: "not-the-secret"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
var downstreamHeaderValue string
|
|
handler := Middleware(pub, topic, "test-app", nil,
|
|
WithEnabled(true),
|
|
WithHandledHeaderSecret("super-secret"),
|
|
)(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
downstreamHeaderValue = r.Header.Get(audit.HandledHeader)
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set(audit.HandledHeader, tt.headerValue)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1, "request with forged header must still be audited")
|
|
|
|
// Downstream services sharing the secret must see the secret, not the forged value.
|
|
assert.Equal(t, "super-secret", downstreamHeaderValue)
|
|
|
|
// The secret must not leak into the audit payload.
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Empty(t, payload.HTTP.Request.Header.Get(audit.HandledHeader))
|
|
assert.NotContains(t, string(messages[0].Payload), "super-secret")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMiddleware_SecretConfiguredSkipsWhenHeaderMatches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil,
|
|
WithEnabled(true),
|
|
WithHandledHeaderSecret("super-secret"),
|
|
)(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set(audit.HandledHeader, "super-secret")
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
require.Equal(t, "ok", rr.Body.String())
|
|
|
|
assert.Empty(t, pub.AllMessages()[topic])
|
|
}
|
|
|
|
func TestMiddleware_SecretConfiguredViaConfigFromFlags(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
|
audit.AddFlags(flags)
|
|
require.NoError(t, flags.Set(audit.AuditEnabledFlag, "true"))
|
|
require.NoError(t, flags.Set(audit.AuditHandledHeaderSecretFlag, "flag-secret"))
|
|
|
|
cfg, err := audit.ConfigFromFlags(flags)
|
|
require.NoError(t, err)
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithConfig(cfg))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
// A client-forged header value is still audited.
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set(audit.HandledHeader, "true")
|
|
req = req.WithContext(logging.TestingContext())
|
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
require.Len(t, pub.AllMessages()[topic], 1)
|
|
|
|
// The configured secret skips audit.
|
|
req = httptest.NewRequest("GET", "/api/test", nil)
|
|
req.Header.Set(audit.HandledHeader, "flag-secret")
|
|
req = req.WithContext(logging.TestingContext())
|
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
require.Len(t, pub.AllMessages()[topic], 1)
|
|
}
|
|
|
|
func TestMiddleware_SetsHeaderForDownstream(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
var downstreamHeaderValue string
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
downstreamHeaderValue = r.Header.Get(audit.HandledHeader)
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, "true", downstreamHeaderValue)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
}
|
|
|
|
func TestMiddleware_HeaderNotInAuditPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pub := publish.InMemory()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
messages := pub.AllMessages()[topic]
|
|
require.Len(t, messages, 1)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(messages[0].Payload, &event))
|
|
|
|
payloadBytes, _ := json.Marshal(event.Payload)
|
|
var payload audit.Payload
|
|
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
|
|
|
|
assert.Empty(t, payload.HTTP.Request.Header.Get(audit.HandledHeader))
|
|
}
|
|
|
|
func TestMiddleware_DefaultPublishesSynchronously(t *testing.T) {
|
|
pub := newBlockingPublisher()
|
|
topic := "audit-events"
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
close(done)
|
|
}()
|
|
|
|
pub.waitStarted(t)
|
|
|
|
select {
|
|
case <-done:
|
|
t.Fatal("handler returned before synchronous publish completed")
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
|
|
pub.release()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("handler did not return after publisher completed")
|
|
}
|
|
}
|
|
|
|
func TestMiddleware_AsyncPublishingReturnsBeforeSlowPublisherCompletes(t *testing.T) {
|
|
pub := newBlockingPublisher()
|
|
topic := "audit-events"
|
|
asyncPublisher := NewAsyncPublisher(pub, topic, "test-app",
|
|
WithAsyncPublishingQueueCapacity(1),
|
|
WithAsyncPublishingWorkerCount(1),
|
|
)
|
|
defer func() {
|
|
pub.release()
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
}()
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true), WithAsyncPublisher(asyncPublisher))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
|
|
start := time.Now()
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
elapsed := time.Since(start)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
assert.Less(t, elapsed, 50*time.Millisecond)
|
|
|
|
pub.waitStarted(t)
|
|
}
|
|
|
|
func TestMiddleware_AsyncPublishingQueueIsBoundedAndDropsWhenFull(t *testing.T) {
|
|
pub := newBlockingPublisher()
|
|
topic := "audit-events"
|
|
var dropped atomic.Uint64
|
|
asyncPublisher := NewAsyncPublisher(pub, topic, "test-app",
|
|
WithAsyncPublishingQueueCapacity(1),
|
|
WithAsyncPublishingWorkerCount(1),
|
|
WithAsyncPublishingDropCallback(func(context.Context, audit.Payload) {
|
|
dropped.Add(1)
|
|
}),
|
|
)
|
|
defer func() {
|
|
pub.release()
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
}()
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true), WithAsyncPublisher(asyncPublisher))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
serveAuditRequest(t, handler)
|
|
pub.waitStarted(t)
|
|
|
|
serveAuditRequest(t, handler)
|
|
require.Eventually(t, func() bool {
|
|
return asyncPublisher.Stats().Enqueued == 2
|
|
}, time.Second, time.Millisecond)
|
|
|
|
serveAuditRequest(t, handler)
|
|
require.Eventually(t, func() bool {
|
|
return asyncPublisher.Stats().Dropped == 1
|
|
}, time.Second, time.Millisecond)
|
|
|
|
stats := asyncPublisher.Stats()
|
|
assert.Equal(t, uint64(2), stats.Enqueued)
|
|
assert.Equal(t, uint64(1), stats.Dropped)
|
|
assert.Equal(t, uint64(1), dropped.Load())
|
|
}
|
|
|
|
func TestMiddleware_AsyncPublishingWorkerCountIsBounded(t *testing.T) {
|
|
pub := newConcurrentBlockingPublisher()
|
|
topic := "audit-events"
|
|
asyncPublisher := NewAsyncPublisher(pub, topic, "test-app",
|
|
WithAsyncPublishingQueueCapacity(2),
|
|
WithAsyncPublishingWorkerCount(2),
|
|
)
|
|
defer func() {
|
|
pub.release()
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
}()
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true), WithAsyncPublisher(asyncPublisher))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
serveAuditRequest(t, handler)
|
|
serveAuditRequest(t, handler)
|
|
pub.waitCalls(t, 2)
|
|
|
|
serveAuditRequest(t, handler)
|
|
serveAuditRequest(t, handler)
|
|
require.Eventually(t, func() bool {
|
|
return asyncPublisher.Stats().Enqueued == 4
|
|
}, time.Second, time.Millisecond)
|
|
|
|
serveAuditRequest(t, handler)
|
|
require.Eventually(t, func() bool {
|
|
return asyncPublisher.Stats().Dropped == 1
|
|
}, time.Second, time.Millisecond)
|
|
|
|
assert.LessOrEqual(t, pub.maxInFlight.Load(), int64(2))
|
|
}
|
|
|
|
func TestMiddleware_AsyncPublishingLogsAndCountsPublishErrors(t *testing.T) {
|
|
var logBuffer bytes.Buffer
|
|
logger := logging.NewDefaultLogger(&logBuffer, true, false, false)
|
|
errPublisher := &errorPublisher{err: errors.New("publisher failed")}
|
|
topic := "audit-events"
|
|
asyncPublisher := NewAsyncPublisher(errPublisher, topic, "test-app",
|
|
WithAsyncPublishingQueueCapacity(1),
|
|
WithAsyncPublishingWorkerCount(1),
|
|
)
|
|
|
|
handler := Middleware(errPublisher, topic, "test-app", nil, WithEnabled(true), WithAsyncPublisher(asyncPublisher))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.ContextWithLogger(context.Background(), logger))
|
|
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
require.Eventually(t, func() bool {
|
|
return asyncPublisher.Stats().PublishErrors == 1
|
|
}, time.Second, time.Millisecond)
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
|
|
stats := asyncPublisher.Stats()
|
|
assert.Equal(t, uint64(1), stats.Enqueued)
|
|
assert.Equal(t, uint64(0), stats.Published)
|
|
assert.Equal(t, uint64(1), stats.PublishErrors)
|
|
assert.Contains(t, logBuffer.String(), "failed to publish audit message asynchronously")
|
|
assert.Contains(t, logBuffer.String(), "publisher failed")
|
|
}
|
|
|
|
func TestMiddleware_PublishLatencyRegression(t *testing.T) {
|
|
topic := "audit-events"
|
|
publishDelay := 100 * time.Millisecond
|
|
|
|
t.Run("synchronous response includes publish delay", func(t *testing.T) {
|
|
pub := &delayedPublisher{delay: publishDelay}
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
start := time.Now()
|
|
serveAuditRequest(t, handler)
|
|
|
|
assert.GreaterOrEqual(t, time.Since(start), publishDelay)
|
|
assert.Equal(t, uint64(1), pub.calls.Load())
|
|
})
|
|
|
|
t.Run("async response does not include publish delay", func(t *testing.T) {
|
|
pub := &delayedPublisher{delay: publishDelay}
|
|
asyncPublisher := NewAsyncPublisher(pub, topic, "test-app",
|
|
WithAsyncPublishingQueueCapacity(1),
|
|
WithAsyncPublishingWorkerCount(1),
|
|
)
|
|
defer func() {
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
}()
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil, WithEnabled(true), WithAsyncPublisher(asyncPublisher))(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
start := time.Now()
|
|
serveAuditRequest(t, handler)
|
|
|
|
assert.Less(t, time.Since(start), publishDelay/2)
|
|
require.Eventually(t, func() bool {
|
|
return asyncPublisher.Stats().Published == 1
|
|
}, time.Second, time.Millisecond)
|
|
assert.Equal(t, uint64(1), pub.calls.Load())
|
|
})
|
|
}
|
|
|
|
func TestMiddleware_WithAsyncPublishingUsesCallerManagedPublisher(t *testing.T) {
|
|
pub := &delayedPublisher{}
|
|
topic := "audit-events"
|
|
asyncPublisher := NewAsyncPublisher(pub, topic, "test-app",
|
|
WithAsyncPublishingQueueCapacity(1),
|
|
WithAsyncPublishingWorkerCount(1),
|
|
)
|
|
defer func() {
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
}()
|
|
|
|
handler := Middleware(pub, topic, "test-app", nil,
|
|
WithEnabled(true),
|
|
WithAsyncPublishing(asyncPublisher),
|
|
)(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}),
|
|
)
|
|
|
|
serveAuditRequest(t, handler)
|
|
|
|
require.Eventually(t, func() bool {
|
|
return pub.calls.Load() == 1
|
|
}, time.Second, time.Millisecond)
|
|
}
|
|
|
|
func TestAsyncPublisher_CapturesEventDateBeforeQueueDelay(t *testing.T) {
|
|
pub := newSequentialBlockingRecorderPublisher()
|
|
asyncPublisher := NewAsyncPublisher(pub, "audit-events", "test-app",
|
|
WithAsyncPublishingQueueCapacity(1),
|
|
WithAsyncPublishingWorkerCount(1),
|
|
)
|
|
defer func() {
|
|
pub.release()
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
}()
|
|
|
|
asyncPublisher.Publish(logging.TestingContext(), audit.Payload{ID: "first"})
|
|
pub.waitCalls(t, 1)
|
|
|
|
beforeSecondEnqueue := time.Now().UTC()
|
|
asyncPublisher.Publish(logging.TestingContext(), audit.Payload{ID: "second"})
|
|
time.Sleep(50 * time.Millisecond)
|
|
releaseTime := time.Now().UTC()
|
|
pub.release()
|
|
pub.waitCalls(t, 2)
|
|
|
|
msgs := pub.messages()
|
|
require.Len(t, msgs, 2)
|
|
|
|
var event publish.EventMessage
|
|
require.NoError(t, json.Unmarshal(msgs[1].Payload, &event))
|
|
assert.GreaterOrEqual(t, event.Date.UnixNano(), beforeSecondEnqueue.UnixNano())
|
|
assert.Less(t, event.Date.UnixNano(), releaseTime.UnixNano())
|
|
}
|
|
|
|
func TestAsyncPublisher_CloseRespectsContextTimeout(t *testing.T) {
|
|
pub := newBlockingPublisher()
|
|
asyncPublisher := NewAsyncPublisher(pub, "audit-events", "test-app",
|
|
WithAsyncPublishingQueueCapacity(1),
|
|
WithAsyncPublishingWorkerCount(1),
|
|
)
|
|
|
|
asyncPublisher.Publish(logging.TestingContext(), audit.Payload{ID: "payload-id"})
|
|
pub.waitStarted(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
|
defer cancel()
|
|
|
|
require.ErrorIs(t, asyncPublisher.Close(ctx), context.DeadlineExceeded)
|
|
|
|
pub.release()
|
|
require.NoError(t, asyncPublisher.Close(context.Background()))
|
|
}
|
|
|
|
func serveAuditRequest(t *testing.T, handler http.Handler) {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
|
req = req.WithContext(logging.TestingContext())
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
}
|
|
|
|
type blockingPublisher struct {
|
|
started chan struct{}
|
|
released chan struct{}
|
|
startedOnce sync.Once
|
|
releaseOnce sync.Once
|
|
calls atomic.Uint64
|
|
}
|
|
|
|
func newBlockingPublisher() *blockingPublisher {
|
|
return &blockingPublisher{
|
|
started: make(chan struct{}),
|
|
released: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (p *blockingPublisher) Publish(string, ...*message.Message) error {
|
|
p.calls.Add(1)
|
|
p.startedOnce.Do(func() {
|
|
close(p.started)
|
|
})
|
|
<-p.released
|
|
return nil
|
|
}
|
|
|
|
func (p *blockingPublisher) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (p *blockingPublisher) waitStarted(t *testing.T) {
|
|
t.Helper()
|
|
|
|
select {
|
|
case <-p.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("publisher was not called")
|
|
}
|
|
}
|
|
|
|
func (p *blockingPublisher) release() {
|
|
p.releaseOnce.Do(func() {
|
|
close(p.released)
|
|
})
|
|
}
|
|
|
|
type concurrentBlockingPublisher struct {
|
|
released chan struct{}
|
|
releaseOnce sync.Once
|
|
calls atomic.Uint64
|
|
inFlight atomic.Int64
|
|
maxInFlight atomic.Int64
|
|
}
|
|
|
|
func newConcurrentBlockingPublisher() *concurrentBlockingPublisher {
|
|
return &concurrentBlockingPublisher{
|
|
released: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (p *concurrentBlockingPublisher) Publish(string, ...*message.Message) error {
|
|
p.calls.Add(1)
|
|
inFlight := p.inFlight.Add(1)
|
|
for {
|
|
maxInFlight := p.maxInFlight.Load()
|
|
if inFlight <= maxInFlight || p.maxInFlight.CompareAndSwap(maxInFlight, inFlight) {
|
|
break
|
|
}
|
|
}
|
|
|
|
<-p.released
|
|
p.inFlight.Add(-1)
|
|
return nil
|
|
}
|
|
|
|
func (p *concurrentBlockingPublisher) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (p *concurrentBlockingPublisher) waitCalls(t *testing.T, calls uint64) {
|
|
t.Helper()
|
|
|
|
require.Eventually(t, func() bool {
|
|
return p.calls.Load() >= calls
|
|
}, time.Second, time.Millisecond)
|
|
}
|
|
|
|
func (p *concurrentBlockingPublisher) release() {
|
|
p.releaseOnce.Do(func() {
|
|
close(p.released)
|
|
})
|
|
}
|
|
|
|
type sequentialBlockingRecorderPublisher struct {
|
|
mu sync.Mutex
|
|
released chan struct{}
|
|
releaseOnce sync.Once
|
|
calls atomic.Uint64
|
|
messagesSet []*message.Message
|
|
}
|
|
|
|
func newSequentialBlockingRecorderPublisher() *sequentialBlockingRecorderPublisher {
|
|
return &sequentialBlockingRecorderPublisher{
|
|
released: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (p *sequentialBlockingRecorderPublisher) Publish(_ string, messages ...*message.Message) error {
|
|
call := p.calls.Add(1)
|
|
p.mu.Lock()
|
|
p.messagesSet = append(p.messagesSet, messages...)
|
|
p.mu.Unlock()
|
|
|
|
if call == 1 {
|
|
<-p.released
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *sequentialBlockingRecorderPublisher) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (p *sequentialBlockingRecorderPublisher) waitCalls(t *testing.T, calls uint64) {
|
|
t.Helper()
|
|
|
|
require.Eventually(t, func() bool {
|
|
return p.calls.Load() >= calls
|
|
}, time.Second, time.Millisecond)
|
|
}
|
|
|
|
func (p *sequentialBlockingRecorderPublisher) release() {
|
|
p.releaseOnce.Do(func() {
|
|
close(p.released)
|
|
})
|
|
}
|
|
|
|
func (p *sequentialBlockingRecorderPublisher) messages() []*message.Message {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
return append([]*message.Message(nil), p.messagesSet...)
|
|
}
|
|
|
|
type delayedPublisher struct {
|
|
delay time.Duration
|
|
calls atomic.Uint64
|
|
}
|
|
|
|
func (p *delayedPublisher) Publish(string, ...*message.Message) error {
|
|
p.calls.Add(1)
|
|
time.Sleep(p.delay)
|
|
return nil
|
|
}
|
|
|
|
func (p *delayedPublisher) Close() error {
|
|
return nil
|
|
}
|
|
|
|
type errorPublisher struct {
|
|
err error
|
|
calls atomic.Uint64
|
|
}
|
|
|
|
func (p *errorPublisher) Publish(string, ...*message.Message) error {
|
|
p.calls.Add(1)
|
|
return p.err
|
|
}
|
|
|
|
func (p *errorPublisher) Close() error {
|
|
return nil
|
|
}
|