2026-07-18 00:11:18 +08:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestSanitizeOpsUpstreamErrorsForQueueBoundsAndRedacts(t *testing.T) {
|
|
|
|
|
entry := &OpsInsertErrorLogInput{}
|
|
|
|
|
for i := 0; i < 20; i++ {
|
2026-08-25 14:07:58 +08:00
|
|
|
proxyID := int64(i + 1)
|
2026-07-18 00:11:18 +08:00
|
|
|
entry.UpstreamErrors = append(entry.UpstreamErrors, &OpsUpstreamErrorEvent{
|
2026-08-25 14:07:58 +08:00
|
|
|
ProxyID: &proxyID,
|
|
|
|
|
ProxyName: "proxy-" + string(rune('a'+i)),
|
2026-07-18 00:11:18 +08:00
|
|
|
Platform: strings.Repeat("p", 100),
|
|
|
|
|
AccountName: strings.Repeat("a", 300),
|
|
|
|
|
UpstreamStatusCode: 500,
|
|
|
|
|
UpstreamURL: strings.Repeat("u", 3000),
|
|
|
|
|
UpstreamResponseBody: `{"authorization":"Bearer secret","message":"` + strings.Repeat("x", 10_000) + `"}`,
|
|
|
|
|
Message: strings.Repeat("m", 3000),
|
|
|
|
|
Detail: `{"api_key":"secret","detail":"` + strings.Repeat("y", 10_000) + `"}`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if entry.UpstreamErrors != nil {
|
|
|
|
|
t.Fatal("raw upstream event slice must be released before queueing")
|
|
|
|
|
}
|
|
|
|
|
if entry.UpstreamErrorsJSON == nil {
|
|
|
|
|
t.Fatal("sanitized upstream event JSON is missing")
|
|
|
|
|
}
|
|
|
|
|
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-08-25 14:07:58 +08:00
|
|
|
if len(events) != 20 {
|
|
|
|
|
t.Fatalf("event count = %d, want 20", len(events))
|
2026-07-18 00:11:18 +08:00
|
|
|
}
|
2026-08-25 14:07:58 +08:00
|
|
|
for i, event := range events {
|
|
|
|
|
if event.ProxyID == nil || *event.ProxyID != int64(i+1) {
|
|
|
|
|
t.Fatalf("event %d proxy id = %v, want %d", i, event.ProxyID, i+1)
|
|
|
|
|
}
|
2026-07-18 00:11:18 +08:00
|
|
|
if len(event.Platform) > 32 || len(event.AccountName) > 128 || len(event.UpstreamURL) > 2048 || len(event.Message) > 2048 {
|
|
|
|
|
t.Fatalf("event fields were not bounded: %+v", event)
|
|
|
|
|
}
|
2026-09-03 15:11:36 +08:00
|
|
|
if i < 4 && (len(event.UpstreamURL) > opsUpstreamErrorsOlderURLMaxLen || len(event.Message) > opsUpstreamErrorsOlderMessageMaxLen) {
|
|
|
|
|
t.Fatalf("older event %d kept a full-size url/message: url=%d message=%d", i, len(event.UpstreamURL), len(event.Message))
|
|
|
|
|
}
|
|
|
|
|
if event.DroppedEarlierAttempts != 0 {
|
|
|
|
|
t.Fatalf("event %d reported dropped attempts without any drop: %d", i, event.DroppedEarlierAttempts)
|
|
|
|
|
}
|
2026-07-18 00:11:18 +08:00
|
|
|
if len(event.UpstreamResponseBody) > OpsErrorLogQueueBodyMaxBytes || len(event.Detail) > OpsErrorLogQueueBodyMaxBytes {
|
|
|
|
|
t.Fatal("event body/detail exceeded queue limit")
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(event.UpstreamResponseBody, "Bearer secret") || strings.Contains(event.Detail, `"secret"`) {
|
|
|
|
|
t.Fatal("credential material was not redacted")
|
|
|
|
|
}
|
2026-08-25 14:07:58 +08:00
|
|
|
if i < 4 && (event.UpstreamResponseBody != "" || event.Detail != "") {
|
|
|
|
|
t.Fatal("older event retained a large body outside the queue body window")
|
|
|
|
|
}
|
2026-07-18 00:11:18 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-03 15:11:36 +08:00
|
|
|
|
|
|
|
|
func TestSanitizeOpsUpstreamErrorsForQueueHardBoundsEventCount(t *testing.T) {
|
|
|
|
|
entry := &OpsInsertErrorLogInput{}
|
|
|
|
|
total := opsUpstreamErrorsMaxEvents + 40
|
|
|
|
|
for i := 0; i < total; i++ {
|
|
|
|
|
entry.UpstreamErrors = append(entry.UpstreamErrors, &OpsUpstreamErrorEvent{
|
|
|
|
|
AtUnixMs: int64(i + 1),
|
|
|
|
|
ProxyName: opsProxyNameDirect,
|
|
|
|
|
UpstreamStatusCode: 500,
|
|
|
|
|
Message: "attempt",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(events) != opsUpstreamErrorsMaxEvents {
|
|
|
|
|
t.Fatalf("event count = %d, want %d", len(events), opsUpstreamErrorsMaxEvents)
|
|
|
|
|
}
|
|
|
|
|
// Newest attempts win; the oldest retained one carries the drop count.
|
|
|
|
|
if events[len(events)-1].AtUnixMs != int64(total) {
|
|
|
|
|
t.Fatalf("newest event lost: at=%d want %d", events[len(events)-1].AtUnixMs, total)
|
|
|
|
|
}
|
|
|
|
|
if events[0].AtUnixMs != int64(total-opsUpstreamErrorsMaxEvents+1) {
|
|
|
|
|
t.Fatalf("oldest retained at=%d", events[0].AtUnixMs)
|
|
|
|
|
}
|
|
|
|
|
if events[0].DroppedEarlierAttempts != 40 {
|
|
|
|
|
t.Fatalf("dropped_earlier_attempts = %d, want 40", events[0].DroppedEarlierAttempts)
|
|
|
|
|
}
|
|
|
|
|
if events[1].DroppedEarlierAttempts != 0 {
|
|
|
|
|
t.Fatal("drop count must only be stamped on the oldest retained event")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSanitizeOpsUpstreamErrorsForQueueHardBoundsSerializedBytes(t *testing.T) {
|
|
|
|
|
entry := &OpsInsertErrorLogInput{}
|
|
|
|
|
// Every bounded field at its cap: the 16 newest attempts weigh ~20KB each
|
|
|
|
|
// (8KB body + 8KB detail + 2KB url + 2KB message), older ones ~2KB. At the
|
|
|
|
|
// count cap that is well past the byte budget, so bytes must win here.
|
|
|
|
|
const total = opsUpstreamErrorsMaxEvents
|
|
|
|
|
for i := 0; i < total; i++ {
|
|
|
|
|
entry.UpstreamErrors = append(entry.UpstreamErrors, &OpsUpstreamErrorEvent{
|
2026-09-03 15:23:22 +08:00
|
|
|
AtUnixMs: int64(i + 1),
|
|
|
|
|
Platform: strings.Repeat("p", 40),
|
|
|
|
|
AccountName: strings.Repeat("a", 200),
|
|
|
|
|
ProxyName: strings.Repeat("n", 200),
|
|
|
|
|
UpstreamRequestID: strings.Repeat("r", 200),
|
|
|
|
|
UpstreamStatusCode: 502,
|
|
|
|
|
UpstreamURL: "https://example.com/" + strings.Repeat("u", 3000),
|
2026-09-03 15:11:36 +08:00
|
|
|
// Plain-text payloads are truncated, not JSON-trimmed, so they keep
|
|
|
|
|
// their full queue-limit weight.
|
|
|
|
|
UpstreamResponseBody: strings.Repeat("b", 10_000),
|
|
|
|
|
Kind: strings.Repeat("k", 80),
|
|
|
|
|
Stage: strings.Repeat("s", 80),
|
|
|
|
|
Scope: strings.Repeat("c", 80),
|
|
|
|
|
Reason: strings.Repeat("e", 200),
|
|
|
|
|
Message: strings.Repeat("m", 3000),
|
|
|
|
|
Detail: strings.Repeat("d", 10_000),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// The newest event is always kept even when it alone exceeds the remaining
|
|
|
|
|
// budget, so allow one max-size event of slack.
|
|
|
|
|
if len(*entry.UpstreamErrorsJSON) > opsUpstreamErrorsQueueMaxBytes+24*1024 {
|
|
|
|
|
t.Fatalf("serialized upstream_errors = %d bytes, budget %d", len(*entry.UpstreamErrorsJSON), opsUpstreamErrorsQueueMaxBytes)
|
|
|
|
|
}
|
|
|
|
|
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(events) == 0 || len(events) >= total {
|
|
|
|
|
t.Fatalf("expected the byte budget to drop some events, kept %d of %d", len(events), total)
|
|
|
|
|
}
|
|
|
|
|
if events[len(events)-1].AtUnixMs != int64(total) {
|
|
|
|
|
t.Fatal("newest attempt must always be retained")
|
|
|
|
|
}
|
|
|
|
|
if events[0].DroppedEarlierAttempts != total-len(events) {
|
|
|
|
|
t.Fatalf("dropped_earlier_attempts = %d, want %d", events[0].DroppedEarlierAttempts, total-len(events))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSanitizeOpsUpstreamErrorsForQueueDropsEmptyButKeepsOlderTrimmedEvents(t *testing.T) {
|
|
|
|
|
entry := &OpsInsertErrorLogInput{}
|
|
|
|
|
// 20 events: the first 4 fall outside the body window. Event 0 is a real
|
|
|
|
|
// attempt whose only payload is a detail (status 0, no message); it must
|
|
|
|
|
// survive the window even though its detail is cleared. Event 1 is truly
|
|
|
|
|
// empty and must be dropped.
|
|
|
|
|
for i := 0; i < 20; i++ {
|
|
|
|
|
ev := &OpsUpstreamErrorEvent{AtUnixMs: int64(i + 1), ProxyName: opsProxyNameDirect}
|
|
|
|
|
switch i {
|
|
|
|
|
case 0:
|
|
|
|
|
ev.Detail = "proxy handshake failed"
|
|
|
|
|
case 1:
|
|
|
|
|
// fully empty
|
|
|
|
|
default:
|
|
|
|
|
ev.UpstreamStatusCode = 500
|
|
|
|
|
}
|
|
|
|
|
entry.UpstreamErrors = append(entry.UpstreamErrors, ev)
|
|
|
|
|
}
|
|
|
|
|
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(events) != 19 {
|
|
|
|
|
t.Fatalf("event count = %d, want 19", len(events))
|
|
|
|
|
}
|
|
|
|
|
if events[0].AtUnixMs != 1 || events[0].Detail != "" {
|
|
|
|
|
t.Fatalf("detail-only older attempt must be kept with detail cleared: %+v", events[0])
|
|
|
|
|
}
|
|
|
|
|
if events[1].AtUnixMs != 3 {
|
|
|
|
|
t.Fatalf("fully-empty attempt must be dropped, got at=%d", events[1].AtUnixMs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSanitizeOpsUpstreamErrorsForQueueIgnoresCallerSuppliedDropCount(t *testing.T) {
|
|
|
|
|
entry := &OpsInsertErrorLogInput{UpstreamErrors: []*OpsUpstreamErrorEvent{
|
|
|
|
|
{UpstreamStatusCode: 500, ProxyName: opsProxyNameDirect, DroppedEarlierAttempts: 99},
|
|
|
|
|
}}
|
|
|
|
|
if err := SanitizeOpsUpstreamErrorsForQueue(entry); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
events, err := ParseOpsUpstreamErrors(*entry.UpstreamErrorsJSON)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(events) != 1 || events[0].DroppedEarlierAttempts != 0 {
|
|
|
|
|
t.Fatalf("caller-supplied drop count must be reset: %+v", events[0])
|
|
|
|
|
}
|
|
|
|
|
}
|