request_body_parse_log_test.go 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. //go:build unit
  2. package handler
  3. import (
  4. "strings"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. "go.uber.org/zap"
  8. "go.uber.org/zap/zaptest/observer"
  9. )
  10. func newObservedLogger(t *testing.T) (*zap.Logger, *observer.ObservedLogs) {
  11. t.Helper()
  12. core, logs := observer.New(zap.WarnLevel)
  13. return zap.New(core), logs
  14. }
  15. func loggedFields(t *testing.T, logs *observer.ObservedLogs) map[string]any {
  16. t.Helper()
  17. entries := logs.All()
  18. require.Len(t, entries, 1)
  19. fields := map[string]any{}
  20. for _, f := range entries[0].Context {
  21. switch f.Key {
  22. case "body_len":
  23. fields[f.Key] = int(f.Integer)
  24. case "error":
  25. fields[f.Key] = f.Interface.(error).Error()
  26. default:
  27. fields[f.Key] = f.String
  28. }
  29. }
  30. return fields
  31. }
  32. func TestLogRequestBodyParseFailure_DerivesErrorWhenNil(t *testing.T) {
  33. log, logs := newObservedLogger(t)
  34. body := []byte(`{"model": bad}`)
  35. logRequestBodyParseFailure(log, body, nil)
  36. fields := loggedFields(t, logs)
  37. require.Equal(t, len(body), fields["body_len"])
  38. require.Contains(t, fields["error"], "invalid json")
  39. require.Contains(t, fields["error"], "offset=11")
  40. }
  41. func TestLogRequestBodyParseFailure_ShortBodyHasNoTail(t *testing.T) {
  42. log, logs := newObservedLogger(t)
  43. body := []byte(`{"broken":`)
  44. logRequestBodyParseFailure(log, body, nil)
  45. fields := loggedFields(t, logs)
  46. require.Contains(t, fields, "body_head")
  47. require.NotContains(t, fields, "body_tail")
  48. require.Contains(t, fields["body_head"].(string), `{\"broken\":`)
  49. }
  50. func TestLogRequestBodyParseFailure_LargeBodyBoundedSnippets(t *testing.T) {
  51. log, logs := newObservedLogger(t)
  52. // ~1MB body: head must show the structural prefix, tail the trailing bytes,
  53. // and neither snippet may exceed the configured bound (plus quoting overhead).
  54. body := []byte(`{"model":"claude-sonnet-4-6","big":"` + strings.Repeat("A", 1<<20) + `"`)
  55. logRequestBodyParseFailure(log, body, nil)
  56. fields := loggedFields(t, logs)
  57. require.Equal(t, len(body), fields["body_len"])
  58. head := fields["body_head"].(string)
  59. tail := fields["body_tail"].(string)
  60. require.Contains(t, head, "claude-sonnet-4-6")
  61. require.Contains(t, tail, "AAA")
  62. require.NotContains(t, tail, "claude-sonnet-4-6")
  63. // strconv.Quote adds surrounding quotes and escapes; 4x is a generous cap.
  64. require.LessOrEqual(t, len(head), parseFailureSnippetLen*4)
  65. require.LessOrEqual(t, len(tail), parseFailureSnippetLen*4)
  66. }
  67. func TestLogRequestBodyParseFailure_EscapesControlCharacters(t *testing.T) {
  68. log, logs := newObservedLogger(t)
  69. body := []byte("{\"model\":\x01\n\"x\"}")
  70. logRequestBodyParseFailure(log, body, nil)
  71. fields := loggedFields(t, logs)
  72. head := fields["body_head"].(string)
  73. require.NotContains(t, head, "\n")
  74. require.NotContains(t, head, "\x01")
  75. require.Contains(t, head, `\n`)
  76. require.Contains(t, head, `\x01`)
  77. }
  78. func TestLogRequestBodyParseFailure_NilLoggerNoPanic(t *testing.T) {
  79. require.NotPanics(t, func() {
  80. logRequestBodyParseFailure(nil, []byte(`{`), nil)
  81. })
  82. }