pkg/util_test.go
Rama main · réplica de lectura del repositorio original.
package pkg
import (
"bytes"
"compress/gzip"
"context"
"crypto/rand"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/andybalholm/brotli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.keploy.io/server/v3/internal/testnet"
"go.keploy.io/server/v3/pkg/models"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestNextIDIgnoresOtherPrefixes(t *testing.T) {
tests := []struct {
name string
ids []string
identifier string
want string
}{
{
name: "custom metadata name with numeric suffix does not affect test-set sequence",
ids: []string{"test-set-0", "checkout-flow-999"},
identifier: models.TestSetPattern,
want: "test-set-1",
},
{
name: "test-set IDs do not affect test-run sequence",
ids: []string{"test-run-2", "test-set-99"},
identifier: models.TestRunTemplateName,
want: "test-run-3",
},
{
name: "invalid suffix is ignored",
ids: []string{"test-set-0", "test-set-0 copy", "test-set-final"},
identifier: models.TestSetPattern,
want: "test-set-1",
},
{
name: "signed suffixes are not indices",
ids: []string{"test-set-0", "test-set--1", "test-set-+5"},
identifier: models.TestSetPattern,
want: "test-set-1",
},
{
name: "empty set starts at zero",
ids: nil,
identifier: models.TestSetPattern,
want: "test-set-0",
},
{
name: "bare prefix is not an index",
ids: []string{"test-set-"},
identifier: models.TestSetPattern,
want: "test-set-0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, NextID(tt.ids, tt.identifier))
})
}
}
func TestLastIDIgnoresOtherPrefixes(t *testing.T) {
tests := []struct {
name string
ids []string
identifier string
want string
}{
{
name: "custom metadata name with numeric suffix does not affect latest test run",
ids: []string{"test-run-2", "checkout-flow-999"},
identifier: models.TestRunTemplateName,
want: "test-run-2",
},
{
name: "test runs do not affect latest test set",
ids: []string{"test-set-1", "test-run-99"},
identifier: models.TestSetPattern,
want: "test-set-1",
},
{
name: "invalid suffix is ignored",
ids: []string{"test-run-3", "test-run-3 copy", "test-run-final"},
identifier: models.TestRunTemplateName,
want: "test-run-3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, LastID(tt.ids, tt.identifier))
})
}
}
// TestSimulateHTTP_NewRequestError_303 ensures that SimulateHTTP returns an error
// when http.NewRequestWithContext fails. This is triggered by providing an invalid
// HTTP method string in the test case.
func TestSimulateHTTP_NewRequestError_303(t *testing.T) {
// Arrange
ctx := context.Background()
logger := zap.NewNop()
tc := &models.TestCase{
Name: "test-case-new-request-error",
HTTPReq: models.HTTPReq{
Method: "INVALID METHOD", // Invalid method
URL: "http://example.com/test",
Body: `{"key":"value"}`,
},
}
// Act
resp, err := SimulateHTTP(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 10})
// Assert
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid method")
assert.Nil(t, resp)
}
func TestSimulateHTTP_MultipartRebuildWithPaths_314(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
// The file is kept in the recording, and named from its directory: a
// test case's file is read only there (ReadRecordingFile).
recording := t.TempDir()
tempFile, err := os.CreateTemp(recording, "keploy-multipart-test-*.txt")
require.NoError(t, err)
_, err = tempFile.WriteString("file-data")
require.NoError(t, err)
require.NoError(t, tempFile.Close())
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Errorf("failed to parse media type: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
assert.Equal(t, "multipart/form-data", mediaType)
if params["boundary"] == "" {
t.Errorf("missing multipart boundary")
w.WriteHeader(http.StatusBadRequest)
return
}
assert.NotEqual(t, "test-boundary", params["boundary"])
if cl := r.Header.Get("Content-Length"); cl != "" {
assert.NotEqual(t, "1", cl)
}
reader := multipart.NewReader(r.Body, params["boundary"])
fields := map[string]string{}
files := map[string]string{}
fileNames := map[string]string{}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Errorf("failed to read multipart part: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
name := part.FormName()
data, err := io.ReadAll(part)
if err != nil {
t.Errorf("failed to read multipart part data: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if part.FileName() != "" {
files[name] = string(data)
fileNames[name] = part.FileName()
continue
}
fields[name] = string(data)
}
assert.Equal(t, "text-value", fields["text"])
assert.Equal(t, "file-data", files["upload"])
assert.Equal(t, filepath.Base(tempFile.Name()), fileNames["upload"])
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
tc := &models.TestCase{
Name: "multipart-paths",
HTTPReq: models.HTTPReq{
Method: "POST",
URL: server.URL,
Header: map[string]string{
"Content-Type": "multipart/form-data; boundary=test-boundary",
"Content-Length": "1",
},
Form: []models.FormData{
{
Key: "text",
Values: []string{"text-value"},
},
{
Key: "upload",
Paths: []string{filepath.Base(tempFile.Name())},
},
},
},
}
resp, err := SimulateHTTP(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 10, KeployPath: recording})
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestSimulateHTTP_MultipartRebuildWithFileNames_315(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Errorf("failed to parse media type: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
assert.Equal(t, "multipart/form-data", mediaType)
if params["boundary"] == "" {
t.Errorf("missing multipart boundary")
w.WriteHeader(http.StatusBadRequest)
return
}
assert.NotEqual(t, "legacy-boundary", params["boundary"])
reader := multipart.NewReader(r.Body, params["boundary"])
fields := map[string]string{}
files := map[string]string{}
fileNames := map[string]string{}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Errorf("failed to read multipart part: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
name := part.FormName()
data, err := io.ReadAll(part)
if err != nil {
t.Errorf("failed to read multipart part data: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if part.FileName() != "" {
files[name] = string(data)
fileNames[name] = part.FileName()
continue
}
fields[name] = string(data)
}
assert.Equal(t, "text-value", fields["text"])
assert.Equal(t, "binary-content", files["payload"])
assert.Equal(t, "blob.bin", fileNames["payload"])
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
tc := &models.TestCase{
Name: "multipart-filenames",
HTTPReq: models.HTTPReq{
Method: "POST",
URL: server.URL,
Header: map[string]string{
"Content-Type": "multipart/form-data; boundary=legacy-boundary",
},
Form: []models.FormData{
{
Key: "text",
Values: []string{"text-value"},
},
{
Key: "payload",
Values: []string{"binary-content"},
FileNames: []string{"blob.bin"},
},
},
},
}
resp, err := SimulateHTTP(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 10})
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestSimulateHTTP_SSEStreamMatchAndEarlyClose_316(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
serverClosed := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
flusher, ok := w.(http.Flusher)
require.True(t, ok, "server response writer must support flushing")
_, _ = w.Write([]byte("id:100\nevent:ticker\ndata:{\"value\":1}\n\n"))
flusher.Flush()
_, _ = w.Write([]byte("id:101\nevent:ticker\ndata:{\"value\":2}\n\n"))
flusher.Flush()
_, _ = w.Write([]byte("id:102\nevent:ticker\ndata:{\"value\":3}\n\n"))
flusher.Flush()
<-r.Context().Done()
close(serverClosed)
}))
defer server.Close()
expectedSSEBody := strings.Join([]string{
"id:100",
"event:ticker",
"data:{\"value\":1}",
"",
"id:101",
"event:ticker",
"data:{\"value\":2}",
"",
}, "\n")
tc := &models.TestCase{
Name: "sse-match-and-close",
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
Header: map[string]string{
"Accept": "text/event-stream",
},
},
HTTPResp: models.HTTPResp{
Header: map[string]string{
"Content-Type": "text/event-stream; charset=utf-8",
},
Body: expectedSSEBody,
StreamBody: []models.HTTPStreamChunk{
{
Data: []models.HTTPStreamDataField{
{Key: "id", Value: "100"},
{Key: "event", Value: "ticker"},
{Key: "data", Value: `{"value":1}`},
},
},
{
Data: []models.HTTPStreamDataField{
{Key: "id", Value: "101"},
{Key: "event", Value: "ticker"},
{Key: "data", Value: `{"value":2}`},
},
},
},
},
}
// Use SimulateHTTPStreaming for streaming test cases
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 3})
require.NoError(t, err)
require.NotNil(t, streamResp)
// Compare the stream
noiseKeys := map[string]struct{}{}
matched, capturedBody, _, compareErr := CompareHTTPStream(tc.HTTPResp, streamResp.Reader, streamResp.StreamConfig, noiseKeys, logger)
streamResp.Reader.Close()
require.NoError(t, compareErr)
require.True(t, matched)
respBody := tc.HTTPResp.Body
if !matched {
respBody = capturedBody
}
assert.Equal(t, http.StatusOK, streamResp.StatusCode)
assert.Equal(t, expectedSSEBody, respBody)
select {
case <-serverClosed:
case <-time.After(1 * time.Second):
t.Fatal("expected server stream to be closed by client after matching SSE queue")
}
}
func TestSimulateHTTP_SSEStreamMismatch_317(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
require.True(t, ok, "server response writer must support flushing")
_, _ = w.Write([]byte("id:1\nevent:update\ndata:{\"value\":1}\n\n"))
flusher.Flush()
_, _ = w.Write([]byte("id:2\nevent:update\ndata:{\"value\":999}\n\n"))
flusher.Flush()
}))
defer server.Close()
tc := &models.TestCase{
Name: "sse-mismatch",
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
Header: map[string]string{
"Accept": "text/event-stream",
},
},
HTTPResp: models.HTTPResp{
Header: map[string]string{
"Content-Type": "text/event-stream",
},
Body: strings.Join([]string{
"id:1",
"event:update",
"data:{\"value\":1}",
"",
"id:2",
"event:update",
"data:{\"value\":2}",
"",
}, "\n"),
StreamBody: []models.HTTPStreamChunk{
{
Data: []models.HTTPStreamDataField{
{Key: "id", Value: "1"},
{Key: "event", Value: "update"},
{Key: "data", Value: `{"value":1}`},
},
},
{
Data: []models.HTTPStreamDataField{
{Key: "id", Value: "2"},
{Key: "event", Value: "update"},
{Key: "data", Value: `{"value":2}`},
},
},
},
},
}
// Use SimulateHTTPStreaming for streaming test cases
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 3})
require.NoError(t, err)
require.NotNil(t, streamResp)
// Compare the stream - should NOT match due to value mismatch
noiseKeys := map[string]struct{}{}
matched, capturedBody, _, compareErr := CompareHTTPStream(tc.HTTPResp, streamResp.Reader, streamResp.StreamConfig, noiseKeys, logger)
streamResp.Reader.Close()
require.NoError(t, compareErr)
respBody := capturedBody
if matched {
respBody = tc.HTTPResp.Body
}
assert.Equal(t, http.StatusOK, streamResp.StatusCode)
assert.NotEqual(t, tc.HTTPResp.Body, respBody)
assert.Contains(t, respBody, "999")
}
func TestSimulateHTTP_SSEStreamMatch_WithStructuredExpectedBody_317A(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
require.True(t, ok, "server response writer must support flushing")
_, _ = w.Write([]byte("id:1\nevent:update\ndata:{\"value\":1}\n\n"))
flusher.Flush()
_, _ = w.Write([]byte("id:2\nevent:update\ndata:{\"value\":2}\n\n"))
flusher.Flush()
}))
defer server.Close()
tc := &models.TestCase{
Name: "sse-structured-expected",
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
Header: map[string]string{
"Accept": "text/event-stream",
},
},
HTTPResp: models.HTTPResp{
Header: map[string]string{
"Content-Type": "text/event-stream",
},
// This intentionally does not match the streamed frames. The stream
// comparator must use HTTPResp.StreamBody when present.
Body: "legacy-body-not-used-for-stream-compare",
StreamBody: []models.HTTPStreamChunk{
{
Data: []models.HTTPStreamDataField{
{Key: "id", Value: "1"},
{Key: "event", Value: "update"},
{Key: "data", Value: `{"value":1}`},
},
},
{
Data: []models.HTTPStreamDataField{
{Key: "id", Value: "2"},
{Key: "event", Value: "update"},
{Key: "data", Value: `{"value":2}`},
},
},
},
},
}
// Use SimulateHTTPStreaming for streaming test cases
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 3})
require.NoError(t, err)
require.NotNil(t, streamResp)
// Compare the stream
noiseKeys := map[string]struct{}{}
matched, _, _, compareErr := CompareHTTPStream(tc.HTTPResp, streamResp.Reader, streamResp.StreamConfig, noiseKeys, logger)
streamResp.Reader.Close()
require.NoError(t, compareErr)
require.True(t, matched)
// When matched, use tc.HTTPResp.Body (legacy body)
respBody := tc.HTTPResp.Body
assert.Equal(t, http.StatusOK, streamResp.StatusCode)
assert.Equal(t, tc.HTTPResp.Body, respBody)
}
func TestCanonicalizeSSEFrame_318(t *testing.T) {
input := "id: 100\nevent: system-alert\ndata: {\"active\": true, \"user\" : \"alice\"}\n"
got := canonicalizeSSEFrame(input)
assert.Equal(t, "id:100\nevent:system-alert\ndata:{\"active\":true,\"user\":\"alice\"}", got)
}
func TestSimulateHTTP_NDJSONStreamMatch_WithStructuredExpectedBody_318A(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
flusher, ok := w.(http.Flusher)
require.True(t, ok, "server response writer must support flushing")
_, _ = w.Write([]byte("{\"id\":1,\"ok\":true}\n"))
flusher.Flush()
_, _ = w.Write([]byte("{\"id\":2,\"ok\":false}\n"))
flusher.Flush()
}))
defer server.Close()
tc := &models.TestCase{
Name: "ndjson-structured-expected",
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
},
HTTPResp: models.HTTPResp{
Header: map[string]string{
"Content-Type": "application/x-ndjson",
},
Body: "legacy-body-not-used-for-stream-compare",
StreamBody: []models.HTTPStreamChunk{
{
Data: []models.HTTPStreamDataField{
{Key: "raw", Value: `{"id":1,"ok":true}`},
},
},
{
Data: []models.HTTPStreamDataField{
{Key: "raw", Value: `{"id":2,"ok":false}`},
},
},
},
},
}
// Use SimulateHTTPStreaming for streaming test cases
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 3})
require.NoError(t, err)
require.NotNil(t, streamResp)
// Compare the stream
noiseKeys := map[string]struct{}{}
matched, _, _, compareErr := CompareHTTPStream(tc.HTTPResp, streamResp.Reader, streamResp.StreamConfig, noiseKeys, logger)
streamResp.Reader.Close()
require.NoError(t, compareErr)
require.True(t, matched)
// When matched, use tc.HTTPResp.Body (legacy body)
respBody := tc.HTTPResp.Body
assert.Equal(t, http.StatusOK, streamResp.StatusCode)
assert.Equal(t, tc.HTTPResp.Body, respBody)
}
func TestSimulateHTTP_NDJSONStreamMatchAndEarlyClose_319(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
serverClosed := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
flusher, ok := w.(http.Flusher)
require.True(t, ok, "server response writer must support flushing")
_, _ = w.Write([]byte("{\"id\":1,\"ok\":true}\n"))
flusher.Flush()
_, _ = w.Write([]byte("{\"id\":2,\"ok\":false}\n"))
flusher.Flush()
_, _ = w.Write([]byte("{\"id\":3,\"ok\":true}\n"))
flusher.Flush()
<-r.Context().Done()
close(serverClosed)
}))
defer server.Close()
expectedBody := "{\"id\":1,\"ok\":true}\n{\"id\":2,\"ok\":false}\n"
tc := &models.TestCase{
Name: "ndjson-match-and-close",
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
},
HTTPResp: models.HTTPResp{
Header: map[string]string{
"Content-Type": "application/x-ndjson",
},
Body: expectedBody,
StreamBody: []models.HTTPStreamChunk{
{
Data: []models.HTTPStreamDataField{
{Key: "raw", Value: `{"id":1,"ok":true}`},
},
},
{
Data: []models.HTTPStreamDataField{
{Key: "raw", Value: `{"id":2,"ok":false}`},
},
},
},
},
}
// Use SimulateHTTPStreaming for streaming test cases
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 3})
require.NoError(t, err)
require.NotNil(t, streamResp)
// Compare the stream
noiseKeys := map[string]struct{}{}
matched, _, _, compareErr := CompareHTTPStream(tc.HTTPResp, streamResp.Reader, streamResp.StreamConfig, noiseKeys, logger)
streamResp.Reader.Close()
require.NoError(t, compareErr)
require.True(t, matched)
respBody := expectedBody
if !matched {
respBody = ""
}
assert.Equal(t, http.StatusOK, streamResp.StatusCode)
assert.Equal(t, expectedBody, respBody)
select {
case <-serverClosed:
case <-time.After(1 * time.Second):
t.Fatal("expected NDJSON stream to be closed by client after matching queue")
}
}
func TestSimulateHTTP_MultipartStreamMatchAndEarlyClose_320(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
const boundary = "myCustomBoundary"
writePart := func(w http.ResponseWriter, contentType string, body string) {
_, _ = w.Write([]byte("--" + boundary + "\r\n"))
_, _ = w.Write([]byte("Content-Type: " + contentType + "\r\n\r\n"))
_, _ = w.Write([]byte(body))
_, _ = w.Write([]byte("\r\n"))
}
serverClosed := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+boundary)
flusher, ok := w.(http.Flusher)
require.True(t, ok, "server response writer must support flushing")
writePart(w, "application/json", `{"frame":1}`)
flusher.Flush()
writePart(w, "text/plain", "frame-2")
flusher.Flush()
writePart(w, "text/plain", "frame-3")
flusher.Flush()
<-r.Context().Done()
close(serverClosed)
}))
defer server.Close()
expectedBody := strings.Join([]string{
"--" + boundary,
"Content-Type: application/json",
"",
`{"frame":1}`,
"--" + boundary,
"Content-Type: text/plain",
"",
"frame-2",
"",
}, "\r\n")
tc := &models.TestCase{
Name: "multipart-match-and-close",
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
},
HTTPResp: models.HTTPResp{
Header: map[string]string{
"Content-Type": "multipart/x-mixed-replace; boundary=" + boundary,
},
Body: expectedBody,
StreamBody: []models.HTTPStreamChunk{
{
Data: []models.HTTPStreamDataField{
{Key: "raw", Value: expectedBody},
},
},
},
},
}
// Use SimulateHTTPStreaming for streaming test cases
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 3})
require.NoError(t, err)
require.NotNil(t, streamResp)
// Compare the stream
noiseKeys := map[string]struct{}{}
matched, _, _, compareErr := CompareHTTPStream(tc.HTTPResp, streamResp.Reader, streamResp.StreamConfig, noiseKeys, logger)
streamResp.Reader.Close()
require.NoError(t, compareErr)
require.True(t, matched)
respBody := expectedBody
if !matched {
respBody = ""
}
assert.Equal(t, http.StatusOK, streamResp.StatusCode)
assert.Equal(t, expectedBody, respBody)
select {
case <-serverClosed:
case <-time.After(1 * time.Second):
t.Fatal("expected multipart stream to be closed by client after matching queue")
}
}
func TestSimulateHTTP_PlainTextStreamMatchAndEarlyClose_321(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
serverClosed := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
flusher, ok := w.(http.Flusher)
require.True(t, ok, "server response writer must support flushing")
_, _ = w.Write([]byte("[INFO] booting\n"))
flusher.Flush()
_, _ = w.Write([]byte("[INFO] ready\n"))
flusher.Flush()
_, _ = w.Write([]byte("[INFO] keep-running\n"))
flusher.Flush()
<-r.Context().Done()
close(serverClosed)
}))
defer server.Close()
expectedBody := "[INFO] booting\n[INFO] ready\n"
tc := &models.TestCase{
Name: "plain-stream-match-and-close",
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
},
HTTPResp: models.HTTPResp{
Header: map[string]string{
"Content-Type": "text/plain",
},
Body: expectedBody,
StreamBody: []models.HTTPStreamChunk{
{
Data: []models.HTTPStreamDataField{
{Key: "raw", Value: "[INFO] booting"},
},
},
{
Data: []models.HTTPStreamDataField{
{Key: "raw", Value: "[INFO] ready"},
},
},
},
},
}
// Use SimulateHTTPStreaming for streaming test cases
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 3})
require.NoError(t, err)
require.NotNil(t, streamResp)
// Compare the stream
noiseKeys := map[string]struct{}{}
matched, _, _, compareErr := CompareHTTPStream(tc.HTTPResp, streamResp.Reader, streamResp.StreamConfig, noiseKeys, logger)
streamResp.Reader.Close()
require.NoError(t, compareErr)
require.True(t, matched)
respBody := expectedBody
if !matched {
respBody = ""
}
assert.Equal(t, http.StatusOK, streamResp.StatusCode)
assert.Equal(t, expectedBody, respBody)
select {
case <-serverClosed:
case <-time.After(1 * time.Second):
t.Fatal("expected plain text stream to be closed by client after matching queue")
}
}
func TestCompareSSEFrame_DataJSONTypeMismatch_322(t *testing.T) {
logger := zap.NewNop()
match, reason := compareSSEFrame(
"data:{\"value\":1}",
"data:not-json",
nil,
logger,
)
assert.False(t, match)
assert.Equal(t, "data-json-type mismatch", reason)
}
func TestCompareSSEFields_MultilineDataNormalization_322A(t *testing.T) {
logger := zap.NewNop()
expected := []sseField{
{key: "id", value: "1", hasValue: true},
{key: "data", value: "line-1\nline-2", hasValue: true},
}
actual := parseSSEFrame("id:1\ndata:line-1\ndata:line-2")
match, reason := compareSSEFields(expected, actual, nil, logger)
assert.True(t, match, "multiline SSE data should compare equal after normalization")
assert.Equal(t, "", reason)
}
func TestComputeStreamingTimeoutSeconds_323(t *testing.T) {
now := time.Now().UTC()
tc := &models.TestCase{
HTTPReq: models.HTTPReq{
Timestamp: now,
},
HTTPResp: models.HTTPResp{
Timestamp: now.Add(1500 * time.Millisecond),
},
}
timeout := ComputeStreamingTimeoutSeconds(tc, 2)
assert.Equal(t, uint64(12), timeout)
preferConfigured := ComputeStreamingTimeoutSeconds(tc, 30)
assert.Equal(t, uint64(30), preferConfigured)
fallback := ComputeStreamingTimeoutSeconds(&models.TestCase{}, 7)
assert.Equal(t, uint64(7), fallback)
defaultMin := ComputeStreamingTimeoutSeconds(&models.TestCase{}, 0)
assert.Equal(t, uint64(10), defaultMin)
}
// TestIsTime_VariousFormats_808 covers multiple scenarios for the IsTime function,
// including standard date formats (RFC3339, UnixDate), numeric timestamps as strings,
// and invalid inputs to ensure it correctly identifies time-like strings.
func TestIsTime_VariousFormats_808(t *testing.T) {
testCases := []struct {
name string
input string
expected bool
}{
{"RFC3339", "2023-01-17T16:34:58Z", true},
{"UnixDate", "Tue Jan 17 16:34:58 UTC 2023", true},
{"NumericTimestamp", fmt.Sprintf("%f", float64(time.Now().UnixNano())), true},
{"AlmostNow", fmt.Sprintf("%f", float64(time.Now().Add(-time.Hour).UnixNano())), true},
{"TooOldNumeric", fmt.Sprintf("%f", float64(time.Now().Add(-48*time.Hour).UnixNano())), false},
{"InvalidString", "not a date", false},
{"EmptyString", "", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, IsTime(tc.input))
})
}
}
// TestToHTTPHeader_WithTimeValue_909 verifies that the ToHTTPHeader function correctly
// converts a map of strings to an http.Header object. It specifically checks that
// header values recognized as timestamps are not split by commas, while other
// comma-separated values are correctly split into slices.
func TestToHTTPHeader_WithTimeValue_909(t *testing.T) {
// Arrange
mockHeader := map[string]string{
"Date": "Tue, 17 Jan 2023 16:34:58 IST",
"X-Custom-Header": "value1,value2",
"Content-Type": "application/json",
}
// Act
httpHeader := ToHTTPHeader(mockHeader)
// Assert: every YAML key replays as exactly one wire header. The
// recorded value is kept intact (commas included) so axios-style
// `Accept: a, b, c` does not fan out into three duplicate-name
// wire headers — see ToHTTPHeader's comment for why fan-out
// breaks `dict(**request.headers)`-style server middlewares.
require.NotNil(t, httpHeader)
assert.Equal(t, []string{"Tue, 17 Jan 2023 16:34:58 IST"}, httpHeader["Date"])
assert.Equal(t, []string{"value1,value2"}, httpHeader["X-Custom-Header"])
assert.Equal(t, []string{"application/json"}, httpHeader["Content-Type"])
}
// TestToHTTPHeader_AcceptListValue_FoldedSingleHeader pins the wire
// behaviour that the duplicate-Accept TypeError fix depends on: an
// axios-style `Accept: application/json, text/plain, */*` mock must
// replay as ONE wire header so server middlewares using
// `dict(**request.headers)` do not see duplicate `accept` keys.
func TestToHTTPHeader_AcceptListValue_FoldedSingleHeader(t *testing.T) {
mockHeader := map[string]string{
"Accept": "application/json, text/plain, */*",
"Accept-Encoding": "gzip, compress, deflate, br",
"Baggage": "trace-id=abc,user-key=def,env=test",
}
httpHeader := ToHTTPHeader(mockHeader)
require.Len(t, httpHeader["Accept"], 1, "Accept must replay as one wire header")
require.Len(t, httpHeader["Accept-Encoding"], 1, "Accept-Encoding must replay as one wire header")
require.Len(t, httpHeader["Baggage"], 1, "Baggage must replay as one wire header")
assert.Equal(t, "application/json, text/plain, */*", httpHeader["Accept"][0])
assert.Equal(t, "gzip, compress, deflate, br", httpHeader["Accept-Encoding"][0])
assert.Equal(t, "trace-id=abc,user-key=def,env=test", httpHeader["Baggage"][0])
}
// The wire lines of a recorded header, round-tripped: what ToYamlHTTPHeader and
// ToYamlHTTPHeaderLineLengths store, ToWireHTTPHeader sends back out, and only
// what they stored. A tenant header sent twice goes out twice, not folded; one
// Accept line with commas in it stays one line (the dict(**headers) fix above).
func TestToWireHTTPHeaderSendsTheRecordedLines(t *testing.T) {
for _, tc := range []struct {
name string
wire http.Header
}{
{"a header repeated on two lines", http.Header{"X_tenant": {"acme", "acme"}}},
{"one line carrying commas", http.Header{"Accept": {"a, b"}}},
{"repeated lines carrying commas", http.Header{"Cookie": {"a=1,2", "b=3,,", ",c"}}},
{"an empty line among them", http.Header{"X-Empty": {"", "x", ""}}},
{"both together", http.Header{"X_tenant": {"acme", "acme"}, "Accept": {"a, b"}, "Host": {"h"}}},
} {
t.Run(tc.name, func(t *testing.T) {
header := ToYamlHTTPHeader(tc.wire)
lengths := ToYamlHTTPHeaderLineLengths(tc.wire)
for name, lines := range tc.wire {
if _, stored := lengths[name]; stored != (len(lines) > 1) {
t.Errorf("%s on %d line(s): lengths stored = %v", name, len(lines), stored)
}
}
assert.Equal(t, tc.wire, ToWireHTTPHeader(header, lengths))
})
}
assert.Nil(t, ToYamlHTTPHeaderLineLengths(http.Header{"Accept": {"a, b"}, "Host": {"h"}}),
"a header with no repeated line records no lengths, so nothing new reaches disk")
}
// A recording made before the lengths existed replays as it always did, and a
// value that no longer is the lines it was recorded from (a template rendered,
// a secret decrypted to something else, a hand edit) is sent as one line, not
// cut at a guessed boundary.
func TestToWireHTTPHeaderFoldsWhatItCannotSplit(t *testing.T) {
for _, tc := range []struct {
name string
value string
lengths []int
}{
{"no lengths: a recording from before them", "ab,ab", nil},
{"one length is not a repeated header", "ab,ab", []int{5}},
{"the value grew", "ab,abb", []int{2, 2}},
{"the value shrank", "ab,a", []int{2, 2}},
{"no comma where a line ended", "abXab", []int{2, 2}},
{"a line too long for the value", "ab", []int{2, 5}},
{"a negative length", "ab,ab", []int{-1, 5}},
} {
t.Run(tc.name, func(t *testing.T) {
var lengths models.HeaderLineLengths
if tc.lengths != nil {
lengths = models.HeaderLineLengths{"X_tenant": tc.lengths}
}
got := ToWireHTTPHeader(map[string]string{"X_tenant": tc.value}, lengths)
assert.Equal(t, http.Header{"X_tenant": {tc.value}}, got)
})
}
// Lengths for a name the header no longer has add nothing.
got := ToWireHTTPHeader(map[string]string{"Accept": "a"}, models.HeaderLineLengths{"X_tenant": {2, 2}})
assert.Equal(t, http.Header{"Accept": {"a"}}, got)
}
// The recorded curl command reproduces the request that was recorded, a
// repeated header on its own lines included.
func TestMakeCurlCommandRepeatsARepeatedHeader(t *testing.T) {
curl := MakeCurlCommand(models.HTTPReq{
Method: "GET",
URL: "http://localhost:8080/v1/session",
Header: map[string]string{"X_tenant": "acme,acme", "Accept": "a, b", "Content-Length": "0"},
HeaderLineLengths: models.HeaderLineLengths{"X_tenant": {4, 4}},
})
assert.Equal(t, 2, strings.Count(curl, "--header 'X_tenant: acme' \\\n"), curl)
assert.NotContains(t, curl, "acme,acme")
assert.Contains(t, curl, "--header 'Accept: a, b' \\\n")
assert.NotContains(t, curl, "Content-Length")
}
// TestParseHTTPRequest_And_Response_111 contains sub-tests for ParseHTTPRequest and
// ParseHTTPResponse, validating both success and failure cases for parsing raw
// HTTP data into their respective struct representations.
func TestParseHTTPRequest_And_Response_111(t *testing.T) {
t.Run("ParseHTTPRequest_Valid", func(t *testing.T) {
rawReq := "GET /test HTTP/1.1\r\nHost: example.com\r\n\r\n"
req, err := ParseHTTPRequest([]byte(rawReq))
require.NoError(t, err)
assert.Equal(t, "GET", req.Method)
assert.Equal(t, "/test", req.URL.Path)
assert.Equal(t, "example.com", req.Host)
})
t.Run("ParseHTTPRequest_Invalid", func(t *testing.T) {
rawReq := "this is not a valid request"
_, err := ParseHTTPRequest([]byte(rawReq))
require.Error(t, err)
})
t.Run("ParseHTTPResponse_Valid", func(t *testing.T) {
rawResp := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, world!"
req, _ := http.NewRequest("GET", "http://example.com", nil)
resp, err := ParseHTTPResponse([]byte(rawResp), req)
require.NoError(t, err)
defer func() {
if err := resp.Body.Close(); err != nil {
t.Logf("failed to close response body: %v", err)
}
}()
assert.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "Hello, world!", string(body))
})
t.Run("ParseHTTPResponse_Invalid", func(t *testing.T) {
rawResp := "this is not a valid response"
req, _ := http.NewRequest("GET", "http://example.com", nil)
_, err := ParseHTTPResponse([]byte(rawResp), req)
require.Error(t, err)
})
}
// TestCompressDecompress_AllEncodings_555 provides comprehensive testing for the
// Compress and Decompress functions. It checks:
// - Successful round-trip (compress then decompress) for 'gzip' and 'br' (brotli).
// - No-op behavior for unknown encodings.
// - Error handling for invalid compressed data.
func TestCompressDecompress_AllEncodings_555(t *testing.T) {
logger := zap.NewNop()
t.Run("Gzip", func(t *testing.T) {
originalData := []byte("hello world, this is a test")
compressedData, err := Compress(logger, "gzip", originalData)
require.NoError(t, err)
assert.NotEqual(t, originalData, compressedData)
decompressedData, err := Decompress(logger, "gzip", compressedData, MaxDecompressedSize)
require.NoError(t, err)
assert.Equal(t, originalData, decompressedData)
})
t.Run("Brotli", func(t *testing.T) {
originalData := []byte("hello world, this is a brotli test")
compressedData, err := Compress(logger, "br", originalData)
require.NoError(t, err)
assert.NotEqual(t, originalData, compressedData)
decompressedData, err := Decompress(logger, "br", compressedData, MaxDecompressedSize)
require.NoError(t, err)
assert.Equal(t, originalData, decompressedData)
})
t.Run("UnknownEncoding", func(t *testing.T) {
originalData := []byte("hello world")
compressedData, err := Compress(logger, "unknown", originalData)
require.NoError(t, err)
assert.Equal(t, originalData, compressedData)
decompressedData, err := Decompress(logger, "unknown", originalData, MaxDecompressedSize)
require.NoError(t, err)
assert.Equal(t, originalData, decompressedData)
})
t.Run("DecompressError", func(t *testing.T) {
invalidGzipData := []byte("not gzip")
_, err := Decompress(logger, "gzip", invalidGzipData, MaxDecompressedSize)
require.Error(t, err)
invalidBrotliData := []byte{0xce, 0xb2, 0xcf, 0x81}
_, err = Decompress(logger, "br", invalidBrotliData, MaxDecompressedSize)
require.Error(t, err)
})
// Content-Encoding is matched case-insensitively after trimming — on
// BOTH sides. Decompress-only normalization would store plaintext at
// record time while Compress (exact match) replays it un-compressed
// under a Content-Encoding header, breaking the app's client decoder.
t.Run("EncodingNormalizedRoundTrip", func(t *testing.T) {
originalData := []byte("normalize me")
for _, enc := range []string{"GZIP", " gzip", "gzip ", "BR", " br "} {
compressedData, err := Compress(logger, enc, originalData)
require.NoError(t, err, "encoding %q", enc)
assert.NotEqual(t, originalData, compressedData, "Compress must not pass %q through", enc)
decompressedData, err := Decompress(logger, enc, compressedData, MaxDecompressedSize)
require.NoError(t, err, "encoding %q", enc)
assert.Equal(t, originalData, decompressedData, "encoding %q", enc)
}
})
// "identity" (RFC 9110 §8.4.1) means no transformation — must pass
// through unchanged, hitting the explicit identity case rather than the
// unsupported-encoding fallback (observed via its log line, the only
// difference between the two paths).
t.Run("IdentityEncoding", func(t *testing.T) {
core, logs := observer.New(zap.DebugLevel)
obsLogger := zap.New(core)
originalData := []byte("plain body")
out, err := Decompress(obsLogger, "identity", originalData, MaxDecompressedSize)
require.NoError(t, err)
assert.Equal(t, originalData, out)
assert.Zero(t, logs.FilterMessageSnippet("unsupported Content-Encoding").Len(),
"identity must not hit the unsupported-encoding fallback")
out, err = Compress(obsLogger, "identity", originalData)
require.NoError(t, err)
assert.Equal(t, originalData, out)
// A genuinely unknown encoding does hit the fallback, at debug level.
_, err = Decompress(obsLogger, "zstd", originalData, MaxDecompressedSize)
require.NoError(t, err)
require.Equal(t, 1, logs.FilterMessageSnippet("unsupported Content-Encoding").Len())
assert.Equal(t, zap.DebugLevel, logs.FilterMessageSnippet("unsupported Content-Encoding").All()[0].Level)
})
// A payload inflating past the caller's limit must error, not OOM (#3867).
// Uses real gzip/brotli through the public API so it fails if the capped
// read wiring is ever reverted. The sentinel lets callers distinguish
// oversized-but-valid from corrupt streams.
t.Run("DecompressionBomb", func(t *testing.T) {
const limit = 1024
bomb := make([]byte, 8*1024) // 8 KiB of zeros: tiny compressed, inflates past limit
gzipBomb, err := Compress(logger, "gzip", bomb)
require.NoError(t, err)
_, err = Decompress(logger, "gzip", gzipBomb, limit)
require.ErrorIs(t, err, ErrDecompressedTooLarge)
assert.Contains(t, err.Error(), "decompression bomb")
brBomb, err := Compress(logger, "br", bomb)
require.NoError(t, err)
_, err = Decompress(logger, "br", brBomb, limit)
require.ErrorIs(t, err, ErrDecompressedTooLarge)
assert.Contains(t, err.Error(), "decompression bomb")
})
// A body of exactly the limit is legal; limit+1 is not. Also pins the
// corrupt-stream case to NOT match the sentinel.
t.Run("DecompressionLimitBoundary", func(t *testing.T) {
const limit = 1024
atLimit, err := Compress(logger, "gzip", make([]byte, limit))
require.NoError(t, err)
out, err := Decompress(logger, "gzip", atLimit, limit)
require.NoError(t, err)
assert.Len(t, out, limit)
overByOne, err := Compress(logger, "gzip", make([]byte, limit+1))
require.NoError(t, err)
_, err = Decompress(logger, "gzip", overByOne, limit)
require.ErrorIs(t, err, ErrDecompressedTooLarge)
_, err = Decompress(logger, "gzip", []byte("not gzip"), limit)
require.Error(t, err)
assert.NotErrorIs(t, err, ErrDecompressedTooLarge)
})
}
// TestReadAllCapped_556 exercises the capped reader directly: boundary
// sizes, tiny limits below the initial buffer, and that buffer capacity
// never overshoots limit+1 rounded up to whole 8 KiB heap pages: for
// limit+1 over 32 KiB, what the allocator gives a buffer of limit+1 bytes;
// under it, up to a page more than the size class it would give, so that
// what a decode reserves for its buffer is what it holds
// (io.ReadAll+LimitReader, which it replaced, could keep twice limit+1).
func TestReadAllCapped_556(t *testing.T) {
t.Run("Boundaries", func(t *testing.T) {
for _, tc := range []struct {
name string
size int64
limit int64
wantErr bool
}{
{"empty", 0, 1024, false},
{"under", 1023, 1024, false},
{"exact", 1024, 1024, false},
{"overByOne", 1025, 1024, true},
{"zeroLimitEmpty", 0, 0, false},
{"zeroLimitOne", 1, 0, true},
{"aboveInitialBuffer", 100 * 1024, 200 * 1024, false},
{"growthThenError", 300 * 1024, 200 * 1024, true},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := readAllCapped(bytes.NewReader(make([]byte, tc.size)), tc.limit)
if tc.wantErr {
require.ErrorIs(t, err, ErrDecompressedTooLarge)
return
}
require.NoError(t, err)
assert.Equal(t, tc.size, int64(len(data)))
pages := (tc.limit + 1 + 8<<10 - 1) / (8 << 10) * (8 << 10)
assert.LessOrEqual(t, int64(cap(data)), pages, "capacity must never overshoot limit+1 in whole pages")
})
}
})
t.Run("UnboundedStreamStops", func(t *testing.T) {
// An infinite reader must error at the cap, not hang or OOM.
const limit = 64 * 1024
_, err := readAllCapped(rand.Reader, limit)
require.ErrorIs(t, err, ErrDecompressedTooLarge)
})
}
// TestNewCappedReader_557 covers the streaming counterpart of readAllCapped:
// exact-limit streams reach EOF, one extra byte errors, and the error is
// sticky across subsequent reads.
func TestNewCappedReader_557(t *testing.T) {
t.Run("ExactLimitReachesEOF", func(t *testing.T) {
r := NewCappedReader(bytes.NewReader(make([]byte, 1024)), 1024)
data, err := io.ReadAll(r)
require.NoError(t, err)
assert.Len(t, data, 1024)
})
t.Run("OverLimitErrorsAndSticks", func(t *testing.T) {
r := NewCappedReader(bytes.NewReader(make([]byte, 1025)), 1024)
_, err := io.Copy(io.Discard, r)
require.ErrorIs(t, err, ErrDecompressedTooLarge)
// Error must persist on further reads — a consumer retrying the
// stream must not see a clean EOF.
_, err = r.Read(make([]byte, 1))
require.ErrorIs(t, err, ErrDecompressedTooLarge)
})
t.Run("SmallChunkedReads", func(t *testing.T) {
r := NewCappedReader(bytes.NewReader(make([]byte, 100)), 1024)
var total int
buf := make([]byte, 7)
for {
n, err := r.Read(buf)
total += n
if err == io.EOF {
break
}
require.NoError(t, err)
}
assert.Equal(t, 100, total)
})
t.Run("UnboundedStreamStops", func(t *testing.T) {
_, err := io.Copy(io.Discard, NewCappedReader(rand.Reader, 64*1024))
require.ErrorIs(t, err, ErrDecompressedTooLarge)
})
}
// zeroReader yields an endless stream of zero bytes (compresses extremely
// well — the shape of a decompression bomb).
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
// TestSimulateHTTPStreaming_DecompressionBomb_558 pins the streaming replay
// wiring (#3867): a response inflating past MaxDecompressedSize must fail
// the stream read with ErrDecompressedTooLarge instead of OOMing while the
// body accumulates.
func TestSimulateHTTPStreaming_DecompressionBomb_558(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
const bombSize = MaxDecompressedSize + 64*1024
makeBomb := func(t *testing.T, encoding string) []byte {
t.Helper()
var buf bytes.Buffer
var w io.WriteCloser
switch encoding {
case "gzip":
w, _ = gzip.NewWriterLevel(&buf, gzip.BestSpeed)
case "br":
w = brotli.NewWriterLevel(&buf, brotli.BestSpeed)
}
_, err := io.CopyN(w, zeroReader{}, bombSize)
require.NoError(t, err)
require.NoError(t, w.Close())
return buf.Bytes()
}
for _, encoding := range []string{"gzip", "br"} {
t.Run(encoding, func(t *testing.T) {
bomb := makeBomb(t, encoding)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Encoding", encoding)
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(bomb)
}))
defer server.Close()
tc := &models.TestCase{
Name: "decompression-bomb-" + encoding,
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL,
// Explicit Accept-Encoding disables the transport's
// transparent decompression, so the body arrives
// compressed with Content-Encoding intact — the same
// shape SimulateHTTPStreaming handles in production.
Header: map[string]string{"Accept-Encoding": encoding},
},
}
streamResp, err := SimulateHTTPStreaming(ctx, tc, "test-set", logger, SimulationConfig{APITimeout: 10})
require.NoError(t, err)
require.NotNil(t, streamResp)
defer streamResp.Reader.Close()
_, err = io.Copy(io.Discard, streamResp.Reader)
require.ErrorIs(t, err, ErrDecompressedTooLarge)
})
}
}
// TestFilterMocks_678 validates the filtering and sorting of mocks in Test and Config modes.
func TestFilterMocks_678(t *testing.T) {
// All subtests in TestFilterMocks_678 assert LAX-mode behaviour
// (out-of-window non-config mocks promoted to the unfiltered pool
// rather than dropped). The env var KEPLOY_STRICT_MOCK_WINDOW is
// parsed once at init and cached in strictWindowEnvOverride; an
// enabling value makes filterByTimeStamp behave strict regardless
// of the perCall flag, so our strict=false arguments no longer
// produce the expected lax output. Skip the whole test — every
// subtest is affected. Strict-mode behaviour is covered by
// TestFilterByTimeStamp_StrictMode below (which runs unconditionally).
if strictWindowEnvOverride {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW set to enabling value — lax assertions unreachable; strict coverage runs elsewhere")
}
logger := zap.NewNop()
ctx := context.Background()
now := time.Now()
mock1 := &models.Mock{Name: "mock1", Version: "api.keploy.io/v1beta1", Spec: models.MockSpec{ReqTimestampMock: now.Add(-2 * time.Hour), ResTimestampMock: now.Add(-2 * time.Hour)}}
mock2 := &models.Mock{Name: "mock2", Version: "api.keploy.io/v1beta1", Spec: models.MockSpec{ReqTimestampMock: now, ResTimestampMock: now}}
mock3 := &models.Mock{Name: "mock3", Version: "api.keploy.io/v1beta1", Spec: models.MockSpec{ReqTimestampMock: now.Add(2 * time.Hour), ResTimestampMock: now.Add(2 * time.Hour)}}
mockNoTime := &models.Mock{Name: "mockNoTime", Version: "api.keploy.io/v1beta1", Spec: models.MockSpec{}}
mockNonKeploy := &models.Mock{Name: "mockNonKeploy", Version: "v1", Spec: models.MockSpec{ReqTimestampMock: now, ResTimestampMock: now}}
allMocks := []*models.Mock{mock3, mock1, mock2, mockNoTime, mockNonKeploy}
t.Run("filterByTimeStamp_NoFilters", func(t *testing.T) {
// strict=false (default lax) — subject under test here is the
// no-window shortcut, not the containment rule.
filtered, unfiltered := filterByTimeStamp(ctx, logger, allMocks, time.Time{}, time.Time{}, false)
assert.Len(t, filtered, 5)
assert.Len(t, unfiltered, 0)
})
t.Run("filterByTimeStamp_ValidRange", func(t *testing.T) {
after := now.Add(-1 * time.Hour)
before := now.Add(1 * time.Hour)
// Existing assertions were written under lax Option-2 semantics
// (req-only inside window, out-of-window mocks kept as unfiltered).
// Pass strict=false to preserve that contract; strict mode is
// exercised separately by TestFilterByTimeStamp_StrictMode.
filtered, unfiltered := filterByTimeStamp(ctx, logger, allMocks, after, before, false)
// Check filtered mocks
require.Len(t, filtered, 3)
filteredNames := []string{}
for _, m := range filtered {
filteredNames = append(filteredNames, m.Name)
assert.True(t, m.TestModeInfo.IsFiltered)
}
assert.ElementsMatch(t, []string{"mock2", "mockNoTime", "mockNonKeploy"}, filteredNames)
// Check unfiltered mocks
require.Len(t, unfiltered, 2)
unfilteredNames := []string{}
for _, m := range unfiltered {
unfilteredNames = append(unfilteredNames, m.Name)
assert.False(t, m.TestModeInfo.IsFiltered)
}
assert.ElementsMatch(t, []string{"mock1", "mock3"}, unfilteredNames)
})
t.Run("FilterTcsMocks", func(t *testing.T) {
after := now.Add(-1 * time.Hour)
before := now.Add(1 * time.Hour)
// strict=false preserves the original lax expectations of this test
// (written before Option-1 strict containment existed).
result := FilterTcsMocks(ctx, logger, allMocks, after, before, false)
require.Len(t, result, 3)
assert.Equal(t, "mockNoTime", result[0].Name) // Zero time comes first
assert.Equal(t, "mock2", result[1].Name)
assert.Equal(t, "mockNonKeploy", result[2].Name)
})
t.Run("FilterConfigMocks", func(t *testing.T) {
after := now.Add(-1 * time.Hour)
before := now.Add(1 * time.Hour)
// strict=false — see FilterTcsMocks note above.
result := FilterConfigMocks(ctx, logger, allMocks, after, before, false)
require.Len(t, result, 5)
// Sorted filtered part
assert.Equal(t, "mockNoTime", result[0].Name)
assert.Equal(t, "mock2", result[1].Name)
assert.Equal(t, "mockNonKeploy", result[2].Name)
// Sorted unfiltered part
assert.Equal(t, "mock1", result[3].Name)
assert.Equal(t, "mock3", result[4].Name)
})
}
func TestFilterConfigMocks_PrioritizesLateMockByRequestTime_3738(t *testing.T) {
// Reproducer asserts lax-mode behaviour for out-of-window mocks;
// skip when KEPLOY_STRICT_MOCK_WINDOW forces strict on. See
// TestFilterMocks_678's guard for the same reason.
if strictWindowEnvOverride {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW is set — unset it to run lax-mode assertions")
}
logger := zap.NewNop()
ctx := context.Background()
testReqTime := time.Date(2026, time.February, 12, 6, 39, 20, 766944656, time.UTC)
testRespTime := time.Date(2026, time.February, 12, 6, 39, 20, 820398862, time.UTC)
staleData := &models.Mock{
Name: "mock-62",
Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: time.Date(2026, time.February, 12, 6, 38, 43, 559270267, time.UTC),
ResTimestampMock: time.Date(2026, time.February, 12, 6, 38, 43, 565339949, time.UTC),
},
}
countQuery := &models.Mock{
Name: "mock-137",
Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: time.Date(2026, time.February, 12, 6, 39, 20, 767839595, time.UTC),
ResTimestampMock: time.Date(2026, time.February, 12, 6, 39, 20, 809483263, time.UTC),
},
}
trailingData := &models.Mock{
Name: "mock-138",
Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: time.Date(2026, time.February, 12, 6, 39, 20, 809891000, time.UTC),
ResTimestampMock: time.Date(2026, time.February, 12, 6, 39, 20, 820711000, time.UTC),
},
}
// strict=false — reproducer test validates legacy Option-2 behaviour
// (trailing response kept in filtered even though it landed a
// microsecond past beforeTime).
filtered, unfiltered := filterByTimeStamp(ctx, logger, []*models.Mock{trailingData, staleData, countQuery}, testReqTime, testRespTime, false)
require.Len(t, filtered, 2)
assert.ElementsMatch(t, []string{"mock-137", "mock-138"}, []string{filtered[0].Name, filtered[1].Name})
require.Len(t, unfiltered, 1)
assert.Equal(t, "mock-62", unfiltered[0].Name)
result := FilterConfigMocks(ctx, logger, []*models.Mock{trailingData, staleData, countQuery}, testReqTime, testRespTime, false)
require.Len(t, result, 3)
assert.Equal(t, "mock-137", result[0].Name)
assert.Equal(t, "mock-138", result[1].Name)
assert.Equal(t, "mock-62", result[2].Name)
}
// TestFilterByTimeStamp_StrictMode validates the strict-window opt-in.
// The in-window predicate is REQUEST-timestamp only (responses may
// legitimately straggle past beforeTime), so strict and lax both keep
// mocks whose req is inside [after,before]. The behavioural difference
// is SOLELY out-of-window handling: strict DROPS non-config mocks that
// would leak into the unfiltered pool, while lax preserves the legacy
// cross-test config pool promotion. Config-tagged mocks survive in
// both modes.
func TestFilterByTimeStamp_StrictMode(t *testing.T) {
// The process-wide env value (strictWindowEnvOverride /
// strictWindowEnvExplicitOff) is parsed once at init and then
// cached — we can't os.Setenv() it mid-test. Per-subtest skips
// below keep each assertion running when its mode is reachable
// and skip only the ones that can't.
logger := zap.NewNop()
ctx := context.Background()
now := time.Now()
after := now.Add(-1 * time.Hour)
before := now.Add(1 * time.Hour)
inWindow := &models.Mock{
Name: "in-window-data", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{ReqTimestampMock: now, ResTimestampMock: now.Add(time.Second)},
}
straggler := &models.Mock{
// Req inside window but Res past beforeTime — under the req-only
// containment rule, BOTH lax and strict keep it in the filtered
// set (response straggle is normal async completion, not bleed).
Name: "straggler-data", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{ReqTimestampMock: now, ResTimestampMock: now.Add(2 * time.Hour)},
}
stale := &models.Mock{
// Both endpoints before window — both modes drop from filteredMocks
// (lax pushes to unfiltered, strict drops entirely).
Name: "stale-data", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{ReqTimestampMock: now.Add(-3 * time.Hour), ResTimestampMock: now.Add(-3 * time.Hour)},
}
staleConfig := &models.Mock{
// Stale but type=config — strict keeps in unfiltered pool, lax also keeps.
Name: "stale-config", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: now.Add(-3 * time.Hour),
ResTimestampMock: now.Add(-3 * time.Hour),
Metadata: map[string]string{"type": "config"},
},
}
all := []*models.Mock{inWindow, straggler, stale, staleConfig}
t.Run("default lax promotes stale-data to unfiltered (legacy leak)", func(t *testing.T) {
// strict=false (default). Req-in-window rule for the filtered
// partition; out-of-window non-config mocks get promoted to the
// unfiltered pool (the legacy bleed). Env forcing strict on
// defeats the lax path — skip this subtest only.
if strictWindowEnvOverride {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW forces strict on — lax semantics not reachable")
}
filtered, unfiltered := filterByTimeStamp(ctx, logger, all, after, before, false)
filteredNames := mockNames(filtered)
unfilteredNames := mockNames(unfiltered)
assert.ElementsMatch(t, []string{"in-window-data", "straggler-data"}, filteredNames)
assert.ElementsMatch(t, []string{"stale-data", "stale-config"}, unfilteredNames)
})
t.Run("opt-in strict keeps straggler (req in window); drops stale-data; keeps stale-config", func(t *testing.T) {
// strict=true — opt into Option-1 containment. Env forcing
// strict OFF defeats the strict path — skip this subtest only.
if strictWindowEnvExplicitOff {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW=0 forces strict off — strict semantics not reachable")
}
filtered, unfiltered := filterByTimeStamp(ctx, logger, all, after, before, true)
filteredNames := mockNames(filtered)
unfilteredNames := mockNames(unfiltered)
assert.ElementsMatch(t, []string{"in-window-data", "straggler-data"}, filteredNames)
assert.ElementsMatch(t, []string{"stale-config"}, unfilteredNames)
})
}
// Locks in the contract that a mock whose response timestamp precedes
// its request timestamp is DROPPED from both filtered and unfiltered
// partitions, in BOTH strict and lax modes — the response-before-
// request state is always an inconsistent recording (clock skew,
// serialisation bug, or file corruption) and keeping it in either pool
// risks confusing the matcher's scoring. See filterByTimeStamp's
// "defensive sanity check" in pkg/util.go.
func TestFilterByTimeStamp_DropsInvalidTimestampOrder(t *testing.T) {
logger := zap.NewNop()
ctx := context.Background()
now := time.Now()
after := now.Add(-1 * time.Hour)
before := now.Add(1 * time.Hour)
// Req inside window, but Res BEFORE Req by 1s — invalid order.
invalid := &models.Mock{
Name: "invalid-order", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: now,
ResTimestampMock: now.Add(-time.Second),
},
}
// Same shape but type=config — still dropped; invalid-order check
// fires before the config-vs-data partition.
invalidConfig := &models.Mock{
Name: "invalid-order-config", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: now,
ResTimestampMock: now.Add(-time.Second),
Metadata: map[string]string{"type": "config"},
},
}
// Sanity: a well-ordered in-window mock survives alongside drops.
valid := &models.Mock{
Name: "valid-in-window", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: now,
ResTimestampMock: now.Add(time.Second),
},
}
all := []*models.Mock{invalid, invalidConfig, valid}
t.Run("lax mode drops invalid-order from BOTH partitions", func(t *testing.T) {
filtered, unfiltered := filterByTimeStamp(ctx, logger, all, after, before, false)
assert.ElementsMatch(t, []string{"valid-in-window"}, mockNames(filtered))
assert.Empty(t, mockNames(unfiltered))
})
t.Run("strict mode drops invalid-order from BOTH partitions", func(t *testing.T) {
filtered, unfiltered := filterByTimeStamp(ctx, logger, all, after, before, true)
assert.ElementsMatch(t, []string{"valid-in-window"}, mockNames(filtered))
assert.Empty(t, mockNames(unfiltered))
})
}
func mockNames(ms []*models.Mock) []string {
out := make([]string, 0, len(ms))
for _, m := range ms {
out = append(out, m.Name)
}
return out
}
// TestFilterByTimeStampTierAware_StartupPreservation pins the
// tier-aware strictMockWindow contract. Given the five cases from the
// contract (startup, in-window, stale prev-test, session, connection)
// plus the stale-past-window case, the filter KEEPs startup + session +
// connection + in-window and DROPs only genuinely stale cross-test
// per-test mocks. See pkg/util.go's filterByTimeStampTierAware doc.
func TestFilterByTimeStampTierAware_StartupPreservation(t *testing.T) {
if strictWindowEnvExplicitOff {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW=0 forces strict off — strict-tier semantics not reachable")
}
logger := zap.NewNop()
ctx := context.Background()
// firstWindowStart < previousTestEnd < currentStart < currentEnd
// | | | |
// 2:00 UTC 2:30 UTC 3:00 UTC 3:10 UTC
firstWindowStart := time.Date(2026, 4, 22, 2, 0, 0, 0, time.UTC)
previousTestEnd := time.Date(2026, 4, 22, 2, 30, 0, 0, time.UTC)
currentStart := time.Date(2026, 4, 22, 3, 0, 0, 0, time.UTC)
currentEnd := time.Date(2026, 4, 22, 3, 10, 0, 0, time.UTC)
// startup: per-test mock recorded BEFORE any test fired (req <
// firstWindowStart). Legacy blanket-drop killed this; tier-aware
// must preserve.
startup := &models.Mock{
Name: "startup-pertest", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: firstWindowStart.Add(-5 * time.Minute),
ResTimestampMock: firstWindowStart.Add(-5*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
// inWindow: per-test mock whose req is inside [currentStart, currentEnd].
inWindow := &models.Mock{
Name: "in-window-pertest", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: currentStart.Add(time.Minute),
ResTimestampMock: currentStart.Add(time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
// stalePrev: per-test mock from a previous test window
// (firstWindowStart <= req < currentStart). REAL bleed — must drop.
stalePrev := &models.Mock{
Name: "stale-prev-test", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: previousTestEnd.Add(-5 * time.Minute),
ResTimestampMock: previousTestEnd.Add(-5*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
// session: LifetimeSession is always preserved (spans tests).
session := &models.Mock{
Name: "session-scoped", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: previousTestEnd.Add(-10 * time.Minute),
ResTimestampMock: previousTestEnd.Add(-10*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimeSession},
}
// connection: LifetimeConnection is always preserved (conn-bounded).
connection := &models.Mock{
Name: "connection-scoped", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: previousTestEnd.Add(-15 * time.Minute),
ResTimestampMock: previousTestEnd.Add(-15*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimeConnection},
}
// stalePast: per-test mock whose req is PAST currentEnd (future-test
// bleed). Also genuine stale and must be dropped.
stalePast := &models.Mock{
Name: "stale-past-test", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: currentEnd.Add(10 * time.Minute),
ResTimestampMock: currentEnd.Add(10*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
all := []*models.Mock{startup, inWindow, stalePrev, session, connection, stalePast}
filtered, unfiltered := filterByTimeStampTierAware(ctx, logger, all, currentStart, currentEnd, true, firstWindowStart)
// The startup per-test mock survives in `filtered` so MockManager's
// SetMocksWithWindow partition-by-firstWindowStart routes it into
// the startup tree. The in-window per-test mock also lives in
// `filtered` — that's its current tier.
assert.ElementsMatch(t, []string{"startup-pertest", "in-window-pertest"}, mockNames(filtered),
"startup + in-window per-test mocks must survive in filtered slice")
// Session and connection mocks go to unfiltered (reusable-across-
// tests pool). Stale previous-test and stale future-test per-test
// mocks are DROPPED — genuine cross-test bleed.
assert.ElementsMatch(t, []string{"session-scoped", "connection-scoped"}, mockNames(unfiltered),
"session + connection must survive in unfiltered slice; stale per-test must drop")
}
// TestFilterByTimeStampTierAware_ZeroFirstStart_Fallback pins the
// fallback contract: when firstWindowStart is zero (no test has fired
// yet on the underlying MockManager, or the caller couldn't thread
// it), the tier-aware filter reverts to the legacy blanket-drop
// semantics so behaviour is strictly no worse than before the fix.
func TestFilterByTimeStampTierAware_ZeroFirstStart_Fallback(t *testing.T) {
if strictWindowEnvExplicitOff {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW=0 forces strict off — strict fallback not reachable")
}
logger := zap.NewNop()
ctx := context.Background()
now := time.Now()
after := now.Add(-1 * time.Hour)
before := now.Add(1 * time.Hour)
// Per-test mock predating the current window. Under tier-aware with
// a known firstWindowStart this would be preserved as startup; under
// zero-firstWindowStart fallback it behaves like the legacy blanket
// drop (dropped under strict).
preWindow := &models.Mock{
Name: "pre-window", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: now.Add(-3 * time.Hour),
ResTimestampMock: now.Add(-3*time.Hour + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
inWindow := &models.Mock{
Name: "in-window", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: now,
ResTimestampMock: now.Add(time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
all := []*models.Mock{preWindow, inWindow}
filtered, unfiltered := filterByTimeStampTierAware(ctx, logger, all, after, before, true, time.Time{})
// Zero firstWindowStart → legacy blanket-drop — pre-window dropped,
// in-window kept.
assert.ElementsMatch(t, []string{"in-window"}, mockNames(filtered),
"zero firstWindowStart must fall back to legacy blanket drop for per-test mocks")
assert.Empty(t, mockNames(unfiltered),
"zero firstWindowStart strict mode must leave per-test unfiltered empty")
}
// TestFilterByTimeStamp_ScopeMetadataIgnored_LifetimeAndTimestampAuthoritative
// pins the mockdb/filter-layer authoritative per-test router contract:
// pool routing uses ONLY Lifetime + ReqTimestampMock. The recorder-
// stamped metadata["scope"] string is informational on the wire and
// MUST NOT influence routing. This regression-guards the debug-bundle
// symptom where perTest-lifetime mocks with a recorder-stamped
// scope=session (context-probe captured between test-window
// transitions) were incorrectly dropped into the session pool.
//
// Contract:
//
// 1. mock{Lifetime=perTest, metadata.scope=session, req ∈ window} →
// per-test pool (filtered). The timestamp attribution wins; scope
// is ignored.
// 2. mock{Lifetime=session, metadata.scope=test:N, req ∈ window} →
// session pool (unfiltered). Lifetime-first short-circuit wins;
// neither scope nor the fact that req happens to land inside the
// current test's window matters for the session-lifetime class.
// 3. mock{Lifetime=connection, metadata.scope=session, req ∈ window} →
// session pool (unfiltered). Same lifetime-first semantics as (2).
func TestFilterByTimeStamp_ScopeMetadataIgnored_LifetimeAndTimestampAuthoritative(t *testing.T) {
if strictWindowEnvExplicitOff {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW=0 forces strict off — lifetime-first routing only exercised under strict")
}
logger := zap.NewNop()
ctx := context.Background()
start := time.Date(2026, 4, 23, 10, 0, 0, 0, time.UTC)
end := start.Add(10 * time.Second)
reqInWindow := start.Add(time.Second)
resInWindow := reqInWindow.Add(time.Millisecond)
// Case 1: the user's exact anomaly shape — a perTest-lifetime mock
// with recorder-stamped scope=session, timestamp inside the window.
// Must land in the per-test (filtered) pool via timestamp
// attribution; the scope tag is noise.
perTestWithSessionScope := &models.Mock{
Name: "pertest-scope-session-anomaly",
Version: "api.keploy.io/v1beta1",
Kind: models.PostgresV3,
Spec: models.MockSpec{
ReqTimestampMock: reqInWindow,
ResTimestampMock: resInWindow,
// The recorder stamped scope=session (context-probe
// thought it was between tests) AND type=mocks (semantic
// class classified the query as per-test). Exactly the
// debug-bundle's "lifetime: perTest, scope: session" shape.
Metadata: map[string]string{"type": "mocks", "scope": "session"},
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
// Case 2: a session-lifetime mock stamped scope=test:N, req in
// window. Lifetime-first short-circuit wins — session pool.
sessionWithTestScope := &models.Mock{
Name: "session-scope-test-anomaly",
Version: "api.keploy.io/v1beta1",
Kind: models.PostgresV3,
Spec: models.MockSpec{
ReqTimestampMock: reqInWindow,
ResTimestampMock: resInWindow,
Metadata: map[string]string{"type": "config", "scope": "test:sap-customer360"},
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimeSession},
}
// Case 3: a connection-lifetime mock stamped scope=session, req in
// window. Lifetime-first short-circuit wins — session/unfiltered
// pool (MockManager seeds the per-connID pool from unfiltered).
connWithSessionScope := &models.Mock{
Name: "connection-scope-session",
Version: "api.keploy.io/v1beta1",
Kind: models.PostgresV3,
Spec: models.MockSpec{
ReqTimestampMock: reqInWindow,
ResTimestampMock: resInWindow,
Metadata: map[string]string{"type": "connection", "scope": "session", "connID": "pg-conn-1"},
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimeConnection},
}
// Case 4 (baseline sanity): an ordinary perTest-lifetime mock with
// no scope tag at all, req in window. Must still land in per-test.
// Guards against a regression where the lifetime-first branch
// accidentally swallows perTest traffic.
perTestNoScope := &models.Mock{
Name: "pertest-no-scope",
Version: "api.keploy.io/v1beta1",
Kind: models.PostgresV3,
Spec: models.MockSpec{
ReqTimestampMock: reqInWindow,
ResTimestampMock: resInWindow,
Metadata: map[string]string{"type": "mocks"},
},
TestModeInfo: models.TestModeInfo{Lifetime: models.LifetimePerTest},
}
all := []*models.Mock{perTestWithSessionScope, sessionWithTestScope, connWithSessionScope, perTestNoScope}
filtered, unfiltered := filterByTimeStampTierAware(ctx, logger, all, start, end, true, start)
assert.ElementsMatch(t,
[]string{"pertest-scope-session-anomaly", "pertest-no-scope"},
mockNames(filtered),
"perTest-lifetime mocks in window must land in the per-test pool irrespective of metadata[\"scope\"]")
assert.ElementsMatch(t,
[]string{"session-scope-test-anomaly", "connection-scope-session"},
mockNames(unfiltered),
"Session/Connection-lifetime mocks must land in the session pool irrespective of metadata[\"scope\"] or whether req sits inside the current window")
}
// TestHasExplicitPort_IPv6_777 validates the hasExplicitPort function with various host strings,
// including IPv4, IPv6, and hostnames, both with and without ports.
func TestHasExplicitPort_IPv6_777(t *testing.T) {
testCases := []struct {
name string
host string
expected bool
}{
{"IPv4WithPort", "127.0.0.1:8080", true},
{"IPv4WithoutPort", "127.0.0.1", false},
{"IPv6WithPort", "[::1]:8080", true},
{"IPv6WithoutPort", "[::1]", false},
{"IPv6WithoutBrackets", "::1", false}, // Invalid for SplitHostPort, so false
{"HostnameWithPort", "localhost:8080", true},
{"HostnameWithoutPort", "localhost", false},
{"InvalidPort", "localhost:http", false}, // Non-numeric port
{"FullURL", "http://localhost:8080", false}, // SplitHostPort fails on scheme
{"IPv6ComplexWithPort", "[2001:db8::1]:8080", true},
{"IPv6ComplexWithoutPort", "[2001:db8::1]", false},
{"ColonInPath", "localhost:8080/foo", false}, // SplitHostPort fails
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, hasExplicitPort(tc.host))
})
}
}
// A URL that already says where the app is (test.basePath) is where the
// request goes under TargetFromURL, and under that address's name. The
// recorded app_port otherwise replaces the URL's port, and the recorded Host
// header the URL's host: the test goes to the port the app was recorded on,
// asking for the app by the name it had there.
func TestSimulateHTTP_TargetFromURLLeavesTheRecordedAddressOut(t *testing.T) {
// askedAs gets the Host header of each request the server answers.
askedAs := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
askedAs <- r.Host
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
serverHost := strings.TrimPrefix(server.URL, "http://")
// The port the test was recorded on: nothing listens there any more.
recordedPort := refusedPort(t)
newCase := func(appPort uint16) *models.TestCase {
return &models.TestCase{
Name: "get-orders",
AppPort: appPort,
HTTPReq: models.HTTPReq{
Method: "GET",
URL: server.URL + "/orders",
Header: map[string]string{"Host": "recorded.internal:8080"},
},
}
}
resp, err := SimulateHTTP(context.Background(), newCase(recordedPort), "test-set", zap.NewNop(),
SimulationConfig{APITimeout: 5, TargetFromURL: true})
require.NoError(t, err, "the request must go to the URL's own port")
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, serverHost, <-askedAs, "the app must be asked for by the URL's host")
// Without it the recorded port still wins, as it must for an app keploy
// starts, and so does the recorded name.
_, err = SimulateHTTP(context.Background(), newCase(recordedPort), "test-set", zap.NewNop(),
SimulationConfig{APITimeout: 5})
require.Error(t, err, "the recorded app_port did not replace the URL's port")
assert.Contains(t, err.Error(), fmt.Sprintf(":%d", recordedPort))
_, err = SimulateHTTP(context.Background(), newCase(0), "test-set", zap.NewNop(),
SimulationConfig{APITimeout: 5})
require.NoError(t, err)
assert.Equal(t, "recorded.internal:8080", <-askedAs, "the recorded Host header was not sent")
}
// Under TargetFromURL the app is asked for by the URL's host as a client
// writes it: without the port when it is the scheme's own. ResolveTestTarget
// writes that port into every URL, and left to Go the request asked for
// "staging.example.com:443", which a front that matches the name as written
// does not route to the app every client reaches as "staging.example.com".
func TestPrepareHTTPRequest_TargetFromURLAsksForTheHostAsAClientWritesIt(t *testing.T) {
for _, tc := range []struct {
name, url string
configPort uint32
want string
}{
{"http and no port", "http://staging.example.com/orders", 0, "staging.example.com"},
{"https and no port", "https://staging.example.com/orders", 0, "staging.example.com"},
{"the scheme's own port written out", "https://staging.example.com:443/orders", 0, "staging.example.com"},
{"IPv6 and no port", "http://[::1]/orders", 0, "[::1]"},
{"a port of its own", "http://staging.example.com:8080/orders", 0, "staging.example.com:8080"},
{"the other scheme's port", "http://staging.example.com:443/orders", 0, "staging.example.com:443"},
{"a port set for the tests", "http://staging.example.com/orders", 8080, "staging.example.com:8080"},
} {
t.Run(tc.name, func(t *testing.T) {
test := &models.TestCase{
Name: "get-orders",
AppPort: 9090,
HTTPReq: models.HTTPReq{
Method: "GET",
URL: tc.url,
Header: map[string]string{"Host": "recorded.internal:9090"},
},
}
prepared, err := prepareHTTPRequest(context.Background(), test, "test-set", zap.NewNop(),
SimulationConfig{TargetFromURL: true, ConfigPort: tc.configPort})
require.NoError(t, err)
// The request as it goes on the wire.
var wire bytes.Buffer
require.NoError(t, prepared.Request.Write(&wire))
assert.Contains(t, wire.String(), "\r\nHost: "+tc.want+"\r\n")
})
}
}
func TestResolveTestTarget(t *testing.T) {
logger := zap.NewNop()
tests := []struct {
name string
originalTarget string
urlReplacements map[string]string
portMappings map[uint32]uint32
configHost string
appPort uint16
configPort uint32
isHTTP bool
expectedTarget string
expectError bool
}{
// HTTP Scenarios
{
name: "HTTP_NoOverrides",
originalTarget: "http://example.com/api",
isHTTP: true,
expectedTarget: "http://example.com:80/api",
},
{
name: "HTTP_AppPortOverride",
originalTarget: "http://example.com/api",
appPort: 8080,
isHTTP: true,
expectedTarget: "http://example.com:8080/api",
},
{
name: "HTTP_ConfigPortOverride",
originalTarget: "http://example.com/api",
appPort: 8080,
configPort: 9090,
isHTTP: true,
expectedTarget: "http://example.com:9090/api",
},
{
name: "HTTP_ConfigHostOverride",
originalTarget: "http://example.com/api",
configHost: "localhost",
isHTTP: true,
expectedTarget: "http://localhost:80/api",
},
{
name: "HTTP_ReplacementWithPort_Final",
originalTarget: "http://example.com/api",
urlReplacements: map[string]string{"example.com": "localhost:3000"},
configPort: 9090, // Should be ignored
isHTTP: true,
expectedTarget: "http://localhost:3000/api",
},
{
name: "HTTP_ReplacementWithoutPort_AppliesOverrides",
originalTarget: "http://example.com/api",
urlReplacements: map[string]string{"example.com": "new-host"},
configPort: 9090,
isHTTP: true,
expectedTarget: "http://new-host:9090/api",
},
{
name: "HTTP_PortMappingOverridesReplacementPort",
originalTarget: "http://example.com/api",
urlReplacements: map[string]string{"example.com": "localhost:3000"},
portMappings: map[uint32]uint32{3000: 4000},
configPort: 9090, // Still ignored by replacement-with-port before mapping.
isHTTP: true,
expectedTarget: "http://localhost:4000/api",
},
{
name: "HTTP_PortMappingOverridesConfigAndAppPort",
originalTarget: "http://example.com/api",
appPort: 8080,
configPort: 9090,
portMappings: map[uint32]uint32{9090: 7070},
isHTTP: true,
expectedTarget: "http://example.com:7070/api",
},
{
name: "HTTPS_DefaultPort",
originalTarget: "https://example.com/api",
isHTTP: true,
expectedTarget: "https://example.com:443/api",
},
// gRPC Scenarios
{
name: "GRPC_NoOverrides",
originalTarget: "example.com",
isHTTP: false,
expectedTarget: "example.com:443",
},
{
name: "GRPC_ExistingPort_NoOverrides",
originalTarget: "example.com:50051",
isHTTP: false,
expectedTarget: "example.com:50051",
},
{
name: "GRPC_AppPortOverride",
originalTarget: "example.com",
appPort: 8080,
isHTTP: false,
expectedTarget: "example.com:8080",
},
{
name: "GRPC_ConfigPortOverride",
originalTarget: "example.com",
configPort: 9090,
isHTTP: false,
expectedTarget: "example.com:9090",
},
{
name: "GRPC_ConfigHostOverride",
originalTarget: "example.com:50051",
configHost: "localhost",
isHTTP: false,
expectedTarget: "localhost:50051",
},
{
name: "GRPC_ReplacementWithPort_Final",
originalTarget: "example.com:50051",
urlReplacements: map[string]string{"example.com": "localhost:3000"},
configPort: 9090, // Should be ignored
isHTTP: false,
expectedTarget: "localhost:3000:50051", // Replacement is literal substitution first
},
{
name: "GRPC_ReplacementWithPort_ExactMatch",
originalTarget: "example.com",
urlReplacements: map[string]string{"example.com": "localhost:3000"},
configPort: 9090,
isHTTP: false,
expectedTarget: "localhost:3000",
},
{
name: "GRPC_IPv6_Host",
originalTarget: "[::1]:50051",
configPort: 9090,
isHTTP: false,
expectedTarget: "[::1]:9090",
},
{
name: "GRPC_PortMappingOverridesConfigPort",
originalTarget: "example.com:50051",
configPort: 50052,
portMappings: map[uint32]uint32{50052: 50053},
isHTTP: false,
expectedTarget: "example.com:50053",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveTestTarget(tt.originalTarget, tt.urlReplacements, tt.portMappings, tt.configHost, tt.appPort, tt.configPort, tt.isHTTP, logger)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedTarget, got)
}
})
}
}
func TestResolveTestTarget_EdgeCases(t *testing.T) {
logger := zap.NewNop()
t.Run("HTTP_InvalidURL", func(t *testing.T) {
_, err := ResolveTestTarget("http://[::1]:namedport", nil, nil, "", 0, 0, true, logger)
assert.Error(t, err)
})
t.Run("GRPC_ConfigHost_ReplaceError", func(t *testing.T) {
// Mock logic or ensure specific error condition if possible, though ReplaceGrpcHost is robust
})
}
// TestFilterByTimeStampThreeTier validates the Lifetime-elevated three-
// pool partition. Contract:
// - LifetimeConnection → startupMocks (connection-scoped reuse)
// - LifetimeSession → unfilteredMocks (session-scoped reuse)
// - LifetimePerTest in-window → filteredMocks
// - legacy metadata.type=config → unfilteredMocks (fallback)
// - legacy metadata.type=connection → startupMocks (fallback)
// - legacy no-type per-test → filteredMocks (legacy fallback path)
// - startup-init (req < firstWindowStart) under strict → startupMocks
func TestFilterByTimeStampThreeTier(t *testing.T) {
if strictWindowEnvExplicitOff {
t.Skip("KEPLOY_STRICT_MOCK_WINDOW=0 forces strict off — strict-tier semantics not reachable")
}
logger := zap.NewNop()
ctx := context.Background()
firstWindowStart := time.Date(2026, 4, 22, 2, 0, 0, 0, time.UTC)
currentStart := time.Date(2026, 4, 22, 3, 0, 0, 0, time.UTC)
currentEnd := time.Date(2026, 4, 22, 3, 10, 0, 0, time.UTC)
// In-window PerTest → filtered.
inWindow := &models.Mock{
Name: "perTest-in", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: currentStart.Add(time.Minute),
ResTimestampMock: currentStart.Add(time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{
Lifetime: models.LifetimePerTest,
LifetimeDerived: true,
},
}
// Session → unfiltered.
session := &models.Mock{
Name: "session", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: firstWindowStart.Add(-10 * time.Minute),
ResTimestampMock: firstWindowStart.Add(-10*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{
Lifetime: models.LifetimeSession,
LifetimeDerived: true,
},
}
// Connection → startup.
conn := &models.Mock{
Name: "conn", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: firstWindowStart.Add(-5 * time.Minute),
ResTimestampMock: firstWindowStart.Add(-5*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{
Lifetime: models.LifetimeConnection,
LifetimeDerived: true,
},
}
// Startup-init PerTest (req < firstWindowStart) → startup.
startupInit := &models.Mock{
Name: "startup-init", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
ReqTimestampMock: firstWindowStart.Add(-2 * time.Minute),
ResTimestampMock: firstWindowStart.Add(-2*time.Minute + time.Millisecond),
},
TestModeInfo: models.TestModeInfo{
Lifetime: models.LifetimePerTest,
LifetimeDerived: true,
},
}
// Legacy config (no Lifetime, just metadata.type=config) → unfiltered.
legacyConfig := &models.Mock{
Name: "legacy-config", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
Metadata: map[string]string{"type": "config"},
ReqTimestampMock: firstWindowStart.Add(-20 * time.Minute),
ResTimestampMock: firstWindowStart.Add(-20*time.Minute + time.Millisecond),
},
}
// Legacy connection (no Lifetime, just metadata.type=connection) → startup.
legacyConn := &models.Mock{
Name: "legacy-conn", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
Metadata: map[string]string{"type": "connection"},
ReqTimestampMock: firstWindowStart.Add(-25 * time.Minute),
ResTimestampMock: firstWindowStart.Add(-25*time.Minute + time.Millisecond),
},
}
// Legacy per-test (no Lifetime, metadata.type="mocks") — treated as PerTest.
legacyPerTest := &models.Mock{
Name: "legacy-pertest", Version: "api.keploy.io/v1beta1",
Spec: models.MockSpec{
Metadata: map[string]string{"type": "mocks"},
ReqTimestampMock: currentStart.Add(2 * time.Minute),
ResTimestampMock: currentStart.Add(2*time.Minute + time.Millisecond),
},
}
all := []*models.Mock{inWindow, session, conn, startupInit, legacyConfig, legacyConn, legacyPerTest}
filtered, unfiltered, startup := FilterByTimeStampThreeTier(ctx, logger, all, currentStart, currentEnd, true, firstWindowStart)
// Helper to collect names.
names := func(ms []*models.Mock) []string {
out := make([]string, 0, len(ms))
for _, m := range ms {
out = append(out, m.Name)
}
return out
}
assert.ElementsMatch(t, []string{"perTest-in", "legacy-pertest"}, names(filtered),
"perTest in-window mocks land in filtered; legacy-untyped perTest falls through")
assert.ElementsMatch(t, []string{"session", "legacy-config"}, names(unfiltered),
"session-lifetime + legacy config-tagged land in unfiltered")
assert.ElementsMatch(t, []string{"conn", "startup-init", "legacy-conn"}, names(startup),
"connection-lifetime + startup-init PerTest + legacy connection-tagged land in startup")
}
// AgentHealthTicker must find an agent whose health answer takes longer than
// its first check waits. Reached through a Kubernetes port-forward over a slow
// link, every new connection costs several round trips before the request is
// even sent (two stream set-ups, then the GET): about 735ms at a 245ms round
// trip. A fixed 500ms per check cut every one of them off, so the agent was
// never found ready, however long the caller waited.
func TestAgentHealthTicker_ReachesAnAgentSlowerThanTheFirstCheck(t *testing.T) {
resetProbeOnce(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/agent/health" {
w.WriteHeader(http.StatusNotFound)
return
}
time.Sleep(750 * time.Millisecond)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`"OK"`))
}))
t.Cleanup(srv.Close)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
readyCh := make(chan bool, 1)
go AgentHealthTicker(ctx, zap.NewNop(), srv.URL+"/agent", readyCh)
ready, open := <-readyCh
require.True(t, open && ready, "the agent answered every health check in 750ms and was never found ready")
}
// A health check's headers are the agent's only when it answered 200: an
// answer other than 200 (a proxy's 503, an agent still starting) says nothing
// about the agent, whatever headers it carries, and is no answer at all, as
// none is when nothing listens. Each check here has a definite answer, so none
// has a deadline.
func TestAgentHealthAnswer_IsOnlyAHealthyAnswersHeaders(t *testing.T) {
answer := func(status int) string {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Keploy-Proven", "off")
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return srv.URL + "/agent"
}
header := agentHealthAnswer(context.Background(), zap.NewNop(), &http.Client{}, answer(http.StatusOK))
require.NotNil(t, header, "a 200 is a healthy answer")
require.Equal(t, "off", header.Get("X-Keploy-Proven"), "the headers returned are not the answer's")
require.Nil(t, agentHealthAnswer(context.Background(), zap.NewNop(), &http.Client{}, answer(http.StatusServiceUnavailable)),
"the headers of a 503 were taken for the agent's")
goneURL := "http://" + testnet.RefusedAddr(t, "tcp") // nothing answers there, nor can come to
require.Nil(t, agentHealthAnswer(context.Background(), zap.NewNop(), &http.Client{}, goneURL+"/agent"),
"an address nothing answers on gave headers")
}
// Each health check waits twice as long as the last one that ran out of time,
// up to a ceiling; a check that failed fast (nothing listening yet) or got an
// answer leaves the wait as it was, so a local agent keeps its quick check.
func TestNextAgentHealthWait(t *testing.T) {
for _, tc := range []struct {
name string
wait time.Duration
timedOut bool
want time.Duration
}{
{"first check timed out", agentHealthFirstWait, true, 2 * agentHealthFirstWait},
{"a later check timed out", 4 * time.Second, true, 8 * time.Second},
{"capped", agentHealthMaxWait, true, agentHealthMaxWait},
{"doubling past the cap stops at it", agentHealthMaxWait/2 + time.Second, true, agentHealthMaxWait},
{"failed fast", agentHealthFirstWait, false, agentHealthFirstWait},
{"answered", 2 * time.Second, false, 2 * time.Second},
} {
require.Equal(t, tc.want, nextAgentHealthWait(tc.wait, tc.timedOut), tc.name)
}
}
// A per-test mock whose recorder stated its lifetime (MetadataLifetimeKey) is
// the data of the test it was recorded in: the window filter serves it in its
// own window, and in the startup band when it predates the first test, and
// lax mode never promotes it to the reusable pool, where every other test
// would be served it. A per-test mock recorded before lifetimes were stated is
// promoted as it always was. KEPLOY_STRICT_MOCK_WINDOW=0, the escape hatch for
// recordings that predate classification, leaves a stated mock as stated.
func TestFilterByTimeStampTierAware_StatedPerTestIsNeverPromoted(t *testing.T) {
prevOverride, prevExplicitOff := strictWindowEnvOverride, strictWindowEnvExplicitOff
t.Cleanup(func() {
strictWindowEnvOverride, strictWindowEnvExplicitOff = prevOverride, prevExplicitOff
})
first := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) // the first test window starts
after := first.Add(10 * time.Second) // this test's window
before := after.Add(2 * time.Second)
mock := func(name string, req time.Time, stated string, lifetime models.Lifetime) *models.Mock {
m := &models.Mock{
Version: "api.keploy.io/v1beta1",
Name: name,
Kind: models.HTTP,
Spec: models.MockSpec{
Metadata: map[string]string{"type": "HTTP_CLIENT"},
ReqTimestampMock: req,
ResTimestampMock: req.Add(time.Millisecond),
},
TestModeInfo: models.TestModeInfo{Lifetime: lifetime, LifetimeDerived: true},
}
if stated != "" {
m.Spec.Metadata[models.MetadataLifetimeKey] = stated
}
return m
}
mocks := []*models.Mock{
mock("stated-startup", first.Add(-time.Second), models.MetadataLifetimePerTest, models.LifetimePerTest),
mock("stated-earlier-test", first.Add(time.Second), models.MetadataLifetimePerTest, models.LifetimePerTest),
mock("stated-own", after.Add(time.Second), models.MetadataLifetimePerTest, models.LifetimePerTest),
mock("stated-later-test", before.Add(time.Second), models.MetadataLifetimePerTest, models.LifetimePerTest),
mock("stated-session", first.Add(time.Second), models.MetadataLifetimeSession, models.LifetimeSession),
mock("unstated-earlier-test", first.Add(time.Second), "", models.LifetimePerTest),
}
for _, tc := range []struct {
name string
explicitOff bool
unfiltered []string
}{
{"lax", false, []string{"stated-session", "unstated-earlier-test"}},
{"KEPLOY_STRICT_MOCK_WINDOW=0", true, []string{"stated-session", "unstated-earlier-test"}},
} {
t.Run(tc.name, func(t *testing.T) {
strictWindowEnvOverride, strictWindowEnvExplicitOff = false, tc.explicitOff
filtered, unfiltered := filterByTimeStampTierAware(context.Background(), zap.NewNop(), mocks, after, before, false, first)
assert.ElementsMatch(t, []string{"stated-startup", "stated-own"}, mockNames(filtered),
"a stated per-test mock is served in its own window and the startup band only")
assert.ElementsMatch(t, tc.unfiltered, mockNames(unfiltered),
"lax promotion reaches unstated per-test mocks only")
})
}
}
// portServer is an HTTP server on 127.0.0.1 that answers each request with
// its own port, and that port.
func portServer(t *testing.T) (*httptest.Server, uint16) {
t.Helper()
var port uint16
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, port)
}))
t.Cleanup(srv.Close)
port = uint16(srv.Listener.Addr().(*net.TCPAddr).Port)
return srv, port
}
// portTest is a GET of url recorded on the app's port appPort.
func portTest(url string, appPort uint16) *models.TestCase {
return &models.TestCase{Name: "test-1", Kind: models.HTTP, AppPort: appPort,
HTTPReq: models.HTTPReq{Method: "GET", URL: url, Header: map[string]string{}}}
}
// A test goes to the port it was recorded on (app_port), over its URL's
// port: an app serving on two ports (HTTP on one, SSE on another) gets each
// test on its own (#3680). Nothing here publishes anything, as on a native
// replay: no PublishedPort.
func TestATestGoesToTheAppPortItWasRecordedOn(t *testing.T) {
a, pa := portServer(t)
_, pb := portServer(t)
for _, tc := range []struct {
url string
appPort uint16
want uint16
}{
{a.URL + "/x", pb, pb},
{a.URL + "/x", pa, pa},
{fmt.Sprintf("http://127.0.0.1:%d/x", pb), pa, pa},
{a.URL + "/x", 0, pa}, // no app_port: the URL's
} {
resp, err := SimulateHTTP(context.Background(), portTest(tc.url, tc.appPort), "test-set-0", zap.NewNop(), SimulationConfig{APITimeout: 5})
require.NoError(t, err)
require.Equal(t, fmt.Sprint(tc.want), resp.Body, "url %s, app_port %d", tc.url, tc.appPort)
}
}
// refusedPort is a port on 127.0.0.1 nothing listens on, held for the test's
// life so that no other socket can take it (testnet.RefusedAddr).
func refusedPort(t *testing.T) uint16 {
t.Helper()
_, p, err := net.SplitHostPort(testnet.RefusedAddr(t, "tcp"))
require.NoError(t, err)
port, err := strconv.ParseUint(p, 10, 16)
require.NoError(t, err)
return uint16(port)
}
// unreachableAt says every address is one the app can never be reached at,
// so a refused test fails at once, saying so.
type unreachableAt struct{}
func (unreachableAt) UnreachableAppPort(context.Context, string, uint16, uint16) string {
return "it is published on host port 18080 instead"
}
// A test recorded on an app port that the host reaches only through one other
// host port goes to that host port, which PublishedPort names; without it, it
// goes to its app port and fails, saying why. A port the user set
// (ConfigPort) is where it goes, and PublishedPort is not asked.
func TestATestOfAnAppPortPublishedOnOneHostPortGoesThere(t *testing.T) {
srv, hostPort := portServer(t)
appPort := refusedPort(t)
tc := func() *models.TestCase { return portTest(srv.URL+"/x", appPort) }
_, err := SimulateHTTP(context.Background(), tc(), "test-set-0", zap.NewNop(), SimulationConfig{APITimeout: 5, AppPortReachability: unreachableAt{}})
require.True(t, IsUnreachableAppPort(err), "without PublishedPort the test goes to its app port: %v", err)
require.Contains(t, err.Error(), "it is published on host port 18080 instead")
var asked []string
published := func(_ context.Context, host string, port uint16) uint16 {
asked = append(asked, fmt.Sprintf("%s %d", host, port))
if port == appPort {
return hostPort
}
return 0
}
resp, err := SimulateHTTP(context.Background(), tc(), "test-set-0", zap.NewNop(), SimulationConfig{APITimeout: 5, AppPortReachability: unreachableAt{}, PublishedPort: published})
require.NoError(t, err)
require.Equal(t, fmt.Sprint(hostPort), resp.Body)
require.Equal(t, []string{fmt.Sprintf("127.0.0.1 %d", appPort)}, asked)
asked = nil
resp, err = SimulateHTTP(context.Background(), tc(), "test-set-0", zap.NewNop(), SimulationConfig{APITimeout: 5, ConfigPort: uint32(hostPort), PublishedPort: published})
require.NoError(t, err)
require.Equal(t, fmt.Sprint(hostPort), resp.Body)
require.Empty(t, asked, "a port the user set is not redirected")
// A replaceWith that matches the recorded URL and names the port is the
// user's choice too: asked of the recorded URL, which the resolved one
// no longer contains.
recorded := strings.TrimPrefix(srv.URL, "http://")
_, err = SimulateHTTP(context.Background(), tc(), "test-set-0", zap.NewNop(), SimulationConfig{APITimeout: 5, AppPortReachability: unreachableAt{},
URLReplacements: map[string]string{recorded: fmt.Sprintf("localhost:%d", appPort)}, PublishedPort: published})
require.True(t, IsUnreachableAppPort(err), "a matching replaceWith naming the port sent the test to the published port: %v", err)
}
// PublishedTarget moves a target to the host port PublishedPort names only
// when it is the test's app port and nothing the user set moved this test: a
// replaceWith URL or port map that does not apply to it does not stop it.
func TestPublishedTargetMovesOnlyAnUnmovedAppPort(t *testing.T) {
to18080 := func(_ context.Context, _ string, appPort uint16) uint16 {
if appPort == 8080 {
return 18080
}
return 0
}
none := func(context.Context, string, uint16) uint16 { return 0 }
for _, tc := range []struct {
name string
cfg SimulationConfig
original string // the recorded URL or authority; "" means target
target string
appPort uint16
isHTTP bool
want string
}{
{"http, published elsewhere", SimulationConfig{PublishedPort: to18080}, "", "http://localhost:8080/x?q=1", 8080, true, "http://localhost:18080/x?q=1"},
{"http, IPv6 loopback", SimulationConfig{PublishedPort: to18080}, "", "http://[::1]:8080/x", 8080, true, "http://[::1]:18080/x"},
{"grpc authority", SimulationConfig{PublishedPort: to18080}, "", "localhost:8080", 8080, false, "localhost:18080"},
{"published as itself, several, or none", SimulationConfig{PublishedPort: none}, "", "http://localhost:8080/x", 8080, true, "http://localhost:8080/x"},
{"nothing to ask (native)", SimulationConfig{}, "", "http://localhost:8080/x", 8080, true, "http://localhost:8080/x"},
{"no app port", SimulationConfig{PublishedPort: to18080}, "", "http://localhost:8080/x", 0, true, "http://localhost:8080/x"},
{"target is not the app port", SimulationConfig{PublishedPort: to18080}, "", "http://localhost:9090/x", 8080, true, "http://localhost:9090/x"},
{"test.port", SimulationConfig{PublishedPort: to18080, ConfigPort: 8080}, "", "http://localhost:8080/x", 8080, true, "http://localhost:8080/x"},
{"a replaceWith URL that matches the test and names a port", SimulationConfig{PublishedPort: to18080, URLReplacements: map[string]string{"localhost:8080": "127.0.0.1:8080"}}, "http://localhost:8080/x", "http://127.0.0.1:8080/x", 8080, true, "http://127.0.0.1:8080/x"},
{"one with its scheme", SimulationConfig{PublishedPort: to18080, URLReplacements: map[string]string{"http://localhost:8080": "http://127.0.0.1:8080"}}, "http://localhost:8080/x", "http://127.0.0.1:8080/x", 8080, true, "http://127.0.0.1:8080/x"},
{"a replaceWith URL that matches the test and changes only its path", SimulationConfig{PublishedPort: to18080, URLReplacements: map[string]string{"/x": "/y"}}, "http://localhost:8080/x", "http://localhost:8080/y", 8080, true, "http://localhost:18080/y"},
{"a replaceWith URL that does not match the test", SimulationConfig{PublishedPort: to18080, URLReplacements: map[string]string{"staging.internal:9": "localhost:9"}}, "http://localhost:8080/x", "http://localhost:8080/x", 8080, true, "http://localhost:18080/x"},
{"a gRPC replaceWith that matches the authority", SimulationConfig{PublishedPort: to18080, URLReplacements: map[string]string{"api:8080": "localhost:8080"}}, "api:8080", "localhost:8080", 8080, false, "localhost:8080"},
{"a port map of the app port", SimulationConfig{PublishedPort: to18080, PortMappings: map[uint32]uint32{8080: 8080}}, "", "http://localhost:8080/x", 8080, true, "http://localhost:8080/x"},
{"a port map of another port", SimulationConfig{PublishedPort: to18080, PortMappings: map[uint32]uint32{9090: 9191}}, "", "http://localhost:8080/x", 8080, true, "http://localhost:18080/x"},
{"the URL names the app", SimulationConfig{PublishedPort: to18080, TargetFromURL: true}, "", "http://localhost:8080/x", 8080, true, "http://localhost:8080/x"},
} {
t.Run(tc.name, func(t *testing.T) {
original := tc.original
if original == "" {
original = tc.target
}
require.Equal(t, tc.want, PublishedTarget(context.Background(), tc.cfg, original, tc.target, tc.appPort, tc.isHTTP))
})
}
}
// Whether a replaceWith value names a port, as both resolvers ask it: after
// the scheme, for HTTP; a path after the host names none.
func TestReplacementNamesPort(t *testing.T) {
for _, tc := range []struct {
replacement string
isHTTP bool
want bool
}{
{"app.internal:9090", true, true},
{"http://app.internal:9090", true, true},
{"https://app.internal:8443", true, true},
{"https://app.internal", true, false},
{"http://app.internal", true, false},
{"[::1]:8080", false, true},
{"[::1]", false, false},
{"app.internal:8080/path", true, false},
{"app.internal", false, false},
} {
if got := replacementNamesPort(tc.replacement, tc.isHTTP); got != tc.want {
t.Errorf("replacementNamesPort(%q, %v) = %v, want %v", tc.replacement, tc.isHTTP, got, tc.want)
}
}
}
// When several replaceWith URL entries match a test, one is applied, the
// most specific, on every run, and PublishedTarget decides by that same one:
// it leaves the test where that entry sends it when the entry names a port,
// and moves it when the entry names none.
func TestSeveralMatchingReplacementsApplyTheMostSpecific(t *testing.T) {
to18080 := func(_ context.Context, _ string, appPort uint16) uint16 {
if appPort == 8080 {
return 18080
}
return 0
}
const recorded = "http://localhost:8080/x"
for _, tc := range []struct {
name string
urls map[string]string
resolved string
published string
}{
{"the most specific names a port", map[string]string{"localhost": "app.internal", "localhost:8080": "127.0.0.1:8080"},
"http://127.0.0.1:8080/x", "http://127.0.0.1:8080/x"},
{"the most specific names none", map[string]string{"localhost": "127.0.0.1:9090", "localhost:8080/x": "localhost:8080/y"},
"http://localhost:8080/y", "http://localhost:18080/y"},
} {
t.Run(tc.name, func(t *testing.T) {
for i := 0; i < 64; i++ { // a map's order differs from range to range
got, err := ResolveTestTarget(recorded, tc.urls, nil, "", 8080, 0, true, zap.NewNop())
require.NoError(t, err)
require.Equal(t, tc.resolved, got)
require.Equal(t, tc.published, PublishedTarget(context.Background(), SimulationConfig{PublishedPort: to18080, URLReplacements: tc.urls}, recorded, got, 8080, true))
}
})
}
}
// A replaceWith value with its scheme that names a port keeps that port: the
// scheme is not taken for a host, and the test's app port does not override
// it.
func TestAReplacementWithItsSchemeKeepsItsPort(t *testing.T) {
got, err := ResolveTestTarget("http://localhost:8080/x", map[string]string{"http://localhost:8080": "http://127.0.0.1:9090"}, nil, "", 8080, 0, true, zap.NewNop())
require.NoError(t, err)
require.Equal(t, "http://127.0.0.1:9090/x", got)
}