diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c457d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +docs \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6ec58be..73aef92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ +FROM ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:v0.158.0 AS telemetrygen + FROM golang:1.21.1-bookworm WORKDIR /tests COPY . . -RUN go test -c \ +COPY --from=telemetrygen /telemetrygen /usr/local/bin/telemetrygen + +RUN go test ./tests/integration/clients/... \ + && go test -c -o quest.test ./tests/integration \ && apt install wget \ && wget https://github.com/grafana/k6/releases/download/v0.46.0/k6-v0.46.0-linux-amd64.deb \ && apt install -f ./k6-v0.46.0-linux-amd64.deb \ @@ -12,6 +17,14 @@ RUN go test -c \ && apt install -y jq \ && wget https://github.com/mingrammer/flog/releases/download/v0.4.3/flog_0.4.3_linux_amd64.tar.gz \ && tar -xvf flog_0.4.3_linux_amd64.tar.gz \ - && cp flog /usr/local/bin + && cp flog /usr/local/bin \ + && pb_release_url=$(wget -qO- --server-response https://github.com/parseablehq/pb/releases/latest 2>&1 | awk '/^ Location: / { url=$2 } END { sub(/\r$/, "", url); print url }') \ + && pb_version=${pb_release_url##*/v} \ + && wget https://github.com/parseablehq/pb/releases/download/v${pb_version}/pb_${pb_version}_linux_amd64.tar.gz \ + && wget https://github.com/parseablehq/pb/releases/download/v${pb_version}/pb_${pb_version}_checksums.txt \ + && grep "pb_${pb_version}_linux_amd64.tar.gz" pb_${pb_version}_checksums.txt | sha256sum -c - \ + && tar -xzf pb_${pb_version}_linux_amd64.tar.gz pb \ + && install -m 0755 pb /usr/local/bin/pb \ + && pb --help > /dev/null ENTRYPOINT ["./main.sh"] diff --git a/main.sh b/main.sh index 76ed671..9e80ec4 100755 --- a/main.sh +++ b/main.sh @@ -37,11 +37,43 @@ minio_bucket=${11} ingestor_endpoint=${12} ingestor_username=${13} ingestor_password=${14} +edition=${QUEST_EDITION:-oss} stream_name=$(head /dev/urandom | tr -dc a-z | head -c10) +case "$edition" in + oss|enterprise) ;; + *) + echo "invalid QUEST_EDITION: $edition" >&2 + exit 1 + ;; +esac + +configure_pb () { + export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-/tmp/quest-pb-config}" + pb profile add quest "$endpoint" "$username" "$password" -o json \ + && pb profile default quest -o json +} + run () { - ./quest.test -test.v -mode="$mode" -query-url="$endpoint" -stream="$stream_name" -query-user="$username" -query-pass="$password" -minio-url="$minio_url" -minio-user="$minio_access_key" -minio-pass="$minio_secret_key" -minio-bucket="$minio_bucket" -ingestor-url="$ingestor_endpoint" -ingestor-user="$ingestor_username" -ingestor-pass="$ingestor_password" - return $? + echo "Running $edition integration tests" + + load_tests='^(TestLoadStreamBatchWithK6_StaticSchema|TestLoadStreamBatchWithK6|TestLoadStreamBatchWithCustomPartitionWithK6|TestLoadStreamNoBatchWithK6|TestLoadStreamNoBatchWithCustomPartitionWithK6|TestSmokeLoadWithK6Streams)$' + + if [ "$mode" = "load" ]; then + echo "Running functional integration test phase" + run_tests -test.skip="$load_tests" || return $? + + echo "Running k6 load test phase" + run_tests -test.run="$load_tests" + return $? + fi + + run_tests +} + +run_tests () { + ./quest.test -test.v -test.parallel=32 "$@" -edition="$edition" -mode="$mode" -query-url="$endpoint" -stream="$stream_name" -query-user="$username" -query-pass="$password" -minio-url="$minio_url" -minio-user="$minio_access_key" -minio-pass="$minio_secret_key" -minio-bucket="$minio_bucket" -ingestor-url="$ingestor_endpoint" -ingestor-user="$ingestor_username" -ingestor-pass="$ingestor_password" } +configure_pb || exit $? run diff --git a/quest_test.go b/quest_test.go deleted file mode 100644 index 8ebf253..0000000 --- a/quest_test.go +++ /dev/null @@ -1,840 +0,0 @@ -// Copyright (c) 2023 Cloudnatively Services Pvt Ltd -// -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "bytes" - "fmt" - "io" - "os/exec" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -const ( - vus = "10" - duration = "2m" - schema_count = "10" - events_count = "5" -) - -func TestSmokeListLogStream(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - req, err := NewGlob.QueryClient.NewRequest("GET", "logstream", nil) - require.NoErrorf(t, err, "Request failed: %s", err) - - response, err := NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status) - res, err := readJsonBody[[]string](bytes.NewBufferString(body)) - if err != nil { - for _, stream := range res { - if stream == NewGlob.Stream { - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) - } - } - } - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} - -func TestSmokeCreateStream(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} - -func TestSmokeDetectSchema(t *testing.T) { - DetectSchema(t, NewGlob.QueryClient, SampleJson, SchemaBody) -} - -func TestSmokeIngestEventsToStream(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - if NewGlob.IngestorUrl.String() == "" { - RunFlog(t, NewGlob.QueryClient, NewGlob.Stream) - } else { - RunFlog(t, NewGlob.IngestorClient, NewGlob.Stream) - } - // Calling Sleep method - time.Sleep(120 * time.Second) - - QueryLogStreamCount(t, NewGlob.QueryClient, NewGlob.Stream, 50) - AssertStreamSchema(t, NewGlob.QueryClient, NewGlob.Stream, FlogJsonSchema) - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} - -// func TestTimePartition_TimeStampMismatch(t *testing.T) { -// historicalStream := NewGlob.Stream + "historical" -// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} -// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) -// if NewGlob.IngestorUrl.String() == "" { -// IngestOneEventWithTimePartition_TimeStampMismatch(t, NewGlob.QueryClient, historicalStream) -// } else { -// IngestOneEventWithTimePartition_TimeStampMismatch(t, NewGlob.IngestorClient, historicalStream) -// } -// DeleteStream(t, NewGlob.QueryClient, historicalStream) -// } - -// func TestTimePartition_NoTimePartitionInLog(t *testing.T) { -// historicalStream := NewGlob.Stream + "historical" -// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} -// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) -// if NewGlob.IngestorUrl.String() == "" { -// IngestOneEventWithTimePartition_NoTimePartitionInLog(t, NewGlob.QueryClient, historicalStream) -// } else { -// IngestOneEventWithTimePartition_NoTimePartitionInLog(t, NewGlob.IngestorClient, historicalStream) -// } -// DeleteStream(t, NewGlob.QueryClient, historicalStream) -// } - -// func TestTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t *testing.T) { -// historicalStream := NewGlob.Stream + "historical" -// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} -// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) -// if NewGlob.IngestorUrl.String() == "" { -// IngestOneEventWithTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t, NewGlob.QueryClient, historicalStream) -// } else { -// IngestOneEventWithTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t, NewGlob.IngestorClient, historicalStream) -// } -// DeleteStream(t, NewGlob.QueryClient, historicalStream) -// } - -func TestLoadStream_StaticSchema_EventWithSameFields(t *testing.T) { - staticSchemaStream := NewGlob.Stream + "staticschema" - staticSchemaFlagHeader := map[string]string{"X-P-Static-Schema-Flag": "true"} - CreateStreamWithSchemaBody(t, NewGlob.QueryClient, staticSchemaStream, staticSchemaFlagHeader, SchemaPayload) - if NewGlob.IngestorUrl.String() == "" { - IngestOneEventForStaticSchemaStream_SameFieldsInLog(t, NewGlob.QueryClient, staticSchemaStream) - } else { - IngestOneEventForStaticSchemaStream_SameFieldsInLog(t, NewGlob.IngestorClient, staticSchemaStream) - } - DeleteStream(t, NewGlob.QueryClient, staticSchemaStream) -} - -func TestLoadStreamBatchWithK6_StaticSchema(t *testing.T) { - if NewGlob.Mode == "load" { - staticSchemaStream := NewGlob.Stream + "staticschema" - staticSchemaFlagHeader := map[string]string{"X-P-Static-Schema-Flag": "true"} - CreateStreamWithSchemaBody(t, NewGlob.QueryClient, staticSchemaStream, staticSchemaFlagHeader, SchemaPayload) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", &NewGlob.QueryUrl), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", staticSchemaStream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), - "./scripts/load_batch_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", &NewGlob.IngestorUrl), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", staticSchemaStream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), - "./scripts/load_batch_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } - - DeleteStream(t, NewGlob.QueryClient, staticSchemaStream) - } -} - -func TestLoadStream_StaticSchema_EventWithNewField(t *testing.T) { - staticSchemaStream := NewGlob.Stream + "staticschema" - staticSchemaFlagHeader := map[string]string{"X-P-Static-Schema-Flag": "true"} - CreateStreamWithSchemaBody(t, NewGlob.QueryClient, staticSchemaStream, staticSchemaFlagHeader, SchemaPayload) - if NewGlob.IngestorUrl.String() == "" { - IngestOneEventForStaticSchemaStream_NewFieldInLog(t, NewGlob.QueryClient, staticSchemaStream) - } else { - IngestOneEventForStaticSchemaStream_NewFieldInLog(t, NewGlob.IngestorClient, staticSchemaStream) - } - DeleteStream(t, NewGlob.QueryClient, staticSchemaStream) -} - -func TestCreateStream_WithCustomPartition_Success(t *testing.T) { - customPartitionStream := NewGlob.Stream + "custompartition" - customHeader := map[string]string{"X-P-Custom-Partition": "level"} - CreateStreamWithHeader(t, NewGlob.QueryClient, customPartitionStream, customHeader) - DeleteStream(t, NewGlob.QueryClient, customPartitionStream) -} - -func TestCreateStream_WithCustomPartition_Error(t *testing.T) { - customPartitionStream := NewGlob.Stream + "custompartition" - customHeader := map[string]string{"X-P-Custom-Partition": "level,os"} - CreateStreamWithCustompartitionError(t, NewGlob.QueryClient, customPartitionStream, customHeader) -} - -func TestSmokeQueryTwoStreams(t *testing.T) { - stream1 := NewGlob.Stream + "1" - stream2 := NewGlob.Stream + "2" - CreateStream(t, NewGlob.QueryClient, stream1) - CreateStream(t, NewGlob.QueryClient, stream2) - if NewGlob.IngestorUrl.String() == "" { - RunFlog(t, NewGlob.QueryClient, stream1) - RunFlog(t, NewGlob.QueryClient, stream2) - } else { - RunFlog(t, NewGlob.IngestorClient, stream1) - RunFlog(t, NewGlob.IngestorClient, stream2) - - } - time.Sleep(120 * time.Second) - QueryTwoLogStreamCount(t, NewGlob.QueryClient, stream1, stream2, 100) - DeleteStream(t, NewGlob.QueryClient, stream1) - DeleteStream(t, NewGlob.QueryClient, stream2) -} - -func TestSmokeRunQueries(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - if NewGlob.IngestorUrl.String() == "" { - RunFlog(t, NewGlob.QueryClient, NewGlob.Stream) - } else { - RunFlog(t, NewGlob.IngestorClient, NewGlob.Stream) - } - time.Sleep(120 * time.Second) - // test count - QueryLogStreamCount(t, NewGlob.QueryClient, NewGlob.Stream, 50) - // test yeild all values - AssertQueryOK(t, NewGlob.QueryClient, "SELECT * FROM %s", NewGlob.Stream) - AssertQueryOK(t, NewGlob.QueryClient, "SELECT * FROM %s OFFSET 25 LIMIT 25", NewGlob.Stream) - // test fetch single column - for _, item := range flogStreamFields() { - AssertQueryOK(t, NewGlob.QueryClient, "SELECT %s FROM %s", item, NewGlob.Stream) - } - // test basic filter - AssertQueryOK(t, NewGlob.QueryClient, "SELECT * FROM %s WHERE method = 'POST'", NewGlob.Stream) - // test group by - AssertQueryOK(t, NewGlob.QueryClient, "SELECT method, COUNT(*) FROM %s GROUP BY method", NewGlob.Stream) - AssertQueryOK(t, NewGlob.QueryClient, `SELECT DATE_TRUNC('minute', p_timestamp) as minute, COUNT(*) FROM %s GROUP BY minute`, NewGlob.Stream) - - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} - -func TestSmokeLoadWithK6Stream(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", NewGlob.Stream), - "./scripts/smoke.js") - - cmd.Run() - cmd.Output() - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", NewGlob.Stream), - "./scripts/smoke.js") - - cmd.Run() - cmd.Output() - } - time.Sleep(150 * time.Second) - QueryLogStreamCount(t, NewGlob.QueryClient, NewGlob.Stream, 20000) - AssertStreamSchema(t, NewGlob.QueryClient, NewGlob.Stream, SchemaBody) - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} - -// func TestSmokeLoad_TimePartition_WithK6Stream(t *testing.T) { -// time_partition_stream := NewGlob.Stream + "timepartition" -// timeHeader := map[string]string{"X-P-Time-Partition": "source_time", "X-P-Time-Partition-Limit": "365d"} -// CreateStreamWithHeader(t, NewGlob.QueryClient, time_partition_stream, timeHeader) -// if NewGlob.IngestorUrl.String() == "" { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", time_partition_stream), -// "./scripts/smoke.js") - -// cmd.Run() -// cmd.Output() -// } else { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", time_partition_stream), -// "./scripts/smoke.js") - -// cmd.Run() -// cmd.Output() -// } -// time.Sleep(120 * time.Second) -// QueryLogStreamCount_Historical(t, NewGlob.QueryClient, time_partition_stream, 20000) -// DeleteStream(t, NewGlob.QueryClient, time_partition_stream) -// } - -func TestSmokeLoad_CustomPartition_WithK6Stream(t *testing.T) { - custom_partition_stream := NewGlob.Stream + "custompartition" - customHeader := map[string]string{"X-P-Custom-Partition": "level"} - CreateStreamWithHeader(t, NewGlob.QueryClient, custom_partition_stream, customHeader) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", custom_partition_stream), - "./scripts/smoke.js") - - cmd.Run() - cmd.Output() - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", custom_partition_stream), - "./scripts/smoke.js") - - cmd.Run() - cmd.Output() - } - time.Sleep(120 * time.Second) - QueryLogStreamCount(t, NewGlob.QueryClient, custom_partition_stream, 20000) - DeleteStream(t, NewGlob.QueryClient, custom_partition_stream) -} - -// func TestSmokeLoad_TimeAndCustomPartition_WithK6Stream(t *testing.T) { -// custom_partition_stream := NewGlob.Stream + "timecustompartition" -// customHeader := map[string]string{"X-P-Custom-Partition": "level", "X-P-Time-Partition": "source_time", "X-P-Time-Partition-Limit": "365d"} -// CreateStreamWithHeader(t, NewGlob.QueryClient, custom_partition_stream, customHeader) -// if NewGlob.IngestorUrl.String() == "" { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", custom_partition_stream), -// "./scripts/smoke.js") - -// cmd.Run() -// cmd.Output() -// } else { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", custom_partition_stream), -// "./scripts/smoke.js") - -// cmd.Run() -// cmd.Output() -// } -// time.Sleep(180 * time.Second) -// QueryLogStreamCount_Historical(t, NewGlob.QueryClient, custom_partition_stream, 20000) -// DeleteStream(t, NewGlob.QueryClient, custom_partition_stream) -// } - -func TestSmokeSetTarget(t *testing.T) { - body := getTargetBody() - req, _ := NewGlob.QueryClient.NewRequest("POST", "/targets", strings.NewReader(body)) - response, err := NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) -} - -func TestSmokeSetAlert(t *testing.T) { - stream := NewGlob.Stream + "alert_testing" - CreateStream(t, NewGlob.QueryClient, stream) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", stream), - "./scripts/smoke.js") - - cmd.Run() - cmd.Output() - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", stream), - "./scripts/smoke.js") - - cmd.Run() - cmd.Output() - } - time.Sleep(120 * time.Second) - req, _ := NewGlob.QueryClient.NewRequest("GET", "/targets", nil) - response, err := NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - bodyTargets, _ := io.ReadAll(response.Body) - reader1 := bytes.NewReader(bodyTargets) - targetId := getIdFromTargetResponse(reader1) - body := getAlertBody(stream, targetId) - req, _ = NewGlob.QueryClient.NewRequest("POST", "/alerts", strings.NewReader(body)) - response, err = NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) -} - -func TestSmokeGetAlert(t *testing.T) { - stream := NewGlob.Stream + "alert_testing" - req, _ := NewGlob.QueryClient.NewRequest("GET", "/targets", nil) - response, err := NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - bodyTargets, _ := io.ReadAll(response.Body) - reader1 := bytes.NewReader(bodyTargets) - targetId := getIdFromTargetResponse(reader1) - req, _ = NewGlob.QueryClient.NewRequest("GET", "/alerts", nil) - response, err = NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body, _ := io.ReadAll(response.Body) - reader1 = bytes.NewReader(body) - reader2 := bytes.NewReader(body) - expected := readAsString(reader1) - id, state, created, datasets := getMetadataFromAlertResponse(reader2) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) - res := createAlertResponse(id, state, created, datasets) - require.JSONEq(t, expected, res, "Get alert response doesn't match with Alert config returned") - DeleteAlert(t, NewGlob.QueryClient, id) - DeleteTarget(t, NewGlob.QueryClient, targetId) - DeleteStream(t, NewGlob.QueryClient, stream) -} - -func TestSmokeSetRetention(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - req, _ := NewGlob.QueryClient.NewRequest("PUT", "logstream/"+NewGlob.Stream+"/retention", strings.NewReader(RetentionBody)) - response, err := NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) -} - -func TestSmokeGetRetention(t *testing.T) { - req, _ := NewGlob.QueryClient.NewRequest("GET", "logstream/"+NewGlob.Stream+"/retention", nil) - response, err := NewGlob.QueryClient.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) - require.JSONEq(t, RetentionBody, body, "Get retention response doesn't match with retention config returned") - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} - -// This test calls all the User API endpoints -// in a sequence to check if they work as expected. -func TestSmoke_AllUsersAPI(t *testing.T) { - CreateRole(t, NewGlob.QueryClient, "dummyrole", dummyRole) - AssertRole(t, NewGlob.QueryClient, "dummyrole", dummyRole) - - CreateUser(t, NewGlob.QueryClient, "dummyuser") - CreateUserWithRole(t, NewGlob.QueryClient, "dummyanotheruser", []string{"dummyrole"}) - AssertUserRole(t, NewGlob.QueryClient, "dummyanotheruser", "dummyrole", dummyRole) - RegenPassword(t, NewGlob.QueryClient, "dummyuser") - DeleteUser(t, NewGlob.QueryClient, "dummyuser") - DeleteUser(t, NewGlob.QueryClient, "dummyanotheruser") - DeleteRole(t, NewGlob.QueryClient, "dummyrole") -} - -// This test checks that a new user doesn't get any role by default -// even if a default role is set. -func TestSmoke_NewUserNoRole(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - - CreateRole(t, NewGlob.QueryClient, "dummyrole", dummyRole) - SetDefaultRole(t, NewGlob.QueryClient, "dummyrole") - AssertDefaultRole(t, NewGlob.QueryClient, "\"dummyrole\"") - - CreateUser(t, NewGlob.QueryClient, "dummyuser") - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} - -func TestSmokeRbacBasic(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - CreateRole(t, NewGlob.QueryClient, "dummy", dummyRole) - AssertRole(t, NewGlob.QueryClient, "dummy", dummyRole) - CreateUserWithRole(t, NewGlob.QueryClient, "dummy", []string{"dummy"}) - userClient := NewGlob.QueryClient - userClient.Username = "dummy" - userClient.Password = RegenPassword(t, NewGlob.QueryClient, "dummy") - checkAPIAccess(t, userClient, NewGlob.QueryClient, NewGlob.Stream, "editor") - DeleteUser(t, NewGlob.QueryClient, "dummy") - DeleteRole(t, NewGlob.QueryClient, "dummy") -} - -func TestSmokeRoles(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - cases := []struct { - roleName string - body string - }{ - { - roleName: "ingestor", - body: Roleingestor(NewGlob.Stream), - }, - { - roleName: "reader", - body: RoleReader(NewGlob.Stream), - }, - { - roleName: "writer", - body: RoleWriter(NewGlob.Stream), - }, - { - roleName: "editor", - body: RoleEditor, - }, - } - - for _, tc := range cases { - t.Run(tc.roleName, func(t *testing.T) { - CreateRole(t, NewGlob.QueryClient, tc.roleName, tc.body) - AssertRole(t, NewGlob.QueryClient, tc.roleName, tc.body) - username := tc.roleName + "_user" - password := CreateUserWithRole(t, NewGlob.QueryClient, username, []string{tc.roleName}) - var ingestClient HTTPClient - queryClient := NewGlob.QueryClient - queryClient.Username = username - queryClient.Password = password - if NewGlob.IngestorUrl.String() != "" { - ingestClient = NewGlob.IngestorClient - ingestClient.Username = username - ingestClient.Password = password - } else { - ingestClient = NewGlob.QueryClient - ingestClient.Username = username - ingestClient.Password = password - } - - checkAPIAccess(t, queryClient, ingestClient, NewGlob.Stream, tc.roleName) - DeleteUser(t, NewGlob.QueryClient, username) - DeleteRole(t, NewGlob.QueryClient, tc.roleName) - }) - } -} - -func TestLoadStreamBatchWithK6(t *testing.T) { - if NewGlob.Mode == "load" { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", NewGlob.Stream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), - "./scripts/load_batch_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", NewGlob.Stream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), - "./scripts/load_batch_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) - - } -} - -// func TestLoadHistoricalStreamBatchWithK6(t *testing.T) { -// if NewGlob.Mode == "load" { -// historicalStream := NewGlob.Stream + "historical" -// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} -// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) -// if NewGlob.IngestorUrl.String() == "" { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), -// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), -// "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), -// "./scripts/load_historical_batch_events.js", -// "--vus=", vus, -// "--duration=", duration) - -// cmd.Run() -// op, err := cmd.Output() -// if err != nil { -// t.Log(err) -// } -// t.Log(string(op)) -// } else { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), -// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), -// "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), -// "./scripts/load_historical_batch_events.js", -// "--vus=", vus, -// "--duration=", duration) - -// cmd.Run() -// op, err := cmd.Output() -// if err != nil { -// t.Log(err) -// } -// t.Log(string(op)) -// } - -// DeleteStream(t, NewGlob.QueryClient, historicalStream) -// } -// } - -func TestLoadStreamBatchWithCustomPartitionWithK6(t *testing.T) { - customPartitionStream := NewGlob.Stream + "custompartition" - customHeader := map[string]string{"X-P-Custom-Partition": "level"} - CreateStreamWithHeader(t, NewGlob.QueryClient, customPartitionStream, customHeader) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), - "./scripts/load_batch_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), - "./scripts/load_batch_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } - - DeleteStream(t, NewGlob.QueryClient, customPartitionStream) -} - -func TestLoadStreamNoBatchWithK6(t *testing.T) { - if NewGlob.Mode == "load" { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", NewGlob.Stream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "./scripts/load_single_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", NewGlob.Stream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "./scripts/load_single_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } - - } -} - -// func TestLoadHistoricalStreamNoBatchWithK6(t *testing.T) { -// if NewGlob.Mode == "load" { -// historicalStream := NewGlob.Stream + "historical" -// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} -// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) -// if NewGlob.IngestorUrl.String() == "" { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), -// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), -// "./scripts/load_single_events.js", -// "--vus=", vus, -// "--duration=", duration) - -// cmd.Run() -// op, err := cmd.Output() -// if err != nil { -// t.Log(err) -// } -// t.Log(string(op)) -// } else { -// cmd := exec.Command("k6", -// "run", -// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), -// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), -// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), -// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), -// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), -// "./scripts/load_single_events.js", -// "--vus=", vus, -// "--duration=", duration) - -// cmd.Run() -// op, err := cmd.Output() -// if err != nil { -// t.Log(err) -// } -// t.Log(string(op)) -// } - -// DeleteStream(t, NewGlob.QueryClient, historicalStream) -// } -// } - -func TestLoadStreamNoBatchWithCustomPartitionWithK6(t *testing.T) { - customPartitionStream := NewGlob.Stream + "custompartition" - customHeader := map[string]string{"X-P-Custom-Partition": "level"} - CreateStreamWithHeader(t, NewGlob.QueryClient, customPartitionStream, customHeader) - if NewGlob.IngestorUrl.String() == "" { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), - "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "./scripts/load_single_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } else { - cmd := exec.Command("k6", - "run", - "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), - "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), - "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), - "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), - "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), - "./scripts/load_single_events.js", - "--vus=", vus, - "--duration=", duration) - - cmd.Run() - op, err := cmd.Output() - if err != nil { - t.Log(err) - } - t.Log(string(op)) - } - - DeleteStream(t, NewGlob.QueryClient, customPartitionStream) -} - -func TestDeleteStream(t *testing.T) { - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) -} diff --git a/scripts/load_single_event.js b/scripts/load_single_event.js index 54b7b93..9a468f1 100644 --- a/scripts/load_single_event.js +++ b/scripts/load_single_event.js @@ -1,5 +1,6 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; +import exec from 'k6/execution'; import encoding from 'k6/encoding'; import { randomString, randomItem, randomIntBetween, uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js' @@ -167,5 +168,11 @@ export default function () { } let batch_requests = generateEvents(1).map(event => ['POST', url, event, params]); - http.batch(batch_requests); -} \ No newline at end of file + let responses = http.batch(batch_requests); + + if (!check(responses, { + 'status code MUST be 200': (responses) => responses.every(response => response.status == 200), + })) { + exec.test.abort("Failed to send event.. status != 200"); + } +} diff --git a/tests/integration/agent_observability_test.go b/tests/integration/agent_observability_test.go new file mode 100644 index 0000000..adcf820 --- /dev/null +++ b/tests/integration/agent_observability_test.go @@ -0,0 +1,517 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + agentObservabilityModel = "gpt-4o-mini" + agentObservabilityTool = "search_orders" + agentObservabilityUser = "quest-user" +) + +type agentValidationResponse struct { + Datasets []struct { + Dataset string `json:"dataset"` + LogSource string `json:"logSource"` + Valid bool `json:"valid"` + MissingFields []string `json:"missingFields"` + Message string `json:"message"` + } `json:"datasets"` + Errors []string `json:"errors"` +} + +type agentMetric struct { + Current float64 `json:"current"` +} + +type agentOverviewResponse struct { + Section1 struct { + InvocationCount agentMetric `json:"invocationCount"` + ErrorCount agentMetric `json:"errorCount"` + TotalTokens agentMetric `json:"totalTokens"` + TotalLLMCalls agentMetric `json:"totalLlmCalls"` + TotalToolCalls agentMetric `json:"totalToolCalls"` + } `json:"section1"` + Section2 struct { + ToolUsage []struct { + ToolName string `json:"toolName"` + Calls float64 `json:"calls"` + } `json:"toolUsagePercentage"` + TokenUsage []struct { + User string `json:"user"` + Model string `json:"model"` + InputTokens float64 `json:"inputTokens"` + OutputTokens float64 `json:"outputTokens"` + } `json:"tokenUsageByUserAndModel"` + } `json:"section2"` +} + +type agentModelsResponse struct { + Cards struct { + InputTokens agentMetric `json:"inputTokens"` + OutputTokens agentMetric `json:"outputTokens"` + MostUsedModel struct { + Name string `json:"name"` + } `json:"mostUsedModel"` + } `json:"cards"` + Charts struct { + TotalTokensByModel []struct { + Name string `json:"name"` + Value float64 `json:"value"` + } `json:"totalTokensByModel"` + } `json:"charts"` +} + +type agentToolsResponse struct { + Cards struct { + TotalToolCalls agentMetric `json:"totalToolCalls"` + Errors agentMetric `json:"errors"` + MostUsedTool struct { + Name string `json:"name"` + } `json:"mostUsedTool"` + } `json:"cards"` + Charts struct { + ToolUsage []struct { + ToolName string `json:"toolName"` + Calls float64 `json:"calls"` + } `json:"toolUsage"` + ToolFailures struct { + Points []struct { + TraceID string `json:"traceId"` + ToolName string `json:"toolName"` + } `json:"points"` + } `json:"toolFailures"` + } `json:"charts"` +} + +type agentListingRecord struct { + TraceID string `json:"traceId"` + Models string `json:"models"` + Model string `json:"model"` + InputTokens float64 `json:"inputTokens"` + OutputTokens float64 `json:"outputTokens"` + InvocationError bool `json:"invocationError"` + ToolCalls float64 `json:"toolCalls"` +} + +type agentListingResponse struct { + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Records []agentListingRecord `json:"records"` +} + +type agentDetailResponse struct { + TraceID string `json:"traceId"` + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Spans []struct { + SpanID string `json:"spanId"` + ParentSpanID string `json:"parentSpanId"` + ServiceName string `json:"serviceName"` + SpanName string `json:"spanName"` + TraceID string `json:"traceId"` + OperationName string `json:"operationName"` + ToolName string `json:"toolName"` + ToolCallID string `json:"toolCallId"` + Model string `json:"model"` + InputTokens float64 `json:"inputTokens"` + OutputTokens float64 `json:"outputTokens"` + HasError bool `json:"hasError"` + Level float64 `json:"level"` + } `json:"spans"` + Events []struct { + EventName string `json:"eventName"` + } `json:"events"` +} + +func agentObservabilityRequest(t *testing.T, path string, payload any, output any) { + t.Helper() + encoded, err := json.Marshal(payload) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequestAtPath("POST", path, bytes.NewReader(encoded)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + body := readAsString(response.Body) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) + require.NoError(t, json.Unmarshal([]byte(body), output)) +} + +func createAgentObservabilityDataset(t *testing.T, dataset string) { + t.Helper() + CreateStreamWithHeader(t, NewGlob.QueryClient, dataset, map[string]string{ + "X-P-Telemetry-Type": "traces", + "X-P-Log-Source": "otel-traces", + "X-P-Dataset-Tags": "agent-observability", + }) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, dataset) + }) +} + +func ingestAgentObservabilityFixture(t *testing.T, dataset, traceID string) { + t.Helper() + client := NewGlob.QueryClient + if NewGlob.IngestorUrl.String() != "" { + client = NewGlob.IngestorClient + } + + start := time.Now().UTC().Add(-3 * time.Second) + rootSpanID := "aaaaaaaaaaaaaaaa" + chatSpanID := "bbbbbbbbbbbbbbbb" + toolSpanID := "cccccccccccccccc" + inputMessages := `[{"role":"user","parts":[{"type":"text","content":"Find order 123"}]}]` + outputMessages := `[{"role":"assistant","parts":[{"type":"text","content":"Order 123 is shipped"}]}]` + + rootSpan := map[string]any{ + "traceId": traceID, "spanId": rootSpanID, "name": "quest-agent-run", "kind": 1, + "startTimeUnixNano": fmt.Sprint(start.UnixNano()), + "endTimeUnixNano": fmt.Sprint(start.Add(2500 * time.Millisecond).UnixNano()), + "status": map[string]any{"code": 2, "message": "quest invocation failed"}, + "attributes": []any{ + otlpStringAttribute("gen_ai.operation.name", "invoke_agent"), + otlpStringAttribute("gen_ai.agent.name", "quest-agent"), + otlpStringAttribute("gen_ai.agent.id", "quest-agent-1"), + otlpStringAttribute("gen_ai.conversation.id", "quest-conversation-1"), + otlpStringAttribute("user.name", agentObservabilityUser), + otlpStringAttribute("team.name", "quest-team"), + otlpStringAttribute("error.message", "quest invocation failed"), + }, + } + chatSpan := map[string]any{ + "traceId": traceID, "spanId": chatSpanID, "parentSpanId": rootSpanID, "name": "quest-agent-chat", "kind": 3, + "startTimeUnixNano": fmt.Sprint(start.Add(200 * time.Millisecond).UnixNano()), + "endTimeUnixNano": fmt.Sprint(start.Add(1500 * time.Millisecond).UnixNano()), + "status": map[string]int{"code": 1}, + "attributes": []any{ + otlpStringAttribute("gen_ai.operation.name", "chat"), + otlpStringAttribute("gen_ai.provider.name", "openai"), + otlpStringAttribute("gen_ai.request.model", agentObservabilityModel), + otlpStringAttribute("gen_ai.response.model", agentObservabilityModel), + otlpIntAttribute("gen_ai.usage.input_tokens", 20), + otlpIntAttribute("gen_ai.usage.output_tokens", 10), + otlpIntAttribute("gen_ai.usage.cached_tokens", 2), + otlpStringAttribute("gen_ai.input.messages", inputMessages), + otlpStringAttribute("gen_ai.output.messages", outputMessages), + }, + } + toolSpan := map[string]any{ + "traceId": traceID, "spanId": toolSpanID, "parentSpanId": rootSpanID, "name": "quest-search-orders", "kind": 3, + "startTimeUnixNano": fmt.Sprint(start.Add(1600 * time.Millisecond).UnixNano()), + "endTimeUnixNano": fmt.Sprint(start.Add(2200 * time.Millisecond).UnixNano()), + "status": map[string]any{"code": 2, "message": "quest tool failure"}, + "attributes": []any{ + otlpStringAttribute("gen_ai.operation.name", "execute_tool"), + otlpStringAttribute("gen_ai.tool.name", agentObservabilityTool), + otlpStringAttribute("gen_ai.tool.call.id", "quest-tool-call-1"), + otlpStringAttribute("gen_ai.tool.call.arguments", `{"order_id":"123"}`), + otlpStringAttribute("gen_ai.tool.call.result", `{"status":"shipped"}`), + otlpStringAttribute("error.message", "quest tool failure"), + }, + } + + payload := map[string]any{"resourceSpans": []any{map[string]any{ + "resource": map[string]any{"attributes": []any{ + otlpStringAttribute("service.name", "quest-agent-service"), + }}, + "scopeSpans": []any{map[string]any{ + "scope": map[string]string{"name": "quest-agent-observability"}, + "spans": []any{rootSpan, chatSpan, toolSpan}, + }}, + }}} + encoded, err := json.Marshal(payload) + require.NoError(t, err) + req, err := client.NewRequestAtPath("POST", "/v1/traces", bytes.NewReader(encoded)) + require.NoError(t, err) + req.Header.Set("X-P-Stream", dataset) + req.Header.Set("X-P-Log-Source", "otel-traces") + response, err := client.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "OTLP agent fixture failed: %s", readAsString(response.Body)) +} + +func waitForValidAgentDataset(t *testing.T, dataset string) { + t.Helper() + var lastBody string + deadline := time.Now().Add(traceVisibilityTimeout) + for { + valid := func() bool { + infoPath := fmt.Sprintf("api/prism/v1/logstream/%s/info", dataset) + infoRequest, err := NewGlob.QueryClient.NewRequestAtPath("GET", infoPath, nil) + if err != nil { + lastBody = err.Error() + return false + } + infoResponse, err := NewGlob.QueryClient.Do(infoRequest) + if err != nil { + lastBody = err.Error() + return false + } + infoBody := readAsString(infoResponse.Body) + infoResponse.Body.Close() + if infoResponse.StatusCode != 200 { + lastBody = fmt.Sprintf("dataset info status=%d body=%s", infoResponse.StatusCode, infoBody) + return false + } + + req, err := NewGlob.QueryClient.NewRequestAtPath("GET", "api/prism/v1/agent-observability/validate", nil) + if err != nil { + lastBody = err.Error() + return false + } + response, err := NewGlob.QueryClient.Do(req) + if err != nil { + lastBody = err.Error() + return false + } + lastBody = readAsString(response.Body) + response.Body.Close() + if response.StatusCode != 200 { + return false + } + var result agentValidationResponse + if json.Unmarshal([]byte(lastBody), &result) != nil { + return false + } + for _, candidate := range result.Datasets { + if candidate.Dataset == dataset { + return candidate.LogSource == "otel-traces" && candidate.Valid && len(candidate.MissingFields) == 0 + } + } + return false + }() + if valid { + return + } + if time.Now().After(deadline) { + require.FailNowf(t, "agent dataset did not become valid", "dataset=%s last response=%s", dataset, lastBody) + } + time.Sleep(5 * time.Second) + } +} + +func waitForAgentListing(t *testing.T, payload map[string]any, traceID string) agentListingResponse { + t.Helper() + var result agentListingResponse + var lastBody string + deadline := time.Now().Add(traceVisibilityTimeout) + for { + found := func() bool { + encoded, err := json.Marshal(payload) + if err != nil { + lastBody = err.Error() + return false + } + req, err := NewGlob.QueryClient.NewRequestAtPath("POST", "api/prism/v1/agent-observability/listing", bytes.NewReader(encoded)) + if err != nil { + lastBody = err.Error() + return false + } + response, err := NewGlob.QueryClient.Do(req) + if err != nil { + lastBody = err.Error() + return false + } + lastBody = readAsString(response.Body) + response.Body.Close() + if response.StatusCode != 200 || json.Unmarshal([]byte(lastBody), &result) != nil { + return false + } + for _, record := range result.Records { + if record.TraceID == traceID { + return true + } + } + return false + }() + if found { + return result + } + if time.Now().After(deadline) { + require.FailNowf(t, "agent trace did not become queryable", "trace=%s last response=%s", traceID, lastBody) + } + time.Sleep(5 * time.Second) + } +} + +func TestEnterpriseAgentObservability(t *testing.T) { + // Verifies tagged GenAI traces across all Agent Observability feature APIs. + if NewGlob.Edition != "enterprise" { + t.Skip("Agent Observability is only available in Enterprise") + } + t.Parallel() + + dataset := NewGlob.Stream + "agentobservability" + traceID := fmt.Sprintf("%032x", time.Now().UnixNano()) + createAgentObservabilityDataset(t, dataset) + ingestAgentObservabilityFixture(t, dataset, traceID) + waitForValidAgentDataset(t, dataset) + + window := map[string]any{ + "dataset": dataset, + "startTime": time.Now().UTC().Add(-15 * time.Minute).Format(time.RFC3339Nano), + "endTime": time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339Nano), + } + listingPayload := map[string]any{ + "dataset": window["dataset"], + "startTime": window["startTime"], + "endTime": window["endTime"], + "limit": 10, + "offset": 0, + "provider": "openai", + } + listing := waitForAgentListing(t, listingPayload, traceID) + require.GreaterOrEqual(t, listing.Total, 1) + require.Equal(t, 10, listing.Limit) + require.Equal(t, 0, listing.Offset) + var listedTrace *agentListingRecord + for index := range listing.Records { + if listing.Records[index].TraceID == traceID { + listedTrace = &listing.Records[index] + break + } + } + require.NotNil(t, listedTrace) + require.True(t, strings.Contains(listedTrace.Models, agentObservabilityModel) || listedTrace.Model == agentObservabilityModel) + require.GreaterOrEqual(t, listedTrace.InputTokens, float64(20)) + require.GreaterOrEqual(t, listedTrace.OutputTokens, float64(10)) + require.True(t, listedTrace.InvocationError) + require.GreaterOrEqual(t, listedTrace.ToolCalls, float64(1)) + + t.Run("FeatureResponses", func(t *testing.T) { + t.Run("Overview", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "dataset": window["dataset"], "startTime": window["startTime"], "endTime": window["endTime"], + "provider": "openai", "numBins": 10, + } + var result agentOverviewResponse + agentObservabilityRequest(t, "api/prism/v1/agent-observability/overview", payload, &result) + require.GreaterOrEqual(t, result.Section1.InvocationCount.Current, float64(1)) + require.GreaterOrEqual(t, result.Section1.ErrorCount.Current, float64(1)) + require.GreaterOrEqual(t, result.Section1.TotalTokens.Current, float64(30)) + require.GreaterOrEqual(t, result.Section1.TotalLLMCalls.Current, float64(1)) + require.GreaterOrEqual(t, result.Section1.TotalToolCalls.Current, float64(1)) + require.Contains(t, result.Section2.ToolUsage, struct { + ToolName string `json:"toolName"` + Calls float64 `json:"calls"` + }{ToolName: agentObservabilityTool, Calls: 1}) + require.NotEmpty(t, result.Section2.TokenUsage) + require.Equal(t, agentObservabilityUser, result.Section2.TokenUsage[0].User) + require.Equal(t, agentObservabilityModel, result.Section2.TokenUsage[0].Model) + }) + + t.Run("Models", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "dataset": window["dataset"], "startTime": window["startTime"], "endTime": window["endTime"], + "provider": "openai", + } + var result agentModelsResponse + agentObservabilityRequest(t, "api/prism/v1/agent-observability/models", payload, &result) + require.GreaterOrEqual(t, result.Cards.InputTokens.Current, float64(20)) + require.GreaterOrEqual(t, result.Cards.OutputTokens.Current, float64(10)) + require.Equal(t, agentObservabilityModel, result.Cards.MostUsedModel.Name) + var modelFound bool + for _, model := range result.Charts.TotalTokensByModel { + if model.Name == agentObservabilityModel && model.Value >= 30 { + modelFound = true + } + } + require.True(t, modelFound, "model totals did not include the ingested model") + }) + + t.Run("Tools", func(t *testing.T) { + t.Parallel() + var result agentToolsResponse + agentObservabilityRequest(t, "api/prism/v1/agent-observability/tools", window, &result) + require.GreaterOrEqual(t, result.Cards.TotalToolCalls.Current, float64(1)) + require.GreaterOrEqual(t, result.Cards.Errors.Current, float64(1)) + require.Equal(t, agentObservabilityTool, result.Cards.MostUsedTool.Name) + var toolFound bool + for _, tool := range result.Charts.ToolUsage { + if tool.ToolName == agentObservabilityTool && tool.Calls >= 1 { + toolFound = true + } + } + require.True(t, toolFound, "tool usage did not include the ingested tool") + var failureFound bool + for _, failure := range result.Charts.ToolFailures.Points { + if failure.TraceID == traceID && failure.ToolName == agentObservabilityTool { + failureFound = true + } + } + require.True(t, failureFound, "tool failures did not include the ingested failed tool span") + }) + }) + + t.Run("Detail", func(t *testing.T) { + payload := map[string]any{ + "dataset": window["dataset"], "traceId": traceID, + "startTime": window["startTime"], "endTime": window["endTime"], "provider": "openai", + } + var result agentDetailResponse + agentObservabilityRequest(t, "api/prism/v1/agent-observability/detail", payload, &result) + require.Equal(t, traceID, result.TraceID) + require.Len(t, result.Spans, 3) + operations := make(map[string]bool) + for _, span := range result.Spans { + require.Equal(t, traceID, span.TraceID) + require.Equal(t, "quest-agent-service", span.ServiceName) + operations[span.OperationName] = true + if span.OperationName == "chat" { + require.Equal(t, agentObservabilityModel, span.Model) + require.GreaterOrEqual(t, span.InputTokens, float64(20)) + require.GreaterOrEqual(t, span.OutputTokens, float64(10)) + } + if span.OperationName == "execute_tool" { + require.Equal(t, agentObservabilityTool, span.ToolName) + require.Equal(t, "quest-tool-call-1", span.ToolCallID) + require.True(t, span.HasError) + } + } + require.True(t, operations["invoke_agent"]) + require.True(t, operations["chat"]) + require.True(t, operations["execute_tool"]) + require.NotEmpty(t, result.Messages) + roles := make(map[string]bool) + for _, message := range result.Messages { + roles[message.Role] = true + } + require.True(t, roles["user"]) + require.True(t, roles["assistant"]) + eventNames := make(map[string]bool) + for _, event := range result.Events { + eventNames[event.EventName] = true + } + require.True(t, eventNames["gen_ai.tool.call"]) + require.True(t, eventNames["gen_ai.tool.output"]) + }) +} diff --git a/tests/integration/alert_test.go b/tests/integration/alert_test.go new file mode 100644 index 0000000..e4f6740 --- /dev/null +++ b/tests/integration/alert_test.go @@ -0,0 +1,434 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type testTargetResponse struct { + Target testTarget `json:"target"` + Enabled bool `json:"enabled"` +} + +type testTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Endpoint string `json:"endpoint"` + Headers map[string]string `json:"headers"` + SkipTLSCheck bool `json:"skipTlsCheck"` +} + +type testAlertTargetPolicy struct { + AllowPrivate bool `json:"allowPrivate"` + AllowedDomains []string `json:"allowedDomains"` + AllowedCIDRs []string `json:"allowedCidrs"` + DeniedDomains []string `json:"deniedDomains"` + DeniedCIDRs []string `json:"deniedCidrs"` + AllowInvalidTLS bool `json:"allowInvalidTls"` +} + +type testAlertResponse struct { + Severity string `json:"severity"` + Title string `json:"title"` + ID string `json:"id"` + State string `json:"state"` + AlertType string `json:"alertType"` + Tags []string `json:"tags"` + Created string `json:"created"` + Datasets []string `json:"datasets"` + LastTriggeredAt string `json:"lastTriggeredAt"` + MetricName string `json:"metric_name"` +} + +func ingestAlertFixture(t *testing.T, stream string) { + t.Helper() + client := NewGlob.QueryClient + if NewGlob.IngestorUrl.String() != "" { + client = NewGlob.IngestorClient + } + req, _ := client.NewRequest("POST", "ingest", strings.NewReader(`[{"level":"info"}]`)) + req.Header.Add("X-P-Stream", stream) + response, err := client.Do(req) + require.NoErrorf(t, err, "Request failed: %s", err) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) +} + +func createTestTarget(t *testing.T, name string) string { + t.Helper() + body := fmt.Sprintf(`{ + "name": %q, + "type": "webhook", + "endpoint": "https://webhook.site/ec627445-d52b-44e9-948d-56671df3581e", + "headers": {}, + "skipTlsCheck": false + }`, name) + req, _ := NewGlob.QueryClient.NewRequest("POST", "/targets", strings.NewReader(body)) + response, err := NewGlob.QueryClient.Do(req) + require.NoErrorf(t, err, "Request failed: %s", err) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) + + req, _ = NewGlob.QueryClient.NewRequest("GET", "/targets", nil) + response, err = NewGlob.QueryClient.Do(req) + require.NoErrorf(t, err, "Request failed: %s", err) + targets, err := readJsonBody[[]testTargetResponse](response.Body) + require.NoError(t, err) + for _, target := range targets { + if target.Target.Name == name { + return target.Target.ID + } + } + t.Fatalf("target %q was not returned by GET /targets", name) + return "" +} + +func getTestTargetByID(t *testing.T, targetID string) testTargetResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", "/targets/"+targetID, nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + target, err := readJsonBody[testTargetResponse](response.Body) + require.NoError(t, err) + return target +} + +func updateTestTarget(t *testing.T, targetID, name string) testTarget { + t.Helper() + body := fmt.Sprintf(`{ + "name": %q, + "type": "webhook", + "endpoint": "https://webhook.site/ec627445-d52b-44e9-948d-56671df3581e", + "headers": {"X-Quest-Test": "updated"}, + "skipTlsCheck": false + }`, name) + req, err := NewGlob.QueryClient.NewRequest("PUT", "/targets/"+targetID, strings.NewReader(body)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + target, err := readJsonBody[testTarget](response.Body) + require.NoError(t, err) + return target +} + +func getTestAlertTargetPolicy(t *testing.T) testAlertTargetPolicy { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", "/alert-target-policy", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + policy, err := readJsonBody[testAlertTargetPolicy](response.Body) + require.NoError(t, err) + return policy +} + +func updateTestAlertTargetPolicy(t *testing.T, policy testAlertTargetPolicy) testAlertTargetPolicy { + t.Helper() + body, err := json.Marshal(policy) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequest("PUT", "/alert-target-policy", bytes.NewReader(body)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + updated, err := readJsonBody[testAlertTargetPolicy](response.Body) + require.NoError(t, err) + return updated +} + +func testAlertRequestBody(stream, targetID, title, metricName string) string { + body := strings.Replace(getAlertBody(stream, targetID, metricName), `"title": "AlertTitle"`, fmt.Sprintf(`"title": %q`, title), 1) + body = strings.Replace(body, `"value": 100`, `"value": 1`, 1) + return strings.Replace(body, `"evalFrequency": 1`, `"evalFrequency": 10`, 1) +} + +func createTestAlert(t *testing.T, stream, targetID, title, metricName string) string { + t.Helper() + body := testAlertRequestBody(stream, targetID, title, metricName) + req, _ := NewGlob.QueryClient.NewRequest("POST", "/alerts", strings.NewReader(body)) + response, err := NewGlob.QueryClient.Do(req) + require.NoErrorf(t, err, "Request failed: %s", err) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) + + alert := getTestAlert(t, title) + return alert.ID +} + +func listTestAlerts(t *testing.T, path string) []testAlertResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", path, nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + alerts, err := readJsonBody[[]testAlertResponse](response.Body) + require.NoError(t, err) + return alerts +} + +func requireTestAlertListed(t *testing.T, alerts []testAlertResponse, alertID string) { + t.Helper() + for _, alert := range alerts { + if alert.ID == alertID { + return + } + } + t.Fatalf("alert %q was not returned", alertID) +} + +func updateTestAlert(t *testing.T, alertID, stream, targetID, title, metricName string) testAlertResponse { + t.Helper() + body := testAlertRequestBody(stream, targetID, title, metricName) + req, err := NewGlob.QueryClient.NewRequest("PUT", "/alerts/"+alertID, strings.NewReader(body)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + alert, err := readJsonBody[testAlertResponse](response.Body) + require.NoError(t, err) + return alert +} + +func disableTestAlert(t *testing.T, alertID string) testAlertResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("PATCH", "/alerts/"+alertID+"/disable", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + alert, err := readJsonBody[testAlertResponse](response.Body) + require.NoError(t, err) + return alert +} + +func enableTestAlert(t *testing.T, alertID string) testAlertResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("PATCH", "/alerts/"+alertID+"/enable", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + alert, err := readJsonBody[testAlertResponse](response.Body) + require.NoError(t, err) + return alert +} + +func getTestAlert(t *testing.T, title string) testAlertResponse { + t.Helper() + req, _ := NewGlob.QueryClient.NewRequest("GET", "/alerts", nil) + response, err := NewGlob.QueryClient.Do(req) + require.NoErrorf(t, err, "Request failed: %s", err) + require.Equal(t, 200, response.StatusCode) + alerts, err := readJsonBody[[]testAlertResponse](response.Body) + require.NoError(t, err) + for _, alert := range alerts { + if alert.Title == title { + return alert + } + } + t.Fatalf("alert %q was not returned by GET /alerts", title) + return testAlertResponse{} +} + +func getTestAlertByID(t *testing.T, alertID string) testAlertResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", "/alerts/"+alertID, nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + alert, err := readJsonBody[testAlertResponse](response.Body) + require.NoError(t, err) + return alert +} + +func muteTestAlertNotifications(t *testing.T, alertID string) { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest( + "PATCH", + "/alerts/"+alertID+"/update_notification_state", + strings.NewReader(`{"state":"indefinite"}`), + ) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) +} + +func evaluateTestAlert(t *testing.T, alertID string) { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("PUT", "/alerts/"+alertID+"/evaluate_alert", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) +} + +func waitForTestAlertState(t *testing.T, alertID, expectedState string) testAlertResponse { + t.Helper() + var alert testAlertResponse + require.Eventuallyf(t, func() bool { + req, err := NewGlob.QueryClient.NewRequest("GET", "/alerts/"+alertID, nil) + if err != nil { + return false + } + response, err := NewGlob.QueryClient.Do(req) + if err != nil { + return false + } + defer response.Body.Close() + if response.StatusCode != 200 { + return false + } + alert, err = readJsonBody[testAlertResponse](response.Body) + return err == nil && alert.State == expectedState + }, 30*time.Second, 500*time.Millisecond, "alert %s did not become %s", alertID, expectedState) + return alert +} + +func TestSmokeSetTarget(t *testing.T) { + // Verifies that a webhook target can be created, read, and updated. + t.Parallel() + targetName := NewGlob.Stream + "settarget" + targetID := createTestTarget(t, targetName) + t.Cleanup(func() { + DeleteTarget(t, NewGlob.QueryClient, targetID) + }) + + target := getTestTargetByID(t, targetID) + require.True(t, target.Enabled) + require.Equal(t, targetID, target.Target.ID) + require.Equal(t, targetName, target.Target.Name) + require.Equal(t, "webhook", target.Target.Type) + + updated := updateTestTarget(t, targetID, targetName) + require.Equal(t, targetID, updated.ID) + require.Equal(t, targetName, updated.Name) + require.Equal(t, "********", updated.Headers["X-Quest-Test"]) + + target = getTestTargetByID(t, targetID) + require.Equal(t, "********", target.Target.Headers["X-Quest-Test"]) +} + +func TestSmokeAlertTargetPolicy(t *testing.T) { + // Verifies that the alert target network policy can be read and saved safely. + t.Parallel() + policy := getTestAlertTargetPolicy(t) + updated := updateTestAlertTargetPolicy(t, policy) + require.Equal(t, policy, updated) + require.Equal(t, policy, getTestAlertTargetPolicy(t)) +} + +func TestSmokeAlertLifecycle(t *testing.T) { + // Verifies an alert changes from not-triggered to triggered after ingestion. + t.Parallel() + stream := NewGlob.Stream + "alert" + title := NewGlob.Stream + "alerttitle" + metricName := NewGlob.Stream + "metric" + CreateStreamWithSchemaBody(t, NewGlob.QueryClient, stream, map[string]string{"X-P-Static-Schema-Flag": "true"}, SchemaPayload) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, stream) + }) + targetID := createTestTarget(t, NewGlob.Stream+"alerttarget") + t.Cleanup(func() { + DeleteTarget(t, NewGlob.QueryClient, targetID) + }) + alertID := createTestAlert(t, stream, targetID, title, metricName) + t.Cleanup(func() { + DeleteAlert(t, NewGlob.QueryClient, alertID) + }) + + alert := getTestAlertByID(t, alertID) + require.Equal(t, alertID, alert.ID) + require.Equal(t, title, alert.Title) + require.Equal(t, "threshold", alert.AlertType) + require.Equal(t, "medium", alert.Severity) + require.Equal(t, []string{stream}, alert.Datasets) + require.Equal(t, "not-triggered", alert.State) + require.NotEmpty(t, alert.Created) + require.Equal(t, metricName, alert.MetricName) + + t.Run("FilterByMetric", func(t *testing.T) { + alerts := listTestAlerts(t, "/alerts?metric_name="+url.QueryEscape(metricName)) + requireTestAlertListed(t, alerts, alertID) + }) + + t.Run("FilterByTag", func(t *testing.T) { + alerts := listTestAlerts(t, "/alerts?tags="+url.QueryEscape("quest-test")) + requireTestAlertListed(t, alerts, alertID) + }) + + t.Run("ListTags", func(t *testing.T) { + req, err := NewGlob.QueryClient.NewRequest("GET", "/alerts/list_tags", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + tags, err := readJsonBody[[]string](response.Body) + require.NoError(t, err) + require.Contains(t, tags, "quest-test") + }) + + updatedTitle := title + "updated" + t.Run("UpdateAlert", func(t *testing.T) { + updated := updateTestAlert(t, alertID, stream, targetID, updatedTitle, metricName) + require.Equal(t, alertID, updated.ID) + require.Equal(t, updatedTitle, updated.Title) + require.Equal(t, metricName, updated.MetricName) + }) + + muteTestAlertNotifications(t, alertID) + ingestAlertFixture(t, stream) + time.Sleep(120 * time.Second) + evaluateTestAlert(t, alertID) + alert = waitForTestAlertState(t, alertID, "triggered") + require.NotEmpty(t, alert.LastTriggeredAt) + + t.Run("DisableAlert", func(t *testing.T) { + disabled := disableTestAlert(t, alertID) + require.Equal(t, "disabled", disabled.State) + }) + + t.Run("EnableAlert", func(t *testing.T) { + enabled := enableTestAlert(t, alertID) + require.Equal(t, "not-triggered", enabled.State) + }) +} diff --git a/tests/integration/apikey_test.go b/tests/integration/apikey_test.go new file mode 100644 index 0000000..e4a0297 --- /dev/null +++ b/tests/integration/apikey_test.go @@ -0,0 +1,186 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +type testAPIKeyResponse struct { + KeyID string `json:"keyId"` + APIKey string `json:"apiKey"` + KeyName string `json:"keyName"` + Roles []string `json:"roles"` + CreatedBy string `json:"createdBy"` + CreatedAt string `json:"createdAt"` + ModifiedAt string `json:"modifiedAt"` +} + +type testAPIKeyValidationResponse struct { + Valid bool `json:"valid"` +} + +type testAPIKeyDeleteResponse struct { + KeyID string `json:"keyId"` + KeyName string `json:"keyName"` + Message string `json:"message"` +} + +func deleteTestAPIKey(t *testing.T, keyID, keyName string) testAPIKeyDeleteResponse { + t.Helper() + payload, err := json.Marshal(map[string]string{ + "keyName": keyName, + "keyType": "", + }) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequestAtPath( + "DELETE", + "api/prism/v1/apikeys/"+keyID, + bytes.NewReader(payload), + ) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + result, err := readJsonBody[testAPIKeyDeleteResponse](response.Body) + require.NoError(t, err) + return result +} + +func validateTestAPIKey(t *testing.T, apiKey string) bool { + t.Helper() + payload, err := json.Marshal(map[string]string{"apiKey": apiKey}) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequestAtPath( + "POST", + "api/prism/v1/apikeys/validate", + bytes.NewReader(payload), + ) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + result, err := readJsonBody[testAPIKeyValidationResponse](response.Body) + require.NoError(t, err) + return result.Valid +} + +func TestSmokeAPIKeyLifecycle(t *testing.T) { + // Verifies create, list, get, validate, and delete for a self-hosted API key. + t.Parallel() + rbacMu.Lock() + t.Cleanup(rbacMu.Unlock) + + role := NewGlob.Stream + "apikeyrole" + keyName := NewGlob.Stream + "apikey" + CreateRole(t, NewGlob.QueryClient, role, dummyRole) + t.Cleanup(func() { + DeleteRole(t, NewGlob.PBClient, role) + }) + + payload, err := json.Marshal(map[string]any{ + "keyName": keyName, + "roles": []string{role}, + }) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequestAtPath( + "POST", + "api/prism/v1/apikeys", + bytes.NewReader(payload), + ) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + created, err := readJsonBody[testAPIKeyResponse](response.Body) + require.NoError(t, err) + require.NotEmpty(t, created.KeyID) + require.NotEmpty(t, created.APIKey) + require.Equal(t, keyName, created.KeyName) + require.ElementsMatch(t, []string{role}, created.Roles) + require.NotEmpty(t, created.CreatedBy) + require.NotEmpty(t, created.CreatedAt) + require.NotEmpty(t, created.ModifiedAt) + + keyDeleted := false + t.Cleanup(func() { + if !keyDeleted { + deleteTestAPIKey(t, created.KeyID, keyName) + } + }) + + t.Run("ListAPIKeys", func(t *testing.T) { + req, err := NewGlob.QueryClient.NewRequestAtPath("GET", "api/prism/v1/apikeys", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + keys, err := readJsonBody[[]testAPIKeyResponse](response.Body) + require.NoError(t, err) + + var listed *testAPIKeyResponse + for index := range keys { + if keys[index].KeyID == created.KeyID { + listed = &keys[index] + break + } + } + require.NotNil(t, listed) + require.Equal(t, keyName, listed.KeyName) + require.ElementsMatch(t, []string{role}, listed.Roles) + require.Equal(t, "****"+created.APIKey[len(created.APIKey)-4:], listed.APIKey) + }) + + t.Run("GetAPIKey", func(t *testing.T) { + req, err := NewGlob.QueryClient.NewRequestAtPath("GET", "api/prism/v1/apikeys/"+created.KeyID, nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + key, err := readJsonBody[testAPIKeyResponse](response.Body) + require.NoError(t, err) + require.Equal(t, created.KeyID, key.KeyID) + require.Equal(t, created.APIKey, key.APIKey) + require.Equal(t, keyName, key.KeyName) + require.ElementsMatch(t, []string{role}, key.Roles) + }) + + t.Run("ValidateAPIKey", func(t *testing.T) { + require.True(t, validateTestAPIKey(t, created.APIKey)) + }) + + t.Run("DeleteAPIKey", func(t *testing.T) { + deleted := deleteTestAPIKey(t, created.KeyID, keyName) + keyDeleted = true + require.Equal(t, created.KeyID, deleted.KeyID) + require.Equal(t, keyName, deleted.KeyName) + require.Equal(t, "API key deleted successfully", deleted.Message) + }) + + t.Run("ValidateDeletedAPIKey", func(t *testing.T) { + require.False(t, validateTestAPIKey(t, created.APIKey)) + }) +} diff --git a/client.go b/tests/integration/clients/http/client.go similarity index 54% rename from client.go rename to tests/integration/clients/http/client.go index 81de796..1ec1e7c 100644 --- a/client.go +++ b/tests/integration/clients/http/client.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package main +package httpclient import ( "io" @@ -39,13 +39,50 @@ func DefaultClient(url url.URL, username string, password string) HTTPClient { } } -func (client *HTTPClient) baseAPIURL(path string) (x string) { - x, _ = url.JoinPath(client.Url.String(), "api/v1/", path) - return +func joinURLPath(baseURL string, reference string) (string, error) { + parsedReference, err := url.Parse(reference) + if err != nil { + return "", err + } + joined, err := url.JoinPath(baseURL, parsedReference.Path) + if err != nil { + return "", err + } + parsedJoined, err := url.Parse(joined) + if err != nil { + return "", err + } + parsedJoined.RawQuery = parsedReference.RawQuery + parsedJoined.Fragment = parsedReference.Fragment + return parsedJoined.String(), nil +} + +func (client *HTTPClient) baseAPIURL(path string) (string, error) { + apiBase, err := url.JoinPath(client.Url.String(), "api/v1/") + if err != nil { + return "", err + } + return joinURLPath(apiBase, path) } func (client *HTTPClient) NewRequest(method string, path string, body io.Reader) (req *http.Request, err error) { - req, err = http.NewRequest(method, client.baseAPIURL(path), body) + requestURL, err := client.baseAPIURL(path) + if err != nil { + return nil, err + } + return client.NewRequestAtPath(method, requestURL, body) +} + +func (client *HTTPClient) NewRequestAtPath(method string, path string, body io.Reader) (req *http.Request, err error) { + requestURL := path + if parsedURL, parseErr := url.Parse(path); parseErr != nil || !parsedURL.IsAbs() { + requestURL, err = joinURLPath(client.Url.String(), path) + if err != nil { + return nil, err + } + } + + req, err = http.NewRequest(method, requestURL, body) if err != nil { return } diff --git a/tests/integration/clients/http/client_test.go b/tests/integration/clients/http/client_test.go new file mode 100644 index 0000000..231d8c9 --- /dev/null +++ b/tests/integration/clients/http/client_test.go @@ -0,0 +1,38 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package httpclient + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewRequestPreservesQueryParameters(t *testing.T) { + baseURL, err := url.Parse("http://parseable:8000") + require.NoError(t, err) + client := DefaultClient(*baseURL, "admin", "admin") + + req, err := client.NewRequest("GET", "/alerts?metric_name=quest%20metric&tags=quest-test", nil) + require.NoError(t, err) + require.Equal(t, "/api/v1/alerts", req.URL.Path) + require.Equal(t, "quest metric", req.URL.Query().Get("metric_name")) + require.Equal(t, "quest-test", req.URL.Query().Get("tags")) +} + +func TestNewRequestAtPathPreservesQueryParameters(t *testing.T) { + baseURL, err := url.Parse("http://parseable:8000") + require.NoError(t, err) + client := DefaultClient(*baseURL, "admin", "admin") + + req, err := client.NewRequestAtPath("GET", "api/prism/v1/datasets?limit=5", nil) + require.NoError(t, err) + require.Equal(t, "/api/prism/v1/datasets", req.URL.Path) + require.Equal(t, "5", req.URL.Query().Get("limit")) +} diff --git a/tests/integration/clients/pb/client.go b/tests/integration/clients/pb/client.go new file mode 100644 index 0000000..541327b --- /dev/null +++ b/tests/integration/clients/pb/client.go @@ -0,0 +1,118 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package pb + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +const defaultPBTimeout = 60 * time.Second + +type PBClient struct { + Binary string + Timeout time.Duration +} + +type PBResult struct { + Stdout string + Stderr string + ExitCode int + Duration time.Duration +} + +func DefaultPBClient(binary string) PBClient { + return PBClient{ + Binary: binary, + Timeout: defaultPBTimeout, + } +} + +func (client PBClient) Run(ctx context.Context, args ...string) (PBResult, error) { + if client.Binary == "" { + client.Binary = "pb" + } + + if client.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, client.Timeout) + defer cancel() + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, client.Binary, args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + startedAt := time.Now() + err := cmd.Run() + result := PBResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: 0, + Duration: time.Since(startedAt), + } + + if err == nil { + return result, nil + } + + result.ExitCode = -1 + var exitError *exec.ExitError + if errors.As(err, &exitError) { + result.ExitCode = exitError.ExitCode() + } + + if ctx.Err() != nil { + return result, fmt.Errorf("pb command timed out: %w", ctx.Err()) + } + + return result, err +} + +func (client PBClient) RunJSON(ctx context.Context, output any, args ...string) (PBResult, error) { + jsonArgs := append(append([]string{}, args...), "-o", "json") + result, err := client.Run(ctx, jsonArgs...) + if err != nil { + return result, err + } + + if err := json.Unmarshal([]byte(result.Stdout), output); err != nil { + return result, fmt.Errorf("decode pb JSON output: %w", err) + } + + return result, nil +} + +func PasswordFromUserAddOutput(output string) (string, error) { + for _, line := range strings.Split(output, "\n") { + if password, found := strings.CutPrefix(strings.TrimSpace(line), "Password is:"); found { + password = strings.TrimSpace(password) + if password != "" { + return password, nil + } + } + } + return "", fmt.Errorf("password not found in pb output") +} diff --git a/tests/integration/clients/pb/client_test.go b/tests/integration/clients/pb/client_test.go new file mode 100644 index 0000000..647b3a4 --- /dev/null +++ b/tests/integration/clients/pb/client_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package pb + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" +) + +func TestPBClientRun(t *testing.T) { + // Verifies PB command output, errors, JSON, and timeouts. + t.Setenv("QUEST_PB_HELPER_PROCESS", "1") + + client := PBClient{ + Binary: os.Args[0], + Timeout: time.Second, + } + command := []string{"-test.run=TestPBClientHelperProcess", "--"} + + t.Run("captures output", func(t *testing.T) { + result, err := client.Run(context.Background(), append(command, "success")...) + if err != nil { + t.Fatalf("run command: %v", err) + } + if result.ExitCode != 0 { + t.Fatalf("expected exit code 0, got %d", result.ExitCode) + } + if result.Stdout != `{"name":"pstats"}` { + t.Fatalf("unexpected stdout: %q", result.Stdout) + } + if result.Stderr != "warning" { + t.Fatalf("unexpected stderr: %q", result.Stderr) + } + }) + + t.Run("returns exit code", func(t *testing.T) { + result, err := client.Run(context.Background(), append(command, "failure")...) + if err == nil { + t.Fatal("expected command to fail") + } + if result.ExitCode != 7 { + t.Fatalf("expected exit code 7, got %d", result.ExitCode) + } + if result.Stderr != "failed" { + t.Fatalf("unexpected stderr: %q", result.Stderr) + } + }) + + t.Run("decodes JSON", func(t *testing.T) { + var output struct { + Name string `json:"name"` + } + result, err := client.RunJSON(context.Background(), &output, append(command, "json")...) + if err != nil { + t.Fatalf("run JSON command: %v (stderr: %s)", err, result.Stderr) + } + if output.Name != "pstats" { + t.Fatalf("unexpected decoded name: %q", output.Name) + } + }) + + t.Run("enforces timeout", func(t *testing.T) { + timeoutClient := client + timeoutClient.Timeout = 50 * time.Millisecond + + result, err := timeoutClient.Run(context.Background(), append(command, "timeout")...) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("expected timeout error, got %v", err) + } + if result.ExitCode != -1 { + t.Fatalf("expected exit code -1, got %d", result.ExitCode) + } + }) +} + +func TestPBClientHelperProcess(t *testing.T) { + // Provides controlled command results for PB client tests. + if os.Getenv("QUEST_PB_HELPER_PROCESS") != "1" { + return + } + + separator := -1 + for index, arg := range os.Args { + if arg == "--" { + separator = index + break + } + } + if separator == -1 || separator+1 >= len(os.Args) { + os.Exit(2) + } + + switch os.Args[separator+1] { + case "success", "json": + fmt.Fprint(os.Stdout, `{"name":"pstats"}`) + if os.Args[separator+1] == "success" { + fmt.Fprint(os.Stderr, "warning") + } + os.Exit(0) + case "failure": + fmt.Fprint(os.Stderr, "failed") + os.Exit(7) + case "timeout": + time.Sleep(2 * time.Second) + default: + os.Exit(2) + } +} + +func TestPasswordFromPBUserAddOutput(t *testing.T) { + // Verifies password parsing from PB user creation output. + output := "Added user: alice\nPassword is: generated-password\nRole(s) assigned: reader\n" + password, err := PasswordFromUserAddOutput(output) + if err != nil { + t.Fatalf("extract password: %v", err) + } + if password != "generated-password" { + t.Fatalf("unexpected password: %q", password) + } + + if _, err := PasswordFromUserAddOutput("Added user: alice\n"); err == nil { + t.Fatal("expected missing password to fail") + } +} diff --git a/tests/integration/cluster_test.go b/tests/integration/cluster_test.go new file mode 100644 index 0000000..4c0b8cc --- /dev/null +++ b/tests/integration/cluster_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +type clusterNode struct { + DomainName string `json:"domain_name"` + Reachable bool `json:"reachable"` + NodeType string `json:"node_type"` + Status *string `json:"status"` +} + +type clusterMetric struct { + Address string `json:"address"` + NodeType string `json:"node_type"` +} + +func clusterRequest(t *testing.T, method, path string, payload any) (int, []byte) { + t.Helper() + var body *bytes.Reader + if payload == nil { + body = bytes.NewReader(nil) + } else { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } + req, err := NewGlob.QueryClient.NewRequest(method, path, body) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + return response.StatusCode, []byte(readAsString(response.Body)) +} + +func TestSmokeClusterManagement(t *testing.T) { + t.Parallel() + + if NewGlob.IngestorUrl.String() == "" { + for _, path := range []string{"cluster/info", "cluster/metrics"} { + status, body := clusterRequest(t, "GET", path, nil) + require.Equalf(t, 404, status, "standalone exposed distributed endpoint %s: %s", path, body) + } + return + } + + status, body := clusterRequest(t, "GET", "cluster/info", nil) + require.Equalf(t, 200, status, "cluster info failed: %s", body) + var nodes []clusterNode + require.NoError(t, json.Unmarshal(body, &nodes)) + require.NotEmpty(t, nodes) + + var liveIngestor *clusterNode + var hasQuerier bool + for i := range nodes { + node := &nodes[i] + require.NotEmpty(t, node.DomainName) + require.NotEmpty(t, node.NodeType) + if node.NodeType == "ingestor" && node.Reachable { + liveIngestor = node + } + if node.NodeType == "querier" { + hasQuerier = true + } + } + require.NotNil(t, liveIngestor, "cluster info did not contain a reachable ingestor") + require.True(t, hasQuerier, "cluster info did not contain a querier") + + t.Run("ClusterMetrics", func(t *testing.T) { + status, body := clusterRequest(t, "GET", "cluster/metrics", nil) + require.Equalf(t, 200, status, "cluster metrics failed: %s", body) + var metrics []clusterMetric + require.NoError(t, json.Unmarshal(body, &metrics)) + require.NotEmpty(t, metrics) + for _, metric := range metrics { + require.NotEmpty(t, metric.Address) + require.NotEmpty(t, metric.NodeType) + } + }) +} diff --git a/main.go b/tests/integration/config.go similarity index 82% rename from main.go rename to tests/integration/config.go index 1918788..b6499e6 100644 --- a/main.go +++ b/tests/integration/config.go @@ -20,11 +20,10 @@ import ( "flag" "net/url" "testing" -) -func main() { - println("hello") -} + httpclient "quest/tests/integration/clients/http" + "quest/tests/integration/clients/pb" +) type Glob struct { QueryUrl url.URL @@ -34,9 +33,11 @@ type Glob struct { IngestorUsername string IngestorPassword string Stream string - QueryClient HTTPClient - IngestorClient HTTPClient + QueryClient httpclient.HTTPClient + IngestorClient httpclient.HTTPClient + PBClient pb.PBClient Mode string + Edition string MinIoConfig } @@ -59,6 +60,8 @@ var NewGlob = func() Glob { var stream string var mode string + var edition string + var pbBinary string // XXX var minioUrl string var minioUser string @@ -75,6 +78,8 @@ var NewGlob = func() Glob { flag.StringVar(&stream, "stream", "app", "Specify stream. Default is app") flag.StringVar(&mode, "mode", "smoke", "Specify mode. Default is smoke") + flag.StringVar(&edition, "edition", "oss", "Specify Parseable edition. Default is oss") + flag.StringVar(&pbBinary, "pb-bin", "pb", "Specify the pb binary path. Default is pb from PATH") flag.StringVar(&minioUrl, "minio-url", "localhost:9000", "Specify MinIO URL. Default is localhost:9000") flag.StringVar(&minioUser, "minio-user", "minioadmin", "Specify MinIO User. Default is `minioadmin`") @@ -88,7 +93,8 @@ var NewGlob = func() Glob { panic("Could not parse url") } - queryClient := DefaultClient(*parsedQueryTargetUrl, queryUsername, queryPassword) + queryClient := httpclient.DefaultClient(*parsedQueryTargetUrl, queryUsername, queryPassword) + pbClient := pb.DefaultPBClient(pbBinary) if targetIngestorUrl != "" { parsedIngestorTargetUrl, err := url.Parse(targetIngestorUrl) @@ -96,7 +102,7 @@ var NewGlob = func() Glob { panic("Could not parse url") } - ingestorClient := DefaultClient(*parsedIngestorTargetUrl, ingestorUsername, ingestorPassword) + ingestorClient := httpclient.DefaultClient(*parsedIngestorTargetUrl, ingestorUsername, ingestorPassword) return Glob{ QueryUrl: *parsedQueryTargetUrl, QueryUsername: queryUsername, @@ -106,8 +112,10 @@ var NewGlob = func() Glob { IngestorUsername: ingestorUsername, IngestorPassword: ingestorPassword, IngestorClient: ingestorClient, + PBClient: pbClient, Stream: stream, Mode: mode, + Edition: edition, MinIoConfig: MinIoConfig{ Url: minioUrl, User: minioUser, @@ -121,8 +129,10 @@ var NewGlob = func() Glob { QueryUsername: queryUsername, QueryPassword: queryPassword, QueryClient: queryClient, + PBClient: pbClient, Stream: stream, Mode: mode, + Edition: edition, MinIoConfig: MinIoConfig{ Url: minioUrl, User: minioUser, diff --git a/tests/integration/dashboard_test.go b/tests/integration/dashboard_test.go new file mode 100644 index 0000000..ed66471 --- /dev/null +++ b/tests/integration/dashboard_test.go @@ -0,0 +1,217 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "net/url" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type testDashboardTile struct { + TileID string `json:"tile_id"` + Title string `json:"title"` +} + +type testDashboardResponse struct { + Version string `json:"version"` + Title string `json:"title"` + Author string `json:"author"` + DashboardID string `json:"dashboardId"` + Created string `json:"created"` + Modified string `json:"modified"` + Tags []string `json:"tags"` + IsFavorite bool `json:"isFavorite"` + Tiles []testDashboardTile `json:"tiles"` +} + +func dashboardRequest(t *testing.T, method, path string, payload any, expectedStatus int) *testDashboardResponse { + t.Helper() + var body *bytes.Reader + if payload == nil { + body = bytes.NewReader(nil) + } else { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } + + req, err := NewGlob.QueryClient.NewRequest(method, path, body) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, expectedStatus, response.StatusCode, "Server returned http code: %s", response.Status) + if expectedStatus != 200 || method == "DELETE" { + return nil + } + + dashboard, err := readJsonBody[testDashboardResponse](response.Body) + require.NoError(t, err) + return &dashboard +} + +func listTestDashboards(t *testing.T, path string) []testDashboardResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", path, nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + dashboards, err := readJsonBody[[]testDashboardResponse](response.Body) + require.NoError(t, err) + return dashboards +} + +func requireTestDashboardListed(t *testing.T, dashboards []testDashboardResponse, dashboardID string) testDashboardResponse { + t.Helper() + for _, dashboard := range dashboards { + if dashboard.DashboardID == dashboardID { + return dashboard + } + } + t.Fatalf("dashboard %q was not returned", dashboardID) + return testDashboardResponse{} +} + +func TestSmokeDashboardLifecycle(t *testing.T) { + // Verifies all shared dashboard CRUD, filter, update, and tile APIs. + t.Parallel() + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + title := NewGlob.Stream + " dashboard " + suffix + updatedTitle := title + " updated" + finalTitle := title + " final" + tag := NewGlob.Stream + "-dashboard-" + suffix + updatedTag := tag + "-updated" + tileID := "01J00000000000000000000001" + + created := dashboardRequest(t, "POST", "/dashboards", map[string]any{ + "title": title, + "tags": []string{tag}, + "tiles": []any{}, + "sections": []any{}, + "variables": []any{}, + "isFavorite": false, + }, 200) + require.NotEmpty(t, created.DashboardID) + require.Equal(t, title, created.Title) + require.Equal(t, []string{tag}, created.Tags) + require.False(t, created.IsFavorite) + require.NotEmpty(t, created.Author) + require.NotEmpty(t, created.Created) + require.NotEmpty(t, created.Modified) + + dashboardDeleted := false + t.Cleanup(func() { + if !dashboardDeleted { + dashboardRequest(t, "DELETE", "/dashboards/"+created.DashboardID, nil, 200) + } + }) + + t.Run("ListDashboards", func(t *testing.T) { + listed := requireTestDashboardListed(t, listTestDashboards(t, "/dashboards"), created.DashboardID) + require.Equal(t, title, listed.Title) + require.Equal(t, []string{tag}, listed.Tags) + }) + + t.Run("ListTopFive", func(t *testing.T) { + dashboards := listTestDashboards(t, "/dashboards?limit=5") + require.LessOrEqual(t, len(dashboards), 5) + requireTestDashboardListed(t, dashboards, created.DashboardID) + }) + + t.Run("FilterByTag", func(t *testing.T) { + path := "/dashboards?tags=" + url.QueryEscape(tag) + requireTestDashboardListed(t, listTestDashboards(t, path), created.DashboardID) + }) + + t.Run("ListTags", func(t *testing.T) { + req, err := NewGlob.QueryClient.NewRequest("GET", "/dashboards/list_tags", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + tags, err := readJsonBody[[]string](response.Body) + require.NoError(t, err) + require.Contains(t, tags, tag) + }) + + t.Run("GetDashboard", func(t *testing.T) { + dashboard := dashboardRequest(t, "GET", "/dashboards/"+created.DashboardID, nil, 200) + require.Equal(t, created.DashboardID, dashboard.DashboardID) + require.Equal(t, title, dashboard.Title) + require.Equal(t, []string{tag}, dashboard.Tags) + }) + + t.Run("UpdateDashboard", func(t *testing.T) { + dashboard := dashboardRequest(t, "PUT", "/dashboards/"+created.DashboardID, map[string]any{ + "title": updatedTitle, + "tags": []string{tag}, + "tiles": []any{}, + "sections": []any{}, + "variables": []any{}, + "isFavorite": false, + }, 200) + require.Equal(t, updatedTitle, dashboard.Title) + require.Equal(t, []string{tag}, dashboard.Tags) + }) + + t.Run("RenameFavoriteAndRetag", func(t *testing.T) { + query := url.Values{} + query.Set("isFavorite", "true") + query.Set("renameTo", finalTitle) + query.Set("tags", tag+","+updatedTag) + dashboard := dashboardRequest(t, "PUT", "/dashboards/"+created.DashboardID+"?"+query.Encode(), nil, 200) + require.Equal(t, finalTitle, dashboard.Title) + require.True(t, dashboard.IsFavorite) + require.ElementsMatch(t, []string{tag, updatedTag}, dashboard.Tags) + }) + + t.Run("AddTile", func(t *testing.T) { + dashboard := dashboardRequest(t, "PUT", "/dashboards/"+created.DashboardID+"/add_tile", map[string]any{ + "tile_id": tileID, + "title": "Quest dashboard tile", + "chartQuery": "SELECT 1", + "dbName": NewGlob.Stream, + "chartType": "table", + "tileType": "code", + "config": map[string]any{}, + "layout": map[string]int{ + "x": 0, + "y": 0, + "w": 4, + "h": 4, + }, + }, 200) + require.Len(t, dashboard.Tiles, 1) + require.Equal(t, tileID, dashboard.Tiles[0].TileID) + require.Equal(t, "Quest dashboard tile", dashboard.Tiles[0].Title) + }) + + t.Run("DeleteDashboard", func(t *testing.T) { + dashboardRequest(t, "DELETE", "/dashboards/"+created.DashboardID, nil, 200) + dashboardDeleted = true + dashboardRequest(t, "GET", "/dashboards/"+created.DashboardID, nil, 400) + }) +} diff --git a/tests/integration/dataset_test.go b/tests/integration/dataset_test.go new file mode 100644 index 0000000..aec40e6 --- /dev/null +++ b/tests/integration/dataset_test.go @@ -0,0 +1,244 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type prismDatasetResponse struct { + Stream string `json:"stream"` + Info json.RawMessage `json:"info"` + Schema json.RawMessage `json:"schema"` + Stats json.RawMessage `json:"stats"` + Retention json.RawMessage `json:"retention"` + Counts json.RawMessage `json:"counts"` +} + +type prismDatasetInfoResponse struct { + Info json.RawMessage `json:"info"` + Schema json.RawMessage `json:"schema"` + Stats json.RawMessage `json:"stats"` + Retention json.RawMessage `json:"retention"` +} + +type prismStreamInfo struct { + TimePartitionLimit string `json:"timePartitionLimit"` +} + +func requireJSONField(t *testing.T, field json.RawMessage, name string) { + t.Helper() + require.NotEmptyf(t, field, "%s is missing from the response", name) + require.NotEqualf(t, "null", string(field), "%s is null in the response", name) +} + +func TestSmokeListLogStream(t *testing.T) { + // Verifies that the dataset list includes a newly created stream. + t.Parallel() + streamName := NewGlob.Stream + "list" + CreateStream(t, NewGlob.PBClient, streamName) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, streamName) + }) + datasets := ListDatasetsWithPB(t, NewGlob.PBClient) + require.Contains(t, datasets, PBDataset{Title: streamName}) +} + +func TestSmokeCreateStream(t *testing.T) { + // Verifies that PB creates a logs dataset. + t.Parallel() + stream := NewGlob.Stream + "create" + CreateStream(t, NewGlob.PBClient, stream) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, stream) + }) + info := DatasetInfoWithPB(t, NewGlob.PBClient, stream) + require.Equal(t, "logs", info.DatasetType) +} + +func TestSmokeDeleteStream(t *testing.T) { + // Verifies that PB deletes an empty stream. + t.Parallel() + stream := NewGlob.Stream + "delete" + CreateStream(t, NewGlob.PBClient, stream) + DeleteStream(t, NewGlob.PBClient, stream) + datasets := ListDatasetsWithPB(t, NewGlob.PBClient) + require.NotContains(t, datasets, PBDataset{Title: stream}) +} + +func TestSmokeDetectSchema(t *testing.T) { + // Verifies that schema detection returns the expected schema. + t.Parallel() + DetectSchema(t, NewGlob.QueryClient, SampleJson, SchemaBody) +} + +func TestPrismDatasetUIEndpoints(t *testing.T) { + // Verifies the combined dataset list and dataset info APIs used by Prism. + t.Parallel() + stream := NewGlob.Stream + "prismdataset" + CreateStream(t, NewGlob.PBClient, stream) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, stream) + }) + + t.Run("ListSelectedDataset", func(t *testing.T) { + body := strings.NewReader(`{"streams":["` + stream + `"]}`) + req, err := NewGlob.QueryClient.NewRequestAtPath("POST", "api/prism/v1/datasets", body) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + + datasets, err := readJsonBody[[]prismDatasetResponse](response.Body) + require.NoError(t, err) + require.Len(t, datasets, 1) + require.Equal(t, stream, datasets[0].Stream) + requireJSONField(t, datasets[0].Info, "info") + requireJSONField(t, datasets[0].Schema, "schema") + requireJSONField(t, datasets[0].Stats, "stats") + requireJSONField(t, datasets[0].Retention, "retention") + requireJSONField(t, datasets[0].Counts, "counts") + }) + + t.Run("GetDatasetInfo", func(t *testing.T) { + req, err := NewGlob.QueryClient.NewRequestAtPath("GET", "api/prism/v1/logstream/"+stream+"/info", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + + info, err := readJsonBody[prismDatasetInfoResponse](response.Body) + require.NoError(t, err) + requireJSONField(t, info.Info, "info") + requireJSONField(t, info.Schema, "schema") + requireJSONField(t, info.Stats, "stats") + requireJSONField(t, info.Retention, "retention") + }) +} + +func TestPrismUpdateDataset(t *testing.T) { + // Verifies that Prism updates and persists a dataset's time-partition limit. + t.Parallel() + stream := NewGlob.Stream + "prismupdate" + CreateStream(t, NewGlob.PBClient, stream) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, stream) + }) + + req, err := NewGlob.QueryClient.NewRequest("PUT", "logstream/"+stream, nil) + require.NoError(t, err) + req.Header.Set("X-P-Update-Stream", "true") + req.Header.Set("X-P-Time-Partition-Limit", "7d") + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + require.NoError(t, response.Body.Close()) + + req, err = NewGlob.QueryClient.NewRequestAtPath("GET", "api/prism/v1/logstream/"+stream+"/info", nil) + require.NoError(t, err) + response, err = NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + + datasetInfo, err := readJsonBody[prismDatasetInfoResponse](response.Body) + require.NoError(t, err) + var streamInfo prismStreamInfo + require.NoError(t, json.Unmarshal(datasetInfo.Info, &streamInfo)) + require.Equal(t, "7", streamInfo.TimePartitionLimit) +} + +// func TestTimePartition_TimeStampMismatch(t *testing.T) { +// historicalStream := NewGlob.Stream + "historical" +// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} +// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) +// if NewGlob.IngestorUrl.String() == "" { +// IngestOneEventWithTimePartition_TimeStampMismatch(t, NewGlob.QueryClient, historicalStream) +// } else { +// IngestOneEventWithTimePartition_TimeStampMismatch(t, NewGlob.IngestorClient, historicalStream) +// } +// DeleteStream(t, NewGlob.PBClient, historicalStream) +// } + +// func TestTimePartition_NoTimePartitionInLog(t *testing.T) { +// historicalStream := NewGlob.Stream + "historical" +// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} +// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) +// if NewGlob.IngestorUrl.String() == "" { +// IngestOneEventWithTimePartition_NoTimePartitionInLog(t, NewGlob.QueryClient, historicalStream) +// } else { +// IngestOneEventWithTimePartition_NoTimePartitionInLog(t, NewGlob.IngestorClient, historicalStream) +// } +// DeleteStream(t, NewGlob.PBClient, historicalStream) +// } + +// func TestTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t *testing.T) { +// historicalStream := NewGlob.Stream + "historical" +// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} +// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) +// if NewGlob.IngestorUrl.String() == "" { +// IngestOneEventWithTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t, NewGlob.QueryClient, historicalStream) +// } else { +// IngestOneEventWithTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t, NewGlob.IngestorClient, historicalStream) +// } +// DeleteStream(t, NewGlob.PBClient, historicalStream) +// } + +func TestStaticSchemaIngestion(t *testing.T) { + // Verifies that a static schema accepts matching fields and rejects new fields. + t.Parallel() + staticSchemaStream := NewGlob.Stream + "staticschema" + staticSchemaFlagHeader := map[string]string{"X-P-Static-Schema-Flag": "true"} + CreateStreamWithSchemaBody(t, NewGlob.QueryClient, staticSchemaStream, staticSchemaFlagHeader, SchemaPayload) + + client := NewGlob.QueryClient + if NewGlob.IngestorUrl.String() != "" { + client = NewGlob.IngestorClient + } + + t.Run("AcceptMatchingFields", func(t *testing.T) { + IngestOneEventForStaticSchemaStream_SameFieldsInLog(t, client, staticSchemaStream) + }) + t.Run("RejectNewField", func(t *testing.T) { + IngestOneEventForStaticSchemaStream_NewFieldInLog(t, client, staticSchemaStream) + }) +} + +func TestCreateStream_WithCustomPartition_Success(t *testing.T) { + // Verifies that a stream accepts one custom partition field. + t.Parallel() + customPartitionStream := NewGlob.Stream + "custompartitionsuccess" + customHeader := map[string]string{"X-P-Custom-Partition": "level"} + CreateStreamWithHeader(t, NewGlob.QueryClient, customPartitionStream, customHeader) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, customPartitionStream) + }) +} + +func TestCreateStream_WithCustomPartition_Error(t *testing.T) { + // Verifies that multiple custom partition fields are rejected. + t.Parallel() + customPartitionStream := NewGlob.Stream + "custompartitionerror" + customHeader := map[string]string{"X-P-Custom-Partition": "level,os"} + CreateStreamWithCustompartitionError(t, NewGlob.QueryClient, customPartitionStream, customHeader) +} diff --git a/tests/integration/errors_apm_test.go b/tests/integration/errors_apm_test.go new file mode 100644 index 0000000..ca51dda --- /dev/null +++ b/tests/integration/errors_apm_test.go @@ -0,0 +1,266 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + observabilityFrontendService = "quest-frontend" + observabilityBackendService = "quest-backend" + observabilityErrorType = "QuestCheckoutError" + observabilityErrorMessage = "quest checkout failed" + observabilityBackendSpan = "POST /checkout" +) + +func observabilityRequest(path string, payload any) (int, []byte, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return 0, nil, err + } + req, err := NewGlob.QueryClient.NewRequestAtPath("POST", path, bytes.NewReader(encoded)) + if err != nil { + return 0, nil, err + } + response, err := NewGlob.QueryClient.Do(req) + if err != nil { + return 0, nil, err + } + defer response.Body.Close() + return response.StatusCode, []byte(readAsString(response.Body)), nil +} + +func createObservabilityDataset(t *testing.T, dataset string) { + t.Helper() + result, err := NewGlob.PBClient.Run(context.Background(), "dataset", "add", dataset, "--type", "traces") + require.NoErrorf(t, err, "pb traces dataset add failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) + t.Cleanup(func() { DeleteStream(t, NewGlob.PBClient, dataset) }) +} + +func otlpStringAttribute(key, value string) map[string]any { + return map[string]any{"key": key, "value": map[string]string{"stringValue": value}} +} + +func otlpIntAttribute(key string, value int) map[string]any { + return map[string]any{"key": key, "value": map[string]string{"intValue": fmt.Sprint(value)}} +} + +func ingestObservabilityFixture(t *testing.T, dataset, traceID string) { + t.Helper() + client := NewGlob.QueryClient + if NewGlob.IngestorUrl.String() != "" { + client = NewGlob.IngestorClient + } + + start := time.Now().UTC().Add(-2 * time.Second) + rootSpanID := "1111111111111111" + childSpanID := "2222222222222222" + rootSpan := map[string]any{ + "traceId": traceID, "spanId": rootSpanID, "name": "GET /cart", "kind": 2, + "startTimeUnixNano": fmt.Sprint(start.UnixNano()), + "endTimeUnixNano": fmt.Sprint(start.Add(1500 * time.Millisecond).UnixNano()), + "status": map[string]int{"code": 1}, + "attributes": []any{otlpStringAttribute("http.route", "/cart"), otlpIntAttribute("http.response.status_code", 200)}, + } + childSpan := map[string]any{ + "traceId": traceID, "spanId": childSpanID, "parentSpanId": rootSpanID, "name": observabilityBackendSpan, "kind": 3, + "startTimeUnixNano": fmt.Sprint(start.Add(200 * time.Millisecond).UnixNano()), + "endTimeUnixNano": fmt.Sprint(start.Add(time.Second).UnixNano()), + "status": map[string]any{"code": 2, "message": observabilityErrorMessage}, + "attributes": []any{otlpStringAttribute("http.route", "/checkout"), otlpIntAttribute("http.response.status_code", 500)}, + "events": []any{map[string]any{ + "timeUnixNano": fmt.Sprint(start.Add(900 * time.Millisecond).UnixNano()), + "name": "exception", + "attributes": []any{ + otlpStringAttribute("exception.type", observabilityErrorType), + otlpStringAttribute("exception.message", observabilityErrorMessage), + otlpStringAttribute("exception.stacktrace", "quest checkout stack"), + }, + }}, + } + payload := map[string]any{"resourceSpans": []any{ + map[string]any{ + "resource": map[string]any{"attributes": []any{otlpStringAttribute("service.name", observabilityFrontendService)}}, + "scopeSpans": []any{map[string]any{"scope": map[string]string{"name": "quest"}, "spans": []any{rootSpan}}}, + }, + map[string]any{ + "resource": map[string]any{"attributes": []any{otlpStringAttribute("service.name", observabilityBackendService)}}, + "scopeSpans": []any{map[string]any{"scope": map[string]string{"name": "quest"}, "spans": []any{childSpan}}}, + }, + }} + encoded, err := json.Marshal(payload) + require.NoError(t, err) + req, err := client.NewRequestAtPath("POST", "/v1/traces", bytes.NewReader(encoded)) + require.NoError(t, err) + req.Header.Set("X-P-Stream", dataset) + req.Header.Set("X-P-Log-Source", "otel-traces") + response, err := client.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "OTLP observability fixture failed: %s", readAsString(response.Body)) +} + +func observabilityWindow() (string, string) { + return time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339Nano), time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339Nano) +} + +type errorsListResponse struct { + Total int `json:"total"` + Records []struct { + ExceptionType string `json:"exceptionType"` + ExceptionMessage string `json:"exceptionMessage"` + ServiceName string `json:"serviceName"` + Occurrences int `json:"occurrences"` + } `json:"records"` +} + +func waitForObservabilityError(t *testing.T, payload map[string]any) errorsListResponse { + t.Helper() + deadline := time.Now().Add(traceVisibilityTimeout) + var lastStatus int + var lastBody []byte + var lastErr error + for time.Now().Before(deadline) { + lastStatus, lastBody, lastErr = observabilityRequest("api/prism/v1/errors/list", payload) + if lastErr == nil && lastStatus == 200 { + var result errorsListResponse + if err := json.Unmarshal(lastBody, &result); err == nil && len(result.Records) > 0 { + return result + } + } + time.Sleep(5 * time.Second) + } + require.NoErrorf(t, lastErr, "error data did not become queryable within %s", traceVisibilityTimeout) + require.Failf(t, "error data did not become queryable", "last status=%d body=%s", lastStatus, lastBody) + return errorsListResponse{} +} + +func TestEnterpriseErrorsAndAPM(t *testing.T) { + // Verifies all Enterprise Errors and APM APIs. + if NewGlob.Edition != "enterprise" { + t.Skip("Errors and APM are only available in Enterprise") + } + t.Parallel() + dataset := NewGlob.Stream + "observability" + traceID := fmt.Sprintf("%032x", time.Now().UnixNano()) + createObservabilityDataset(t, dataset) + ingestObservabilityFixture(t, dataset, traceID) + startTime, endTime := observabilityWindow() + + listPayload := map[string]any{ + "dataset": dataset, "startTime": startTime, "endTime": endTime, + "offset": 0, "limit": 10, "sortBy": "lastSeen", "sortOrder": "desc", + } + errors := waitForObservabilityError(t, listPayload) + require.GreaterOrEqual(t, errors.Total, 1) + errorRecord := errors.Records[0] + require.Equal(t, observabilityErrorType, errorRecord.ExceptionType) + require.Equal(t, observabilityErrorMessage, errorRecord.ExceptionMessage) + require.Equal(t, observabilityBackendService, errorRecord.ServiceName) + require.GreaterOrEqual(t, errorRecord.Occurrences, 1) + + errorGroupPayload := map[string]any{ + "dataset": dataset, "startTime": startTime, "endTime": endTime, + "serviceName": errorRecord.ServiceName, "exceptionType": errorRecord.ExceptionType, + "exceptionMessage": errorRecord.ExceptionMessage, + } + + t.Run("ErrorsFrequency", func(t *testing.T) { + t.Parallel() + status, body, err := observabilityRequest("api/prism/v1/errors/frequency", errorGroupPayload) + require.NoError(t, err) + require.Equalf(t, 200, status, "errors frequency failed: %s", body) + require.Contains(t, string(body), `"records"`) + }) + + t.Run("ErrorTraces", func(t *testing.T) { + t.Parallel() + payload := make(map[string]any, len(errorGroupPayload)+4) + for key, value := range errorGroupPayload { + payload[key] = value + } + payload["limit"], payload["offset"] = 10, 0 + payload["sortBy"], payload["sortOrder"] = "spanStartTime", "desc" + status, body, err := observabilityRequest("api/prism/v1/errors/traces", payload) + require.NoError(t, err) + require.Equalf(t, 200, status, "error traces failed: %s", body) + require.Contains(t, string(body), traceID) + }) + + servicePayload := map[string]any{"dataset": dataset, "startTime": startTime, "endTime": endTime} + t.Run("ServicesList", func(t *testing.T) { + t.Parallel() + status, body, err := observabilityRequest("api/prism/v1/services/list", servicePayload) + require.NoError(t, err) + require.Equalf(t, 200, status, "services list failed: %s", body) + require.Contains(t, string(body), observabilityFrontendService) + require.Contains(t, string(body), observabilityBackendService) + }) + + serviceInsightPayload := map[string]any{ + "dataset": dataset, "serviceName": observabilityBackendService, + "startTime": startTime, "endTime": endTime, + } + t.Run("ServiceInsights", func(t *testing.T) { + t.Parallel() + status, body, err := observabilityRequest("api/prism/v1/services/insights", serviceInsightPayload) + require.NoError(t, err) + require.Equalf(t, 200, status, "service insights failed: %s", body) + require.Contains(t, string(body), `"serviceSummary"`) + require.Contains(t, string(body), observabilityBackendSpan) + }) + + t.Run("ResourceInsights", func(t *testing.T) { + t.Parallel() + payload := map[string]any{ + "dataset": dataset, "serviceName": observabilityBackendService, "resource": observabilityBackendSpan, + "startTime": startTime, "endTime": endTime, + } + status, body, err := observabilityRequest("api/prism/v1/services/resource/insights", payload) + require.NoError(t, err) + require.Equalf(t, 200, status, "resource insights failed: %s", body) + require.Contains(t, string(body), `"overview"`) + }) + + t.Run("ServiceMap", func(t *testing.T) { + t.Parallel() + status, body, err := observabilityRequest("api/prism/v1/services/map", servicePayload) + require.NoError(t, err) + require.Equalf(t, 200, status, "service map failed: %s", body) + require.Contains(t, string(body), observabilityFrontendService) + require.Contains(t, string(body), observabilityBackendService) + require.Contains(t, string(body), `"dependencies"`) + }) + + t.Run("ServiceMapInsights", func(t *testing.T) { + t.Parallel() + status, body, err := observabilityRequest("api/prism/v1/services/map/insights", serviceInsightPayload) + require.NoError(t, err) + require.Equalf(t, 200, status, "service map insights failed: %s", body) + require.Contains(t, string(body), `"upstreamServices"`) + require.Contains(t, string(body), `"downstreamServices"`) + require.Contains(t, string(body), `"resources"`) + }) +} diff --git a/tests/integration/filter_test.go b/tests/integration/filter_test.go new file mode 100644 index 0000000..b1ea6eb --- /dev/null +++ b/tests/integration/filter_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type testSavedFilterQuery struct { + FilterType string `json:"filter_type"` + FilterQuery string `json:"filter_query"` +} + +type testSavedFilterResponse struct { + Version string `json:"version"` + UserID string `json:"user_id"` + StreamName string `json:"stream_name"` + FilterName string `json:"filter_name"` + FilterID string `json:"filter_id"` + FilterDescription string `json:"filter_description"` + Query testSavedFilterQuery `json:"query"` +} + +func savedFilterRequest(t *testing.T, method, path string, payload any, expectedStatus int) *testSavedFilterResponse { + t.Helper() + var body *bytes.Reader + if payload == nil { + body = bytes.NewReader(nil) + } else { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } + + req, err := NewGlob.QueryClient.NewRequest(method, path, body) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, expectedStatus, response.StatusCode, "Server returned http code: %s", response.Status) + if expectedStatus != 200 || method == "DELETE" { + return nil + } + + filter, err := readJsonBody[testSavedFilterResponse](response.Body) + require.NoError(t, err) + return &filter +} + +func listTestSavedFilters(t *testing.T) []testSavedFilterResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", "/filters", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + filters, err := readJsonBody[[]testSavedFilterResponse](response.Body) + require.NoError(t, err) + return filters +} + +func requireTestSavedFilterListed(t *testing.T, filters []testSavedFilterResponse, filterID string) testSavedFilterResponse { + t.Helper() + for _, filter := range filters { + if filter.FilterID == filterID { + return filter + } + } + t.Fatalf("saved filter %q was not returned", filterID) + return testSavedFilterResponse{} +} + +func TestSmokeSavedFilterLifecycle(t *testing.T) { + // Verifies create, list, get, update, and delete for a saved filter. + t.Parallel() + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + stream := NewGlob.Stream + "savedfilter" + filterName := NewGlob.Stream + " saved filter " + suffix + updatedName := filterName + " updated" + query := "SELECT * FROM \"" + stream + "\" LIMIT 10" + updatedQuery := "SELECT * FROM \"" + stream + "\" LIMIT 5" + + created := savedFilterRequest(t, "POST", "/filters", map[string]any{ + "stream_name": stream, + "filter_name": filterName, + "filter_description": "Quest saved filter", + "query": map[string]string{ + "filter_type": "sql", + "filter_query": query, + }, + "time_filter": nil, + }, 200) + require.NotEmpty(t, created.FilterID) + require.Equal(t, "v2", created.Version) + require.NotEmpty(t, created.UserID) + require.Equal(t, stream, created.StreamName) + require.Equal(t, filterName, created.FilterName) + require.Equal(t, query, created.Query.FilterQuery) + + filterDeleted := false + t.Cleanup(func() { + if !filterDeleted { + savedFilterRequest(t, "DELETE", "/filters/"+created.FilterID, nil, 200) + } + }) + + t.Run("ListFilters", func(t *testing.T) { + listed := requireTestSavedFilterListed(t, listTestSavedFilters(t), created.FilterID) + require.Equal(t, filterName, listed.FilterName) + require.Equal(t, query, listed.Query.FilterQuery) + }) + + t.Run("GetFilter", func(t *testing.T) { + filter := savedFilterRequest(t, "GET", "/filters/"+created.FilterID, nil, 200) + require.Equal(t, created.FilterID, filter.FilterID) + require.Equal(t, filterName, filter.FilterName) + require.Equal(t, query, filter.Query.FilterQuery) + }) + + t.Run("UpdateFilter", func(t *testing.T) { + filter := savedFilterRequest(t, "PUT", "/filters/"+created.FilterID, map[string]any{ + "filter_id": created.FilterID, + "stream_name": stream, + "filter_name": updatedName, + "filter_description": "Updated by Quest", + "query": map[string]string{ + "filter_type": "sql", + "filter_query": updatedQuery, + }, + "time_filter": nil, + }, 200) + require.Equal(t, created.FilterID, filter.FilterID) + require.Equal(t, updatedName, filter.FilterName) + require.Equal(t, "Updated by Quest", filter.FilterDescription) + require.Equal(t, updatedQuery, filter.Query.FilterQuery) + }) + + t.Run("DeleteFilter", func(t *testing.T) { + savedFilterRequest(t, "DELETE", "/filters/"+created.FilterID, nil, 200) + filterDeleted = true + savedFilterRequest(t, "GET", "/filters/"+created.FilterID, nil, 400) + for _, filter := range listTestSavedFilters(t) { + require.NotEqual(t, created.FilterID, filter.FilterID) + } + }) +} diff --git a/integrity_test.go b/tests/integration/integrity_test.go similarity index 92% rename from integrity_test.go rename to tests/integration/integrity_test.go index 6423581..7fdec71 100644 --- a/integrity_test.go +++ b/tests/integration/integrity_test.go @@ -79,12 +79,12 @@ func (flog *ParquetFlog) Deref() Flog { } } -// - Send logs to Parseable -// - Wait for sync -// - Download parquet files from the store created by Parseable for the minute -// - Compare the sent logs with the ones loaded from the downloaded parquet func TestIntegrity(t *testing.T) { - CreateStream(t, NewGlob.QueryClient, NewGlob.Stream) + // Verifies that ingested logs match the logs stored in Parquet files. + t.Parallel() + stream := NewGlob.Stream + "integrity" + workDir := t.TempDir() + CreateStream(t, NewGlob.PBClient, stream) iterations := 1 flogsPerIteration := 100 @@ -97,7 +97,7 @@ func TestIntegrity(t *testing.T) { flogs := make([]Flog, 0, iterations*flogsPerIteration) for i := 0; i < iterations; i++ { - flogsFile := fmt.Sprintf("%d.log", i) + flogsFile := filepath.Join(workDir, fmt.Sprintf("%d.log", i)) err := exec.Command("flog", "--number", strconv.Itoa(flogsPerIteration), @@ -111,7 +111,7 @@ func TestIntegrity(t *testing.T) { loadedFlogs := loadFlogsFromFile(flogsFile) - err = ingestFlogs(loadedFlogs, NewGlob.Stream) + err = ingestFlogs(loadedFlogs, stream) if err != nil { t.Fatal("error ingesting flogs", err) } @@ -127,7 +127,7 @@ func TestIntegrity(t *testing.T) { // XXX: We don't need to sleep for the entire minute, just until the next minute boundary. } - parquetFiles := downloadParquetFiles(NewGlob.Stream, NewGlob.MinIoConfig) + parquetFiles := downloadParquetFiles(stream, NewGlob.MinIoConfig, workDir) actualFlogs := loadFlogsFromParquetFiles(parquetFiles) rowCount := len(actualFlogs) @@ -140,7 +140,6 @@ func TestIntegrity(t *testing.T) { require.Equal(t, actualFlog, expectedFlog) } - DeleteStream(t, NewGlob.QueryClient, NewGlob.Stream) } func ingestFlogs(flogs []Flog, stream string) error { @@ -172,7 +171,7 @@ func ingestFlogs(flogs []Flog, stream string) error { return nil } -func downloadParquetFiles(stream string, config MinIoConfig) []string { +func downloadParquetFiles(stream string, config MinIoConfig, downloadDir string) []string { client, err := minio.New(config.Url, config.User, config.Pass, false) if err != nil { slog.Error("couldn't create MinIO client", "error", err) @@ -199,7 +198,7 @@ func downloadParquetFiles(stream string, config MinIoConfig) []string { // Write the MinIO Object we got, into `downloadPath`. - fileName := strings.ReplaceAll(key, "/", ".") + fileName := filepath.Join(downloadDir, strings.ReplaceAll(key, "/", ".")) f, _ := os.Create(fileName) _, err = io.Copy(f, parquetObject) @@ -286,6 +285,10 @@ func loadFlogsFromFile(path string) []Flog { flogs = append(flogs, flog) } + linesErr := lines.Err() + if linesErr != nil { + slog.Error("error reading lines", "error", linesErr) + } return flogs } diff --git a/tests/integration/llm_test.go b/tests/integration/llm_test.go new file mode 100644 index 0000000..d373f50 --- /dev/null +++ b/tests/integration/llm_test.go @@ -0,0 +1,204 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +type testLLMConfig struct { + Provider string `json:"provider"` + Title string `json:"title"` + APIKey string `json:"apiKey"` + URL string `json:"url"` + ID string `json:"id"` + IsDefault bool `json:"isDefault"` + ModelsList []string `json:"modelsList"` + ResponsesEndpoint string `json:"responsesEndpoint"` +} + +type testLLMConfigList struct { + LLMConfigs []testLLMConfig `json:"llmConfigs"` +} + +type testLLMModelList struct { + ID string `json:"id"` + Name string `json:"name"` + Default bool `json:"default"` + Provider string `json:"provider"` + Models []string `json:"models"` +} + +func llmRequest(t *testing.T, method, path string, payload any, expectedStatus int) []byte { + t.Helper() + var body *bytes.Reader + if payload == nil { + body = bytes.NewReader(nil) + } else { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } + + req, err := NewGlob.QueryClient.NewRequestAtPath(method, path, body) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + responseBody := []byte(readAsString(response.Body)) + require.Equalf(t, expectedStatus, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, responseBody) + return responseBody +} + +func decodeTestLLMConfig(t *testing.T, body []byte) testLLMConfig { + t.Helper() + var config testLLMConfig + require.NoError(t, json.Unmarshal(body, &config)) + return config +} + +func listTestLLMConfigs(t *testing.T) []testLLMConfig { + t.Helper() + body := llmRequest(t, "GET", "api/prism/v1/llm", nil, 200) + var result testLLMConfigList + require.NoError(t, json.Unmarshal(body, &result)) + return result.LLMConfigs +} + +func requireTestLLMConfig(t *testing.T, configs []testLLMConfig, id string) testLLMConfig { + t.Helper() + for _, config := range configs { + if config.ID == id { + return config + } + } + t.Fatalf("LLM config %q was not returned", id) + return testLLMConfig{} +} + +func requireTestLLMModelList(t *testing.T, configs []testLLMModelList, id string) testLLMModelList { + t.Helper() + for _, config := range configs { + if config.ID == id { + return config + } + } + t.Fatalf("LLM model config %q was not returned", id) + return testLLMModelList{} +} + +func TestEnterpriseLLMConfigLifecycle(t *testing.T) { + // Verifies LLM config persistence, secret masking, models, defaults, and deletion rules. + if NewGlob.Edition != "enterprise" { + t.Skip("LLM configuration is only available in Enterprise") + } + t.Parallel() + + const maskedAPIKey = "qu********" + basePath := "api/prism/v1/llm" + titleOne := NewGlob.Stream + " llm one" + titleTwo := NewGlob.Stream + " llm two" + modelOne := NewGlob.Stream + "-model-one" + modelTwo := NewGlob.Stream + "-model-two" + secretOne := "quest-secret-key-one" + secretTwo := "quest-secret-key-two" + + createPayload := func(title, secret, model string) map[string]any { + return map[string]any{ + "provider": "Custom", + "title": title, + "apiKey": secret, + "url": "http://unused-llm.test", + "modelsList": []string{model}, + "responsesEndpoint": "responses", + } + } + + createdOneBody := llmRequest(t, "POST", basePath, createPayload(titleOne, secretOne, modelOne), 200) + require.NotContains(t, string(createdOneBody), secretOne) + createdOne := decodeTestLLMConfig(t, createdOneBody) + require.NotEmpty(t, createdOne.ID) + require.Equal(t, "Custom", createdOne.Provider) + require.Equal(t, titleOne, createdOne.Title) + require.Equal(t, maskedAPIKey, createdOne.APIKey) + require.Equal(t, []string{modelOne}, createdOne.ModelsList) + require.Equal(t, "responses", createdOne.ResponsesEndpoint) + + createdTwoBody := llmRequest(t, "POST", basePath, createPayload(titleTwo, secretTwo, modelTwo), 200) + require.NotContains(t, string(createdTwoBody), secretTwo) + createdTwo := decodeTestLLMConfig(t, createdTwoBody) + require.NotEmpty(t, createdTwo.ID) + require.NotEqual(t, createdOne.ID, createdTwo.ID) + require.Equal(t, maskedAPIKey, createdTwo.APIKey) + + t.Run("ListConfigs", func(t *testing.T) { + configs := listTestLLMConfigs(t) + listedOne := requireTestLLMConfig(t, configs, createdOne.ID) + listedTwo := requireTestLLMConfig(t, configs, createdTwo.ID) + require.Equal(t, titleOne, listedOne.Title) + require.Equal(t, titleTwo, listedTwo.Title) + require.Equal(t, maskedAPIKey, listedOne.APIKey) + require.Equal(t, maskedAPIKey, listedTwo.APIKey) + encoded, err := json.Marshal(configs) + require.NoError(t, err) + require.NotContains(t, string(encoded), secretOne) + require.NotContains(t, string(encoded), secretTwo) + }) + + t.Run("ListModels", func(t *testing.T) { + body := llmRequest(t, "GET", basePath+"/models", nil, 200) + var models []testLLMModelList + require.NoError(t, json.Unmarshal(body, &models)) + listedOne := requireTestLLMModelList(t, models, createdOne.ID) + listedTwo := requireTestLLMModelList(t, models, createdTwo.ID) + require.Equal(t, titleOne, listedOne.Name) + require.Equal(t, titleTwo, listedTwo.Name) + require.Equal(t, "Custom", listedOne.Provider) + require.Equal(t, []string{modelOne}, listedOne.Models) + require.Equal(t, []string{modelTwo}, listedTwo.Models) + }) + + t.Run("ProtectAndSwitchDefault", func(t *testing.T) { + firstDefault := decodeTestLLMConfig(t, llmRequest(t, "PUT", basePath+"/"+createdOne.ID+"/default", nil, 200)) + require.True(t, firstDefault.IsDefault) + + deleteBody := llmRequest(t, "DELETE", basePath+"/"+createdOne.ID, nil, 500) + require.Contains(t, string(deleteBody), "Cannot delete the default LLM configuration") + + secondDefault := decodeTestLLMConfig(t, llmRequest(t, "PUT", basePath+"/"+createdTwo.ID+"/default", nil, 200)) + require.True(t, secondDefault.IsDefault) + configs := listTestLLMConfigs(t) + require.False(t, requireTestLLMConfig(t, configs, createdOne.ID).IsDefault) + require.True(t, requireTestLLMConfig(t, configs, createdTwo.ID).IsDefault) + }) + + t.Run("DeleteConfigs", func(t *testing.T) { + deletedOne := decodeTestLLMConfig(t, llmRequest(t, "DELETE", basePath+"/"+createdOne.ID, nil, 200)) + require.Equal(t, createdOne.ID, deletedOne.ID) + deletedTwo := decodeTestLLMConfig(t, llmRequest(t, "DELETE", basePath+"/"+createdTwo.ID, nil, 200)) + require.Equal(t, createdTwo.ID, deletedTwo.ID) + + remaining := listTestLLMConfigs(t) + for _, config := range remaining { + require.NotEqual(t, createdOne.ID, config.ID) + require.NotEqual(t, createdTwo.ID, config.ID) + } + }) +} diff --git a/tests/integration/load_test.go b/tests/integration/load_test.go new file mode 100644 index 0000000..45774a0 --- /dev/null +++ b/tests/integration/load_test.go @@ -0,0 +1,459 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "fmt" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + vus = "10" + duration = "2m" + schema_count = "10" + events_count = "5" + parseableLoadSettleWait = 3 * time.Minute // Allows asynchronous flush and conversion to finish. +) + +func TestLoadStreamBatchWithK6_StaticSchema(t *testing.T) { + // Verifies batch ingestion into a static-schema stream under load. + if NewGlob.Mode == "load" { + t.Parallel() + + staticSchemaStream := NewGlob.Stream + "loadbatchstaticschema" + staticSchemaFlagHeader := map[string]string{"X-P-Static-Schema-Flag": "true"} + CreateStreamWithSchemaBody(t, NewGlob.QueryClient, staticSchemaStream, staticSchemaFlagHeader, SchemaPayload) + if NewGlob.IngestorUrl.String() == "" { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", &NewGlob.QueryUrl), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), + "-e", fmt.Sprintf("P_STREAM=%s", staticSchemaStream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), + "./scripts/load_batch_events.js") + + runK6Load(t, cmd) + } else { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", &NewGlob.IngestorUrl), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), + "-e", fmt.Sprintf("P_STREAM=%s", staticSchemaStream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), + "./scripts/load_batch_events.js") + + runK6Load(t, cmd) + } + } +} + +func TestSmokeLoadWithK6Streams(t *testing.T) { + // Verifies smoke ingestion for normal and custom-partition streams. + t.Parallel() + stream := NewGlob.Stream + "smokeload" + CreateStream(t, NewGlob.PBClient, stream) + runK6Smoke(t, stream) + + customPartitionStream := NewGlob.Stream + "smokeloadcustompartition" + customHeader := map[string]string{"X-P-Custom-Partition": "level"} + CreateStreamWithHeader(t, NewGlob.QueryClient, customPartitionStream, customHeader) + runK6Smoke(t, customPartitionStream) + + time.Sleep(parseableLoadSettleWait) + + t.Run("LoadWithK6Stream", func(t *testing.T) { + QueryLogStreamCount(t, NewGlob.PBClient, stream, 20000) + AssertStreamSchema(t, NewGlob.QueryClient, stream, SchemaBody) + }) + + t.Run("Load_CustomPartition_WithK6Stream", func(t *testing.T) { + QueryLogStreamCount(t, NewGlob.PBClient, customPartitionStream, 20000) + }) + +} + +func runK6Smoke(t *testing.T, stream string) { + t.Helper() + url := NewGlob.QueryUrl.String() + username := NewGlob.QueryUsername + password := NewGlob.QueryPassword + if NewGlob.IngestorUrl.String() != "" { + url = NewGlob.IngestorUrl.String() + username = NewGlob.IngestorUsername + password = NewGlob.IngestorPassword + } + + cmd := exec.Command("k6", + "run", + "--address", "", + "-e", fmt.Sprintf("P_URL=%s", url), + "-e", fmt.Sprintf("P_USERNAME=%s", username), + "-e", fmt.Sprintf("P_PASSWORD=%s", password), + "-e", fmt.Sprintf("P_STREAM=%s", stream), + "./scripts/smoke.js") + + op, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "k6 failed: %s", string(op)) + t.Log(string(op)) +} + +func runK6Load(t *testing.T, cmd *exec.Cmd) { + t.Helper() + op, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "k6 failed: %s", string(op)) + t.Log(string(op)) + time.Sleep(parseableLoadSettleWait) +} + +// func TestSmokeLoad_TimePartition_WithK6Stream(t *testing.T) { +// time_partition_stream := NewGlob.Stream + "timepartition" +// timeHeader := map[string]string{"X-P-Time-Partition": "source_time", "X-P-Time-Partition-Limit": "365d"} +// CreateStreamWithHeader(t, NewGlob.QueryClient, time_partition_stream, timeHeader) +// if NewGlob.IngestorUrl.String() == "" { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", time_partition_stream), +// "./scripts/smoke.js") + +// cmd.Run() +// cmd.Output() +// } else { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", time_partition_stream), +// "./scripts/smoke.js") + +// cmd.Run() +// cmd.Output() +// } +// time.Sleep(120 * time.Second) +// QueryLogStreamCount_Historical(t, NewGlob.PBClient, time_partition_stream, 20000) +// DeleteStream(t, NewGlob.PBClient, time_partition_stream) +// } + +// func TestSmokeLoad_TimeAndCustomPartition_WithK6Stream(t *testing.T) { +// custom_partition_stream := NewGlob.Stream + "timecustompartition" +// customHeader := map[string]string{"X-P-Custom-Partition": "level", "X-P-Time-Partition": "source_time", "X-P-Time-Partition-Limit": "365d"} +// CreateStreamWithHeader(t, NewGlob.QueryClient, custom_partition_stream, customHeader) +// if NewGlob.IngestorUrl.String() == "" { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", custom_partition_stream), +// "./scripts/smoke.js") + +// cmd.Run() +// cmd.Output() +// } else { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", custom_partition_stream), +// "./scripts/smoke.js") + +// cmd.Run() +// cmd.Output() +// } +// time.Sleep(180 * time.Second) +// QueryLogStreamCount_Historical(t, NewGlob.PBClient, custom_partition_stream, 20000) +// DeleteStream(t, NewGlob.PBClient, custom_partition_stream) +// } + +func TestLoadStreamBatchWithK6(t *testing.T) { + // Verifies batch ingestion into a normal stream under load. + if NewGlob.Mode == "load" { + t.Parallel() + + stream := NewGlob.Stream + "loadbatch" + CreateStream(t, NewGlob.PBClient, stream) + if NewGlob.IngestorUrl.String() == "" { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), + "-e", fmt.Sprintf("P_STREAM=%s", stream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), + "./scripts/load_batch_events.js") + + runK6Load(t, cmd) + } else { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), + "-e", fmt.Sprintf("P_STREAM=%s", stream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), + "./scripts/load_batch_events.js") + + runK6Load(t, cmd) + } + } +} + +// func TestLoadHistoricalStreamBatchWithK6(t *testing.T) { +// if NewGlob.Mode == "load" { +// historicalStream := NewGlob.Stream + "historical" +// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} +// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) +// if NewGlob.IngestorUrl.String() == "" { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), +// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), +// "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), +// "./scripts/load_historical_batch_events.js", +// "--vus=", vus, +// "--duration=", duration) + +// cmd.Run() +// op, err := cmd.Output() +// if err != nil { +// t.Log(err) +// } +// t.Log(string(op)) +// } else { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), +// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), +// "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), +// "./scripts/load_historical_batch_events.js", +// "--vus=", vus, +// "--duration=", duration) + +// cmd.Run() +// op, err := cmd.Output() +// if err != nil { +// t.Log(err) +// } +// t.Log(string(op)) +// } + +// DeleteStream(t, NewGlob.PBClient, historicalStream) +// } +// } + +func TestLoadStreamBatchWithCustomPartitionWithK6(t *testing.T) { + // Verifies batch ingestion into a custom-partition stream under load. + if NewGlob.Mode != "load" { + return + } + t.Parallel() + + customPartitionStream := NewGlob.Stream + "loadbatchcustompartition" + customHeader := map[string]string{"X-P-Custom-Partition": "level"} + CreateStreamWithHeader(t, NewGlob.QueryClient, customPartitionStream, customHeader) + if NewGlob.IngestorUrl.String() == "" { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), + "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), + "./scripts/load_batch_events.js") + + runK6Load(t, cmd) + } else { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), + "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "-e", fmt.Sprintf("P_EVENTS_COUNT=%s", events_count), + "./scripts/load_batch_events.js") + + runK6Load(t, cmd) + } +} + +func TestLoadStreamNoBatchWithK6(t *testing.T) { + // Verifies single-event ingestion into a normal stream under load. + if NewGlob.Mode == "load" { + t.Parallel() + + stream := NewGlob.Stream + "loadsingle" + CreateStream(t, NewGlob.PBClient, stream) + if NewGlob.IngestorUrl.String() == "" { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), + "-e", fmt.Sprintf("P_STREAM=%s", stream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "./scripts/load_single_event.js") + + runK6Load(t, cmd) + } else { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), + "-e", fmt.Sprintf("P_STREAM=%s", stream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "./scripts/load_single_event.js") + + runK6Load(t, cmd) + } + + } +} + +// func TestLoadHistoricalStreamNoBatchWithK6(t *testing.T) { +// if NewGlob.Mode == "load" { +// historicalStream := NewGlob.Stream + "historical" +// timeHeader := map[string]string{"X-P-Time-Partition": "source_time"} +// CreateStreamWithHeader(t, NewGlob.QueryClient, historicalStream, timeHeader) +// if NewGlob.IngestorUrl.String() == "" { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), +// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), +// "./scripts/load_single_events.js", +// "--vus=", vus, +// "--duration=", duration) + +// cmd.Run() +// op, err := cmd.Output() +// if err != nil { +// t.Log(err) +// } +// t.Log(string(op)) +// } else { +// cmd := exec.Command("k6", +// "run", +// "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), +// "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), +// "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), +// "-e", fmt.Sprintf("P_STREAM=%s", historicalStream), +// "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), +// "./scripts/load_single_events.js", +// "--vus=", vus, +// "--duration=", duration) + +// cmd.Run() +// op, err := cmd.Output() +// if err != nil { +// t.Log(err) +// } +// t.Log(string(op)) +// } + +// DeleteStream(t, NewGlob.PBClient, historicalStream) +// } +// } + +func TestLoadStreamNoBatchWithCustomPartitionWithK6(t *testing.T) { + // Verifies single-event ingestion into a custom-partition stream under load. + if NewGlob.Mode != "load" { + return + } + t.Parallel() + + customPartitionStream := NewGlob.Stream + "loadsinglecustompartition" + customHeader := map[string]string{"X-P-Custom-Partition": "level"} + CreateStreamWithHeader(t, NewGlob.QueryClient, customPartitionStream, customHeader) + if NewGlob.IngestorUrl.String() == "" { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.QueryUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.QueryUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.QueryPassword), + "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "./scripts/load_single_event.js") + + runK6Load(t, cmd) + } else { + cmd := exec.Command("k6", + "run", + "--address", "", + "--vus", vus, + "--duration", duration, + "-e", fmt.Sprintf("P_URL=%s", NewGlob.IngestorUrl.String()), + "-e", fmt.Sprintf("P_USERNAME=%s", NewGlob.IngestorUsername), + "-e", fmt.Sprintf("P_PASSWORD=%s", NewGlob.IngestorPassword), + "-e", fmt.Sprintf("P_STREAM=%s", customPartitionStream), + "-e", fmt.Sprintf("P_SCHEMA_COUNT=%s", schema_count), + "./scripts/load_single_event.js") + + runK6Load(t, cmd) + } +} diff --git a/model.go b/tests/integration/model.go similarity index 99% rename from model.go rename to tests/integration/model.go index 6a4d115..d343775 100644 --- a/model.go +++ b/tests/integration/model.go @@ -502,11 +502,12 @@ func getIdFromTargetResponse(body io.Reader) string { return target.Target.Id } -func getAlertBody(stream string, targetId string) string { +func getAlertBody(stream string, targetId string, metricName string) string { return fmt.Sprintf(` { "severity": "medium", "title": "AlertTitle", + "metric_name": %q, "query": "select count(level) from %s where level = 'info'", "alertType": "threshold", "thresholdConfig": { @@ -534,7 +535,7 @@ func getAlertBody(stream string, targetId string) string { "%s" ], "tags": ["quest-test"] - }`, stream, targetId) + }`, metricName, stream, targetId) } func getMetadataFromAlertResponse(body io.Reader) (string, string, string, []string) { diff --git a/tests/integration/promql_test.go b/tests/integration/promql_test.go new file mode 100644 index 0000000..e5a4532 --- /dev/null +++ b/tests/integration/promql_test.go @@ -0,0 +1,213 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const promQLMetricName = "gen" +const promQLMetricCount = 120 +const promQLMetricRate = 2 + +type promQLCommandCase struct { + name string + args []string + expectMetric bool +} + +type promQLExpressionCase struct { + name string + expression string +} + +type promQLResponse struct { + Status string `json:"status"` + Data json.RawMessage `json:"data"` +} + +type promQLData struct { + ResultType string `json:"resultType"` + Result []json.RawMessage `json:"result"` +} + +func promQLExpressionCases() []promQLExpressionCase { + return []promQLExpressionCase{ + {name: "metric selector", expression: promQLMetricName}, + {name: "sum aggregation", expression: "sum(" + promQLMetricName + ")"}, + {name: "average aggregation", expression: "avg(" + promQLMetricName + ")"}, + {name: "count aggregation", expression: "count(" + promQLMetricName + ")"}, + {name: "arithmetic", expression: promQLMetricName + " + 1"}, + {name: "comparison", expression: promQLMetricName + " > bool 0"}, + {name: "average over time", expression: "avg_over_time(" + promQLMetricName + "[5m])"}, + {name: "sum over time", expression: "sum_over_time(" + promQLMetricName + "[5m])"}, + {name: "count over time", expression: "count_over_time(" + promQLMetricName + "[5m])"}, + {name: "delta", expression: "delta(" + promQLMetricName + "[5m])"}, + } +} + +func promQLCommandCases(dataset, instantTime string) []promQLCommandCase { + return []promQLCommandCase{ + {name: "range query", args: []string{"promql", "run", promQLMetricName, "--dataset", dataset, "--from", "10m", "--step", "1m"}, expectMetric: true}, + {name: "instant query", args: []string{"promql", "run", promQLMetricName, "--dataset", dataset, "--instant", "--to", instantTime}, expectMetric: true}, + {name: "labels", args: []string{"promql", "labels", "--dataset", dataset}}, + {name: "label values", args: []string{"promql", "label-values", "__name__", "--dataset", dataset}, expectMetric: true}, + {name: "series", args: []string{"promql", "series", "--dataset", dataset, "--match", fmt.Sprintf(`{__name__=%q}`, promQLMetricName)}, expectMetric: true}, + {name: "cardinality label names", args: []string{"promql", "cardinality", "label-names", "--dataset", dataset}}, + {name: "cardinality label values", args: []string{"promql", "cardinality", "label-values", "--dataset", dataset, "--label", "__name__"}}, + {name: "cardinality active series", args: []string{"promql", "cardinality", "active-series", "--dataset", dataset}}, + {name: "active queries", args: []string{"promql", "active-queries"}}, + {name: "TSDB stats", args: []string{"promql", "tsdb", "--dataset", dataset}}, + } +} + +func ingestPromQLMetric(t *testing.T, dataset string) time.Time { + // Generates and ingests real OTLP metrics, like RunFlog does for SQL tests. + t.Helper() + targetURL := NewGlob.QueryUrl + username := NewGlob.QueryUsername + password := NewGlob.QueryPassword + if NewGlob.IngestorUrl.String() != "" { + targetURL = NewGlob.IngestorUrl + username = NewGlob.IngestorUsername + password = NewGlob.IngestorPassword + } + + args := []string{ + "metrics", + "--metrics", fmt.Sprint(promQLMetricCount), + "--rate", fmt.Sprint(promQLMetricRate), + "--otlp-http", + "--otlp-endpoint", targetURL.Host, + "--otlp-http-url-path", "/v1/metrics", + "--otlp-header", fmt.Sprintf(`x-p-stream=%q`, dataset), + "--otlp-header", `x-p-log-source="otel-metrics"`, + "--otlp-header", fmt.Sprintf(`Authorization=%q`, basicAuthorization(username, password)), + } + if targetURL.Scheme == "http" { + args = append(args, "--otlp-insecure") + } + + t.Logf("ingesting OTLP metrics: dataset=%s metric=%s count=%d rate=%d/s endpoint=%s/v1/metrics", dataset, promQLMetricName, promQLMetricCount, promQLMetricRate, targetURL.String()) + output, err := exec.Command("telemetrygen", args...).CombinedOutput() + require.NoErrorf(t, err, "telemetrygen metrics failed: %s", output) + t.Logf("telemetrygen output: %s", strings.TrimSpace(string(output))) + return time.Now().UTC().Add(time.Second) +} + +func waitForPromQLMetric(t *testing.T, dataset string) { + // Retries the real instant query until Parseable makes the ingested metric queryable. + t.Helper() + const visibilityTimeout = 5 * time.Minute + deadline := time.Now().Add(visibilityTimeout) + var lastResult string + var lastErr error + for time.Now().Before(deadline) { + var response promQLResponse + result, err := NewGlob.PBClient.RunJSON( + context.Background(), + &response, + "promql", "run", promQLMetricName, "--dataset", dataset, "--from", "10m", "--step", "1m", + ) + lastResult = fmt.Sprintf("exit=%d, stdout=%q, stderr=%q", result.ExitCode, result.Stdout, result.Stderr) + lastErr = err + if err == nil && response.Status == "success" && strings.Contains(string(response.Data), promQLMetricName) { + return + } + time.Sleep(5 * time.Second) + } + require.NoErrorf(t, lastErr, "metric did not become queryable within %s (%s)", visibilityTimeout, lastResult) + require.Failf(t, "metric did not become queryable", "metric %q was absent after %s (%s)", promQLMetricName, visibilityTimeout, lastResult) +} + +func basicAuthorization(username, password string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) +} + +func createPromQLDataset(t *testing.T, dataset string) { + t.Helper() + result, err := NewGlob.PBClient.Run(context.Background(), "dataset", "add", dataset, "--type", "metrics") + require.NoErrorf(t, err, "pb metrics dataset add failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) + cleanupPromQLDataset(t, dataset) +} + +func cleanupPromQLDataset(t *testing.T, dataset string) { + t.Helper() + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, dataset) + }) +} + +func TestPromQLCommands(t *testing.T) { + // Verifies every PromQL command against Enterprise using the PB client. + if NewGlob.Edition != "enterprise" { + t.Skip("PromQL is only available in Enterprise") + } + t.Parallel() + dataset := NewGlob.Stream + "promql" + instantTime := ingestPromQLMetric(t, dataset).Format(time.RFC3339Nano) + cleanupPromQLDataset(t, dataset) + t.Logf("waiting %s for ingested metrics to flush before the first PromQL query", parseableLoadSettleWait) + time.Sleep(parseableLoadSettleWait) + waitForPromQLMetric(t, dataset) + + t.Run("queries", func(t *testing.T) { + for _, tc := range promQLExpressionCases() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var response promQLResponse + result, err := NewGlob.PBClient.RunJSON( + context.Background(), + &response, + "promql", "run", tc.expression, "--dataset", dataset, "--from", "10m", "--step", "1m", + ) + require.NoErrorf(t, err, "pb PromQL query %q failed (exit=%d, stdout=%q, stderr=%q)", tc.expression, result.ExitCode, result.Stdout, result.Stderr) + require.Equal(t, "success", response.Status) + + var data promQLData + require.NoError(t, json.Unmarshal(response.Data, &data)) + require.NotEmptyf(t, data.Result, "PromQL query %q returned no series", tc.expression) + }) + } + }) + + for _, tc := range promQLCommandCases(dataset, instantTime) { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var response promQLResponse + result, err := NewGlob.PBClient.RunJSON(context.Background(), &response, tc.args...) + require.NoErrorf(t, err, "pb %v failed (exit=%d, stdout=%q, stderr=%q)", tc.args, result.ExitCode, result.Stdout, result.Stderr) + require.Equal(t, "success", response.Status) + require.NotEmpty(t, response.Data) + require.NotEqual(t, "null", string(response.Data)) + if tc.expectMetric { + require.Contains(t, string(response.Data), promQLMetricName) + } + }) + } +} diff --git a/tests/integration/query_test.go b/tests/integration/query_test.go new file mode 100644 index 0000000..711d5c2 --- /dev/null +++ b/tests/integration/query_test.go @@ -0,0 +1,234 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type queryWithFieldsResponse struct { + Fields []string `json:"fields"` + Records []json.RawMessage `json:"records"` +} + +type countsResponse struct { + Fields []string `json:"fields"` + Records []countsRecord `json:"records"` +} + +type countsRecord struct { + Count uint64 `json:"count"` +} + +type datasetFieldStats struct { + FieldCount float64 `json:"field_count"` + DistinctCount float64 `json:"distinct_count"` + DistinctValues map[string]float64 `json:"distinct_values"` +} + +func postQueryFeature(path string, payload any) (int, []byte, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return 0, nil, err + } + req, err := NewGlob.QueryClient.NewRequestAtPath("POST", path, bytes.NewReader(encoded)) + if err != nil { + return 0, nil, err + } + response, err := NewGlob.QueryClient.Do(req) + if err != nil { + return 0, nil, err + } + defer response.Body.Close() + return response.StatusCode, []byte(readAsString(response.Body)), nil +} + +func TestSmokeIngestAndQuery(t *testing.T) { + // Verifies ingestion and SQL queries across two streams. + t.Parallel() + stream1 := NewGlob.Stream + "ingestquery1" + stream2 := NewGlob.Stream + "ingestquery2" + CreateStream(t, NewGlob.PBClient, stream1) + CreateStream(t, NewGlob.PBClient, stream2) + + if NewGlob.IngestorUrl.String() == "" { + RunFlog(t, NewGlob.QueryClient, stream1) + RunFlog(t, NewGlob.QueryClient, stream2) + } else { + RunFlog(t, NewGlob.IngestorClient, stream1) + RunFlog(t, NewGlob.IngestorClient, stream2) + } + + // Parseable persists ingested events in a two-minute batch. Both streams are + // populated before this wait so all ingestion and query assertions can share + // the same batch window. + time.Sleep(120 * time.Second) + + t.Run("IngestEventsToStream", func(t *testing.T) { + t.Parallel() + QueryLogStreamCount(t, NewGlob.PBClient, stream1, 50) + AssertStreamSchema(t, NewGlob.QueryClient, stream1, FlogJsonSchema) + }) + + t.Run("RunQueries", func(t *testing.T) { + t.Parallel() + QueryLogStreamCount(t, NewGlob.PBClient, stream1, 50) + AssertQueryOK(t, NewGlob.PBClient, "SELECT * FROM %s", stream1) + AssertQueryOK(t, NewGlob.PBClient, "SELECT * FROM %s OFFSET 25 LIMIT 25", stream1) + + for _, item := range flogStreamFields() { + AssertQueryOK(t, NewGlob.PBClient, "SELECT %s FROM %s", item, stream1) + } + + AssertQueryOK(t, NewGlob.PBClient, "SELECT * FROM %s WHERE method = 'POST'", stream1) + AssertQueryOK(t, NewGlob.PBClient, "SELECT method, COUNT(*) FROM %s GROUP BY method", stream1) + AssertQueryOK(t, NewGlob.PBClient, `SELECT DATE_TRUNC('minute', p_timestamp) as minute, COUNT(*) FROM %s GROUP BY minute`, stream1) + }) + + t.Run("QueryTwoStreams", func(t *testing.T) { + t.Parallel() + QueryTwoLogStreamCount(t, NewGlob.PBClient, stream1, stream2, 100) + }) + + t.Run("QueryWithFields", func(t *testing.T) { + // Verifies the UI query response includes field names and records. + t.Parallel() + endTime := time.Now().Add(time.Second).Format(time.RFC3339Nano) + startTime := time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano) + payload, err := json.Marshal(map[string]string{ + "query": "SELECT method, status FROM " + stream1 + " LIMIT 1", + "startTime": startTime, + "endTime": endTime, + }) + require.NoError(t, err) + + req, err := NewGlob.QueryClient.NewRequest("POST", "query", bytes.NewReader(payload)) + require.NoError(t, err) + params := req.URL.Query() + params.Set("fields", "true") + req.URL.RawQuery = params.Encode() + + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + + result, err := readJsonBody[queryWithFieldsResponse](response.Body) + require.NoError(t, err) + require.Equal(t, []string{"method", "status"}, result.Fields) + require.Len(t, result.Records, 1) + }) + + t.Run("QueryCounts", func(t *testing.T) { + // Verifies that the UI counts API returns graph buckets for ingested events. + t.Parallel() + endTime := time.Now().Add(time.Second).Format(time.RFC3339Nano) + startTime := time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano) + payload, err := json.Marshal(map[string]any{ + "stream": stream1, + "startTime": startTime, + "endTime": endTime, + "numBins": 10, + }) + require.NoError(t, err) + + req, err := NewGlob.QueryClient.NewRequest("POST", "counts", bytes.NewReader(payload)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + + result, err := readJsonBody[countsResponse](response.Body) + require.NoError(t, err) + require.Equal(t, []string{"start_time", "end_time", "count"}, result.Fields) + require.NotEmpty(t, result.Records) + var total uint64 + for _, record := range result.Records { + total += record.Count + } + require.Equal(t, uint64(50), total) + }) + + t.Run("DatasetFieldStats", func(t *testing.T) { + // Verifie that dataset field statistics are returned for a stream. + t.Parallel() + payload := map[string]any{ + "datasetName": stream1, + "startTime": time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano), + "endTime": time.Now().Add(time.Minute).Format(time.RFC3339Nano), + "fields": []string{"method", "status"}, + "offset": 0, + "limit": 5, + } + var stats map[string]datasetFieldStats + var status int + var body []byte + var err error + deadline := time.Now().Add(time.Minute) + for { + status, body, err = postQueryFeature("api/prism/v1/dataset_stats", payload) + if err == nil && status == 200 && json.Unmarshal(body, &stats) == nil && + stats["method"].FieldCount > 0 && stats["status"].FieldCount > 0 { + break + } + if time.Now().After(deadline) { + require.FailNowf(t, "dataset stats did not become available", "status=%d body=%s error=%v", status, body, err) + } + time.Sleep(10 * time.Second) + } + require.Greater(t, stats["method"].DistinctCount, float64(0)) + require.NotEmpty(t, stats["method"].DistinctValues) + require.Greater(t, stats["status"].DistinctCount, float64(0)) + }) + +} + +func TestQueryContextEndpoint(t *testing.T) { + // Verifies the shared log-context route is registered. + t.Parallel() + status, body, err := postQueryFeature("api/v1/query/context", map[string]any{}) + require.NoError(t, err) + require.NotEqualf(t, 404, status, "log-context route was not found: %s", body) +} + +func TestForecastCountsEndpoint(t *testing.T) { + // Verifies the Enterprise forecast-counts route is registered. + if NewGlob.Edition != "enterprise" { + t.Skip("forecast counts are only available in Enterprise") + } + t.Parallel() + status, body, err := postQueryFeature("api/v1/counts?forecast=true", map[string]any{}) + require.NoError(t, err) + require.Equalf(t, 400, status, "expected request validation from the forecast-counts route: %s", body) +} + +func TestPanoramaForecastEndpoint(t *testing.T) { + // Verifies the Enterprise Panorama forecast route is registered. + if NewGlob.Edition != "enterprise" { + t.Skip("Panorama forecast is only available in Enterprise") + } + t.Parallel() + status, body, err := postQueryFeature("api/prism/v1/panorama/forecast", map[string]any{}) + require.NoError(t, err) + require.Equalf(t, 400, status, "expected request validation from the Panorama route: %s", body) +} diff --git a/tests/integration/rbac_test.go b/tests/integration/rbac_test.go new file mode 100644 index 0000000..ae5adde --- /dev/null +++ b/tests/integration/rbac_test.go @@ -0,0 +1,338 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + httpclient "quest/tests/integration/clients/http" +) + +// RBAC tests mutate shared server-wide user and role state. They are still +// scheduled as parallel tests, but those mutations must not overlap. +var rbacMu sync.Mutex + +type prismUserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email *string `json:"email"` + Roles map[string]json.RawMessage `json:"roles"` +} + +func getPrismUser(t *testing.T, user string) prismUserResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", "users/"+user, nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + result, err := readJsonBody[prismUserResponse](response.Body) + require.NoError(t, err) + return result +} + +func getDefaultRole(t *testing.T) *string { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", "role/default", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + role, err := readJsonBody[*string](response.Body) + require.NoError(t, err) + return role +} + +func setDefaultRole(t *testing.T, role string) { + t.Helper() + payload, err := json.Marshal(role) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequest("PUT", "role/default", bytes.NewReader(payload)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) +} + +func TestSmoke_AllUsersAPI(t *testing.T) { + // Verifies the UI user APIs together with creation, password, and deletion. + t.Skip("temporarily disabled due to a Parseable RBAC server issue") + t.Parallel() + rbacMu.Lock() + t.Cleanup(rbacMu.Unlock) + + role := NewGlob.Stream + "allusersrole" + addedRole := NewGlob.Stream + "allusersaddedrole" + user := NewGlob.Stream + "allusers" + CreateRole(t, NewGlob.QueryClient, role, dummyRole) + t.Cleanup(func() { + DeleteRole(t, NewGlob.PBClient, role) + }) + AssertRole(t, NewGlob.QueryClient, role, dummyRole) + CreateRole(t, NewGlob.QueryClient, addedRole, dummyRole) + t.Cleanup(func() { + DeleteRole(t, NewGlob.PBClient, addedRole) + }) + AssertRole(t, NewGlob.QueryClient, addedRole, dummyRole) + + CreateUserWithRole(t, NewGlob.PBClient, user, []string{role}) + t.Cleanup(func() { + DeleteUser(t, NewGlob.PBClient, user) + }) + AssertUserRole(t, NewGlob.QueryClient, user, role, dummyRole) + + t.Run("ListUsers", func(t *testing.T) { + req, err := NewGlob.QueryClient.NewRequest("GET", "users", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + users, err := readJsonBody[[]prismUserResponse](response.Body) + require.NoError(t, err) + var listedUser *prismUserResponse + for index := range users { + if users[index].ID == user { + listedUser = &users[index] + break + } + } + require.NotNil(t, listedUser) + require.Equal(t, user, listedUser.Username) + require.Contains(t, listedUser.Roles, role) + }) + + t.Run("GetUser", func(t *testing.T) { + result := getPrismUser(t, user) + require.Equal(t, user, result.ID) + require.Equal(t, user, result.Username) + require.Contains(t, result.Roles, role) + }) + + t.Run("UpdateUserEmail", func(t *testing.T) { + email := user + "@example.com" + payload, err := json.Marshal(map[string]string{"email": email}) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequest("PATCH", "user/"+user, bytes.NewReader(payload)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + require.NoError(t, response.Body.Close()) + + updated := getPrismUser(t, user) + require.NotNil(t, updated.Email) + require.Equal(t, email, *updated.Email) + }) + + t.Run("AddUserRole", func(t *testing.T) { + payload, err := json.Marshal([]string{addedRole}) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequest("PATCH", "user/"+user+"/role/add", bytes.NewReader(payload)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + message, err := readJsonBody[string](response.Body) + require.NoError(t, err) + require.Equal(t, "Roles updated successfully for "+user, message) + + updated := getPrismUser(t, user) + require.Contains(t, updated.Roles, role) + require.Contains(t, updated.Roles, addedRole) + }) + + t.Run("RemoveUserRole", func(t *testing.T) { + payload, err := json.Marshal([]string{addedRole}) + require.NoError(t, err) + req, err := NewGlob.QueryClient.NewRequest("PATCH", "user/"+user+"/role/remove", bytes.NewReader(payload)) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + message, err := readJsonBody[string](response.Body) + require.NoError(t, err) + require.Equal(t, "Roles updated successfully for "+user, message) + + updated := getPrismUser(t, user) + require.Contains(t, updated.Roles, role) + require.NotContains(t, updated.Roles, addedRole) + }) + + RegenPassword(t, NewGlob.QueryClient, user) +} + +func TestSmokeRoleUIEndpoints(t *testing.T) { + // Verifies role listing and default-role APIs without leaking global state. + t.Skip("temporarily disabled due to a Parseable RBAC server issue") + t.Parallel() + rbacMu.Lock() + t.Cleanup(rbacMu.Unlock) + + role := NewGlob.Stream + "defaultrole" + CreateRole(t, NewGlob.QueryClient, role, dummyRole) + t.Cleanup(func() { + DeleteRole(t, NewGlob.PBClient, role) + }) + + t.Run("ListRoles", func(t *testing.T) { + req, err := NewGlob.QueryClient.NewRequest("GET", "roles", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + roles, err := readJsonBody[map[string]json.RawMessage](response.Body) + require.NoError(t, err) + require.Contains(t, roles, role) + }) + + originalDefault := getDefaultRole(t) + t.Run("GetDefaultRole", func(t *testing.T) { + currentDefault := getDefaultRole(t) + require.Equal(t, originalDefault, currentDefault) + }) + + t.Run("SetDefaultRole", func(t *testing.T) { + if originalDefault == nil { + t.Skip("Parseable has no default role and the API cannot clear a default role after this test") + } + + setDefaultRole(t, role) + t.Cleanup(func() { + setDefaultRole(t, *originalDefault) + }) + updatedDefault := getDefaultRole(t) + require.NotNil(t, updatedDefault) + require.Equal(t, role, *updatedDefault) + }) +} + +func TestSmoke_NewUserWithRole(t *testing.T) { + // Verifies that a new user can be created with a role. + t.Skip("temporarily disabled due to a Parseable RBAC server issue") + t.Parallel() + rbacMu.Lock() + defer rbacMu.Unlock() + + role := NewGlob.Stream + "newuserrole" + user := NewGlob.Stream + "newuser" + + CreateRole(t, NewGlob.QueryClient, role, dummyRole) + AssertRole(t, NewGlob.QueryClient, role, dummyRole) + CreateUserWithRole(t, NewGlob.PBClient, user, []string{role}) + AssertUserRole(t, NewGlob.QueryClient, user, role, dummyRole) + DeleteUser(t, NewGlob.PBClient, user) + DeleteRole(t, NewGlob.PBClient, role) +} + +func TestSmokeRbacBasic(t *testing.T) { + // Verifies that a user's role controls basic API access. + t.Skip("temporarily disabled due to a Parseable RBAC server issue") + t.Parallel() + rbacMu.Lock() + defer rbacMu.Unlock() + + stream := NewGlob.Stream + "rbacbasic" + role := NewGlob.Stream + "rbacbasicrole" + user := NewGlob.Stream + "rbacbasicuser" + CreateStream(t, NewGlob.PBClient, stream) + CreateRole(t, NewGlob.QueryClient, role, dummyRole) + AssertRole(t, NewGlob.QueryClient, role, dummyRole) + CreateUserWithRole(t, NewGlob.PBClient, user, []string{role}) + userClient := NewGlob.QueryClient + userClient.Username = user + userClient.Password = RegenPassword(t, NewGlob.QueryClient, user) + checkAPIAccess(t, userClient, NewGlob.QueryClient, stream, "editor") + DeleteUser(t, NewGlob.PBClient, user) + DeleteRole(t, NewGlob.PBClient, role) +} + +func TestSmokeRoles(t *testing.T) { + // Verifies API access for ingestor, reader, writer, and editor roles. + t.Skip("temporarily disabled due to a Parseable RBAC server issue") + t.Parallel() + rbacMu.Lock() + defer rbacMu.Unlock() + + stream := NewGlob.Stream + "roles" + editorDeleteStream := NewGlob.Stream + "roleseditordelete" + CreateStream(t, NewGlob.PBClient, stream) + CreateStream(t, NewGlob.PBClient, editorDeleteStream) + cases := []struct { + roleName string + body string + }{ + { + roleName: NewGlob.Stream + "ingestor", + body: Roleingestor(stream), + }, + { + roleName: NewGlob.Stream + "reader", + body: RoleReader(stream), + }, + { + roleName: NewGlob.Stream + "writer", + body: RoleWriter(stream), + }, + { + roleName: NewGlob.Stream + "editor", + body: RoleEditor, + }, + } + + for _, tc := range cases { + t.Run(tc.roleName, func(t *testing.T) { + CreateRole(t, NewGlob.QueryClient, tc.roleName, tc.body) + AssertRole(t, NewGlob.QueryClient, tc.roleName, tc.body) + username := tc.roleName + "_user" + password := CreateUserWithRole(t, NewGlob.PBClient, username, []string{tc.roleName}) + var ingestClient httpclient.HTTPClient + queryClient := NewGlob.QueryClient + queryClient.Username = username + queryClient.Password = password + if NewGlob.IngestorUrl.String() != "" { + ingestClient = NewGlob.IngestorClient + ingestClient.Username = username + ingestClient.Password = password + } else { + ingestClient = NewGlob.QueryClient + ingestClient.Username = username + ingestClient.Password = password + } + + roleKind := strings.TrimPrefix(tc.roleName, NewGlob.Stream) + accessStream := stream + if roleKind == "editor" { + accessStream = editorDeleteStream + } + checkAPIAccess(t, queryClient, ingestClient, accessStream, roleKind) + DeleteUser(t, NewGlob.PBClient, username) + DeleteRole(t, NewGlob.PBClient, tc.roleName) + }) + } +} diff --git a/tests/integration/retention_test.go b/tests/integration/retention_test.go new file mode 100644 index 0000000..196242d --- /dev/null +++ b/tests/integration/retention_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmokeSetRetention(t *testing.T) { + // Verifies that retention rules can be set on a stream. + t.Parallel() + stream := NewGlob.Stream + "setretention" + CreateStream(t, NewGlob.PBClient, stream) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, stream) + }) + req, _ := NewGlob.QueryClient.NewRequest("PUT", "logstream/"+stream+"/retention", strings.NewReader(RetentionBody)) + response, err := NewGlob.QueryClient.Do(req) + require.NoErrorf(t, err, "Request failed: %s", err) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) +} + +func TestSmokeGetRetention(t *testing.T) { + // Verifies that PB returns the configured retention rules. + t.Parallel() + stream := NewGlob.Stream + "getretention" + CreateStream(t, NewGlob.PBClient, stream) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, stream) + }) + + req, _ := NewGlob.QueryClient.NewRequest("PUT", "logstream/"+stream+"/retention", strings.NewReader(RetentionBody)) + response, err := NewGlob.QueryClient.Do(req) + require.NoErrorf(t, err, "Request failed: %s", err) + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) + + info := DatasetInfoWithPB(t, NewGlob.PBClient, stream) + var expected []PBRetentionRule + require.NoError(t, json.Unmarshal([]byte(RetentionBody), &expected)) + require.Equal(t, expected, info.Retention, "Get retention response doesn't match with retention config returned") +} diff --git a/test_utils.go b/tests/integration/test_utils.go similarity index 63% rename from test_utils.go rename to tests/integration/test_utils.go index caabfc9..1c03363 100644 --- a/test_utils.go +++ b/tests/integration/test_utils.go @@ -18,6 +18,7 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -27,10 +28,8 @@ import ( "time" "github.com/stretchr/testify/require" -) - -const ( - sleepDuration = 2 * time.Second + httpclient "quest/tests/integration/clients/http" + "quest/tests/integration/clients/pb" ) func flogStreamFields() []string { @@ -59,18 +58,50 @@ func readJsonBody[T any](body io.Reader) (res T, err error) { return } -func Sleep() { - time.Sleep(sleepDuration) +type PBDataset struct { + Title string `json:"title"` } -func CreateStream(t *testing.T, client HTTPClient, stream string) { - req, _ := client.NewRequest("PUT", "logstream/"+stream, nil) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) +type PBDatasetInfo struct { + DatasetType string `json:"dataset_type"` + Retention []PBRetentionRule `json:"retention"` +} + +type PBRetentionRule struct { + Description string `json:"description"` + Action string `json:"action"` + Duration string `json:"duration"` +} + +func CreateStream(t *testing.T, client pb.PBClient, dataset string) { + t.Helper() + result, err := client.Run(context.Background(), "dataset", "add", dataset, "--type", "logs") + require.NoErrorf(t, err, "pb dataset add failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) +} + +func ListDatasetsWithPB(t *testing.T, client pb.PBClient) []PBDataset { + t.Helper() + var datasets []PBDataset + result, err := client.RunJSON(context.Background(), &datasets, "dataset", "list") + require.NoErrorf(t, err, "pb dataset list failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) + return datasets +} + +func DatasetInfoWithPB(t *testing.T, client pb.PBClient, dataset string) PBDatasetInfo { + t.Helper() + var info PBDatasetInfo + result, err := client.RunJSON(context.Background(), &info, "dataset", "info", dataset) + require.NoErrorf(t, err, "pb dataset info failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) + return info +} + +func DeleteStream(t *testing.T, client pb.PBClient, dataset string) { + t.Helper() + result, err := client.Run(context.Background(), "dataset", "remove", dataset) + require.NoErrorf(t, err, "pb dataset remove failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) } -func CreateStreamWithHeader(t *testing.T, client HTTPClient, stream string, header map[string]string) { +func CreateStreamWithHeader(t *testing.T, client httpclient.HTTPClient, stream string, header map[string]string) { req, _ := client.NewRequest("PUT", "logstream/"+stream, nil) for k, v := range header { req.Header.Add(k, v) @@ -80,7 +111,7 @@ func CreateStreamWithHeader(t *testing.T, client HTTPClient, stream string, head require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) } -func CreateStreamWithCustompartitionError(t *testing.T, client HTTPClient, stream string, header map[string]string) { +func CreateStreamWithCustompartitionError(t *testing.T, client httpclient.HTTPClient, stream string, header map[string]string) { req, _ := client.NewRequest("PUT", "logstream/"+stream, nil) for k, v := range header { req.Header.Add(k, v) @@ -89,7 +120,7 @@ func CreateStreamWithCustompartitionError(t *testing.T, client HTTPClient, strea require.Equalf(t, 500, response.StatusCode, "Server returned http code: %s", response.Status) } -func CreateStreamWithSchemaBody(t *testing.T, client HTTPClient, stream string, header map[string]string, schema_payload string) { +func CreateStreamWithSchemaBody(t *testing.T, client httpclient.HTTPClient, stream string, header map[string]string, schema_payload string) { req, _ := client.NewRequest("PUT", "logstream/"+stream, bytes.NewBufferString(schema_payload)) for k, v := range header { @@ -100,7 +131,7 @@ func CreateStreamWithSchemaBody(t *testing.T, client HTTPClient, stream string, require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) } -func DetectSchema(t *testing.T, client HTTPClient, sampleJson string, schemaBody string) { +func DetectSchema(t *testing.T, client httpclient.HTTPClient, sampleJson string, schemaBody string) { req, _ := client.NewRequest("POST", "logstream/schema/detect", bytes.NewBufferString(sampleJson)) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) @@ -109,28 +140,21 @@ func DetectSchema(t *testing.T, client HTTPClient, sampleJson string, schemaBody require.JSONEq(t, schemaBody, body, "Schema detection failed") } -func DeleteStream(t *testing.T, client HTTPClient, stream string) { - req, _ := client.NewRequest("DELETE", "logstream/"+stream, nil) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) -} - -func DeleteAlert(t *testing.T, client HTTPClient, alert_id string) { +func DeleteAlert(t *testing.T, client httpclient.HTTPClient, alert_id string) { req, _ := client.NewRequest("DELETE", "alerts/"+alert_id, nil) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) } -func DeleteTarget(t *testing.T, client HTTPClient, target_id string) { +func DeleteTarget(t *testing.T, client httpclient.HTTPClient, target_id string) { req, _ := client.NewRequest("DELETE", "targets/"+target_id, nil) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) } -func RunFlog(t *testing.T, client HTTPClient, stream string) { +func RunFlog(t *testing.T, client httpclient.HTTPClient, stream string) { cmd := exec.Command("flog", "-f", "json", "-n", "50") var out strings.Builder cmd.Stdout = &out @@ -151,7 +175,7 @@ func RunFlog(t *testing.T, client HTTPClient, stream string) { } } -func IngestOneEventWithTimePartition_TimeStampMismatch(t *testing.T, client HTTPClient, stream string) { +func IngestOneEventWithTimePartition_TimeStampMismatch(t *testing.T, client httpclient.HTTPClient, stream string) { var test_payload string = `{"source_time":"2024-03-26T18:08:00.434Z","level":"info","message":"Application is failing","version":"1.2.0","user_id":13912,"device_id":4138,"session_id":"abc","os":"Windows","host":"112.168.1.110","location":"ngeuprqhynuvpxgp","request_body":"rnkmffyawtdcindtrdqruyxbndbjpfsptzpwtujbmkwcqastmxwbvjwphmyvpnhordwljnodxhtvpjesjldtifswqbpyuhlcytmm","status_code":300,"app_meta":"ckgpibhmlusqqfunnpxbfxbc", "new_field_added_by":"ingestor 8020"}` req, _ := client.NewRequest("POST", "ingest", bytes.NewBufferString(test_payload)) req.Header.Add("X-P-Stream", stream) @@ -160,7 +184,7 @@ func IngestOneEventWithTimePartition_TimeStampMismatch(t *testing.T, client HTTP require.Equalf(t, 400, response.StatusCode, "Server returned http code: %s resp %s", response.Status, readAsString(response.Body)) } -func IngestOneEventWithTimePartition_NoTimePartitionInLog(t *testing.T, client HTTPClient, stream string) { +func IngestOneEventWithTimePartition_NoTimePartitionInLog(t *testing.T, client httpclient.HTTPClient, stream string) { var test_payload string = `{"level":"info","message":"Application is failing","version":"1.2.0","user_id":13912,"device_id":4138,"session_id":"abc","os":"Windows","host":"112.168.1.110","location":"ngeuprqhynuvpxgp","request_body":"rnkmffyawtdcindtrdqruyxbndbjpfsptzpwtujbmkwcqastmxwbvjwphmyvpnhordwljnodxhtvpjesjldtifswqbpyuhlcytmm","status_code":300,"app_meta":"ckgpibhmlusqqfunnpxbfxbc", "new_field_added_by":"ingestor 8020"}` req, _ := client.NewRequest("POST", "ingest", bytes.NewBufferString(test_payload)) req.Header.Add("X-P-Stream", stream) @@ -169,7 +193,7 @@ func IngestOneEventWithTimePartition_NoTimePartitionInLog(t *testing.T, client H require.Equalf(t, 400, response.StatusCode, "Server returned http code: %s resp %s", response.Status, readAsString(response.Body)) } -func IngestOneEventWithTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t *testing.T, client HTTPClient, stream string) { +func IngestOneEventWithTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t *testing.T, client httpclient.HTTPClient, stream string) { var test_payload string = `{"source_time":"2024-03-26", "level":"info","message":"Application is failing","version":"1.2.0","user_id":13912,"device_id":4138,"session_id":"abc","os":"Windows","host":"112.168.1.110","location":"ngeuprqhynuvpxgp","request_body":"rnkmffyawtdcindtrdqruyxbndbjpfsptzpwtujbmkwcqastmxwbvjwphmyvpnhordwljnodxhtvpjesjldtifswqbpyuhlcytmm","status_code":300,"app_meta":"ckgpibhmlusqqfunnpxbfxbc", "new_field_added_by":"ingestor 8020"}` req, _ := client.NewRequest("POST", "ingest", bytes.NewBufferString(test_payload)) req.Header.Add("X-P-Stream", stream) @@ -178,7 +202,7 @@ func IngestOneEventWithTimePartition_IncorrectDateTimeFormatTimePartitionInLog(t require.Equalf(t, 400, response.StatusCode, "Server returned http code: %s resp %s", response.Status, readAsString(response.Body)) } -func IngestOneEventForStaticSchemaStream_NewFieldInLog(t *testing.T, client HTTPClient, stream string) { +func IngestOneEventForStaticSchemaStream_NewFieldInLog(t *testing.T, client httpclient.HTTPClient, stream string) { var test_payload string = `{"source_time":"2024-03-26", "level":"info","message":"Application is failing","version":"1.2.0","user_id":13912,"device_id":4138,"session_id":"abc","os":"Windows","host":"112.168.1.110","location":"ngeuprqhynuvpxgp","request_body":"rnkmffyawtdcindtrdqruyxbndbjpfsptzpwtujbmkwcqastmxwbvjwphmyvpnhordwljnodxhtvpjesjldtifswqbpyuhlcytmm","status_code":300,"app_meta":"ckgpibhmlusqqfunnpxbfxbc", "new_field_added_by":"ingestor 8020"}` req, _ := client.NewRequest("POST", "ingest", bytes.NewBufferString(test_payload)) req.Header.Add("X-P-Stream", stream) @@ -187,7 +211,7 @@ func IngestOneEventForStaticSchemaStream_NewFieldInLog(t *testing.T, client HTTP require.Equalf(t, 400, response.StatusCode, "Server returned http code: %s resp %s", response.Status, readAsString(response.Body)) } -func IngestOneEventForStaticSchemaStream_SameFieldsInLog(t *testing.T, client HTTPClient, stream string) { +func IngestOneEventForStaticSchemaStream_SameFieldsInLog(t *testing.T, client httpclient.HTTPClient, stream string) { var test_payload string = `{"source_time":"2024-03-26", "level":"info","message":"Application is failing","version":"1.2.0","user_id":13912,"device_id":4138,"session_id":"abc","os":"Windows","host":"112.168.1.110","location":"ngeuprqhynuvpxgp","request_body":"rnkmffyawtdcindtrdqruyxbndbjpfsptzpwtujbmkwcqastmxwbvjwphmyvpnhordwljnodxhtvpjesjldtifswqbpyuhlcytmm","status_code":300,"app_meta":"ckgpibhmlusqqfunnpxbfxbc"}` req, _ := client.NewRequest("POST", "ingest", bytes.NewBufferString(test_payload)) req.Header.Add("X-P-Stream", stream) @@ -196,68 +220,57 @@ func IngestOneEventForStaticSchemaStream_SameFieldsInLog(t *testing.T, client HT require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s resp %s", response.Status, readAsString(response.Body)) } -func QueryLogStreamCount(t *testing.T, client HTTPClient, stream string, count uint64) { +func runSQLWithPB(t *testing.T, client pb.PBClient, query, startTime, endTime string, output any) { + t.Helper() + result, err := client.RunJSON( + context.Background(), + output, + "sql", "run", query, + "--from", startTime, + "--to", endTime, + ) + require.NoErrorf(t, err, "pb sql run failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) +} + +type PBCountRow struct { + Count uint64 `json:"count"` +} + +func QueryLogStreamCount(t *testing.T, client pb.PBClient, stream string, count uint64) { // Query last 30 minutes of data only endTime := time.Now().Add(time.Second).Format(time.RFC3339Nano) startTime := time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano) - query := map[string]interface{}{ - "query": "select count(*) as count from " + stream, - "startTime": startTime, - "endTime": endTime, - } - queryJSON, _ := json.Marshal(query) - req, _ := client.NewRequest("POST", "query", bytes.NewBuffer(queryJSON)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) - expected := fmt.Sprintf(`[{"count":%d}]`, count) - require.Equalf(t, expected, body, "Query count incorrect; Expected %s, Actual %s", expected, body) + query := "select count(*) as count from " + stream + var rows []PBCountRow + runSQLWithPB(t, client, query, startTime, endTime, &rows) + require.Equalf(t, []PBCountRow{{Count: count}}, rows, "Query count incorrect; Expected %d, Actual %v", count, rows) } -func QueryLogStreamCount_Historical(t *testing.T, client HTTPClient, stream string, count uint64) { +func QueryLogStreamCount_Historical(t *testing.T, client pb.PBClient, stream string, count uint64) { // Query last 30 minutes of data only now := time.Now() startTime := now.AddDate(0, 0, -33).Format(time.RFC3339Nano) endTime := now.AddDate(0, 0, -27).Format(time.RFC3339Nano) - query := map[string]interface{}{ - "query": "select count(*) as count from " + stream, - "startTime": startTime, - "endTime": endTime, - } - queryJSON, _ := json.Marshal(query) - req, _ := client.NewRequest("POST", "query", bytes.NewBuffer(queryJSON)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) - expected := fmt.Sprintf(`[{"count":%d}]`, count) - require.Equalf(t, expected, body, "Query count incorrect; Expected %s, Actual %s", expected, body) + query := "select count(*) as count from " + stream + var rows []PBCountRow + runSQLWithPB(t, client, query, startTime, endTime, &rows) + require.Equalf(t, []PBCountRow{{Count: count}}, rows, "Query count incorrect; Expected %d, Actual %v", count, rows) } -func QueryTwoLogStreamCount(t *testing.T, client HTTPClient, stream1 string, stream2 string, count uint64) { +func QueryTwoLogStreamCount(t *testing.T, client pb.PBClient, stream1 string, stream2 string, count uint64) { // Query last 30 minutes of data only endTime := time.Now().Add(time.Second).Format(time.RFC3339Nano) startTime := time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano) - query := map[string]interface{}{ - "query": fmt.Sprintf("select sum(c) as count from (select count(*) as c from %s union all select count(*) as c from %s)", stream1, stream2), - "startTime": startTime, - "endTime": endTime, - } - queryJSON, _ := json.Marshal(query) - req, _ := client.NewRequest("POST", "query", bytes.NewBuffer(queryJSON)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) - expected := fmt.Sprintf(`[{"count":%d}]`, count) - require.Equalf(t, expected, body, "Query count incorrect; Expected %s, Actual %s", expected, body) + query := fmt.Sprintf("select sum(c) as count from (select count(*) as c from %s union all select count(*) as c from %s)", stream1, stream2) + var rows []PBCountRow + runSQLWithPB(t, client, query, startTime, endTime, &rows) + require.Equalf(t, []PBCountRow{{Count: count}}, rows, "Query count incorrect; Expected %d, Actual %v", count, rows) } -func AssertQueryOK(t *testing.T, client HTTPClient, query string, args ...any) { +func AssertQueryOK(t *testing.T, client pb.PBClient, query string, args ...any) { // Query last 30 minutes of data only endTime := time.Now().Add(time.Second).Format(time.RFC3339Nano) startTime := time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano) @@ -269,20 +282,11 @@ func AssertQueryOK(t *testing.T, client HTTPClient, query string, args ...any) { finalQuery = fmt.Sprintf(query, args...) } - queryJSON, _ := json.Marshal(map[string]interface{}{ - "query": finalQuery, - "startTime": startTime, - "endTime": endTime, - }) - - req, _ := client.NewRequest("POST", "query", bytes.NewBuffer(queryJSON)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) + var rows []json.RawMessage + runSQLWithPB(t, client, finalQuery, startTime, endTime, &rows) } -func AssertStreamSchema(t *testing.T, client HTTPClient, stream string, schema string) { +func AssertStreamSchema(t *testing.T, client httpclient.HTTPClient, stream string, schema string) { req, _ := client.NewRequest("GET", "logstream/"+stream+"/schema", nil) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) @@ -291,14 +295,14 @@ func AssertStreamSchema(t *testing.T, client HTTPClient, stream string, schema s require.JSONEq(t, schema, body, "Get schema response doesn't match with expected schema") } -func CreateRole(t *testing.T, client HTTPClient, name string, role string) { +func CreateRole(t *testing.T, client httpclient.HTTPClient, name string, role string) { req, _ := client.NewRequest("PUT", "role/"+name, strings.NewReader(role)) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) } -func AssertRole(t *testing.T, client HTTPClient, name string, role string) { +func AssertRole(t *testing.T, client httpclient.HTTPClient, name string, role string) { req, _ := client.NewRequest("GET", "role/"+name, nil) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) @@ -307,34 +311,16 @@ func AssertRole(t *testing.T, client HTTPClient, name string, role string) { require.JSONEq(t, role, body, "Get role response doesn't match with retention config returned") } -func CreateUser(t *testing.T, client HTTPClient, user string) string { - req, _ := client.NewRequest("POST", "user/"+user, nil) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s resp %s", response.Status, readAsString(response.Body)) - return body +func CreateUserWithRole(t *testing.T, client pb.PBClient, user string, roles []string) string { + t.Helper() + result, err := client.Run(context.Background(), "user", "add", user, "--role", strings.Join(roles, ",")) + require.NoErrorf(t, err, "pb user add failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) + password, err := pb.PasswordFromUserAddOutput(result.Stdout) + require.NoErrorf(t, err, "pb user add returned no password (stdout=%q, stderr=%q)", result.Stdout, result.Stderr) + return password } -func CreateUserWithRole(t *testing.T, client HTTPClient, user string, roles []string) string { - payload, _ := json.Marshal(roles) - req, _ := client.NewRequest("POST", "user/"+user, bytes.NewBuffer(payload)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) - return body -} - -func AssignRolesToUser(t *testing.T, client HTTPClient, user string, roles []string) { - payload, _ := json.Marshal(roles) - req, _ := client.NewRequest("PUT", "user/"+user+"/role", bytes.NewBuffer(payload)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) -} - -func AssertUserRole(t *testing.T, client HTTPClient, user string, roleName, roleBody string) { +func AssertUserRole(t *testing.T, client httpclient.HTTPClient, user string, roleName, roleBody string) { req, _ := client.NewRequest("GET", "user/"+user+"/role", nil) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) @@ -344,7 +330,7 @@ func AssertUserRole(t *testing.T, client HTTPClient, user string, roleName, role require.JSONEq(t, userRoleBody, expectedRoleBody, "Get user role response doesn't match with expected role") } -func RegenPassword(t *testing.T, client HTTPClient, user string) string { +func RegenPassword(t *testing.T, client httpclient.HTTPClient, user string) string { req, _ := client.NewRequest("POST", "user/"+user+"/generate-new-password", nil) response, err := client.Do(req) require.NoErrorf(t, err, "Request failed: %s", err) @@ -353,68 +339,19 @@ func RegenPassword(t *testing.T, client HTTPClient, user string) string { return body } -func SetUserRole(t *testing.T, client HTTPClient, user string, roles []string) { - payload, _ := json.Marshal(roles) - req, _ := client.NewRequest("PUT", "user/"+user+"/role", bytes.NewBuffer(payload)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) -} - -func DeleteUser(t *testing.T, client HTTPClient, user string) { - req, _ := client.NewRequest("DELETE", "user/"+user, nil) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) +func DeleteUser(t *testing.T, client pb.PBClient, user string) { + t.Helper() + result, err := client.Run(context.Background(), "user", "remove", user) + require.NoErrorf(t, err, "pb user remove failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) } -func DeleteRole(t *testing.T, client HTTPClient, roleName string) { - req, _ := client.NewRequest("DELETE", "role/"+roleName, nil) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) -} - -func SetDefaultRole(t *testing.T, client HTTPClient, roleName string) { - payload, _ := json.Marshal(roleName) - req, _ := client.NewRequest("PUT", "role/default", bytes.NewBuffer(payload)) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) -} - -func AssertDefaultRole(t *testing.T, client HTTPClient, roleName string) { - req, _ := client.NewRequest("GET", "role/default", nil) - response, err := client.Do(req) - require.NoErrorf(t, err, "Request failed: %s", err) - body := readAsString(response.Body) - require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, body) - require.Equalf(t, roleName, body, "Get default role response doesn't match with expected role") -} - -func PutSingleEventExpectErr(t *testing.T, client HTTPClient, stream string) { - payload := `{ - "id": "id;objectId", - "maxRunDistance": "float;1;20;1", - "cpf": "cpf", - "cnpj": "cnpj", - "pretendSalary": "money", - "age": "int;20;80", - "gender": "gender", - "firstName": "firstName", - "lastName": "lastName", - "phone": "maskInt;+55 (83) 9####-####", - "address": "address", - "hairColor": "color" - }` - req, _ := client.NewRequest("POST", "logstream/"+stream, bytes.NewBufferString(payload)) - response, err := client.Do(req) - - require.NoErrorf(t, err, "Request failed when expected to pass: %s", err) - require.Equalf(t, 403, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) +func DeleteRole(t *testing.T, client pb.PBClient, roleName string) { + t.Helper() + result, err := client.Run(context.Background(), "role", "remove", roleName) + require.NoErrorf(t, err, "pb role remove failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) } -func PutSingleEvent(t *testing.T, client HTTPClient, stream string) { +func PutSingleEvent(t *testing.T, client httpclient.HTTPClient, stream string) { payload := `{ "id": "id;objectId", "maxRunDistance": "float;1;20;1", @@ -436,7 +373,7 @@ func PutSingleEvent(t *testing.T, client HTTPClient, stream string) { require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, readAsString(response.Body)) } -func checkAPIAccess(t *testing.T, queryClient HTTPClient, ingestClient HTTPClient, stream string, role string) { +func checkAPIAccess(t *testing.T, queryClient httpclient.HTTPClient, ingestClient httpclient.HTTPClient, stream string, role string) { switch role { case "editor": // Check access to non-protected API diff --git a/tests/integration/trace_test.go b/tests/integration/trace_test.go new file mode 100644 index 0000000..3a0bba7 --- /dev/null +++ b/tests/integration/trace_test.go @@ -0,0 +1,241 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const traceVisibilityTimeout = 5 * time.Minute + +type testTraceListRecord struct { + ServiceName string `json:"service.name"` + SpanName string `json:"span_name"` + TraceID string `json:"span_trace_id"` + SpanID string `json:"span_span_id"` +} + +type testTraceListResponse struct { + Count uint64 `json:"count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Records []testTraceListRecord `json:"records"` +} + +type testTraceDetailRecord struct { + ServiceName string `json:"service.name"` + SpanName string `json:"span_name"` + TraceID string `json:"span_trace_id"` + SpanID string `json:"span_span_id"` + ParentSpanID string `json:"span_parent_span_id"` + Level int `json:"level"` +} + +type testTraceDetailResponse struct { + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Records []testTraceDetailRecord `json:"records"` +} + +func createTraceDataset(t *testing.T, dataset string) { + t.Helper() + result, err := NewGlob.PBClient.Run(context.Background(), "dataset", "add", dataset, "--type", "traces") + require.NoErrorf(t, err, "pb traces dataset add failed (exit=%d, stdout=%q, stderr=%q)", result.ExitCode, result.Stdout, result.Stderr) + t.Cleanup(func() { + DeleteStream(t, NewGlob.PBClient, dataset) + }) +} + +func ingestTestTraces(t *testing.T, dataset, traceID string) { + t.Helper() + client := NewGlob.QueryClient + if NewGlob.IngestorUrl.String() != "" { + client = NewGlob.IngestorClient + } + + rootSpanID := "0123456789abcdef" + childSpanID := "fedcba9876543210" + start := time.Now().UTC().Add(-2 * time.Second) + rootEnd := start.Add(1500 * time.Millisecond) + childStart := start.Add(250 * time.Millisecond) + childEnd := start.Add(time.Second) + payload := map[string]any{ + "resourceSpans": []any{ + map[string]any{ + "resource": map[string]any{ + "attributes": []any{ + map[string]any{ + "key": "service.name", + "value": map[string]string{"stringValue": "quest-trace-service"}, + }, + }, + }, + "scopeSpans": []any{ + map[string]any{ + "scope": map[string]string{"name": "quest"}, + "spans": []any{ + map[string]any{ + "traceId": traceID, + "spanId": rootSpanID, + "parentSpanId": "", + "name": "quest-root-span", + "kind": 2, + "startTimeUnixNano": fmt.Sprint(start.UnixNano()), + "endTimeUnixNano": fmt.Sprint(rootEnd.UnixNano()), + "status": map[string]int{"code": 1}, + }, + map[string]any{ + "traceId": traceID, + "spanId": childSpanID, + "parentSpanId": rootSpanID, + "name": "quest-child-span", + "kind": 3, + "startTimeUnixNano": fmt.Sprint(childStart.UnixNano()), + "endTimeUnixNano": fmt.Sprint(childEnd.UnixNano()), + "status": map[string]int{"code": 1}, + }, + }, + }, + }, + }, + }, + } + encoded, err := json.Marshal(payload) + require.NoError(t, err) + req, err := client.NewRequestAtPath("POST", "/v1/traces", bytes.NewReader(encoded)) + require.NoError(t, err) + req.Header.Set("X-P-Stream", dataset) + req.Header.Set("X-P-Log-Source", "otel-traces") + response, err := client.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "OTLP/JSON trace ingestion failed: %s", readAsString(response.Body)) +} + +func traceAPIRequest(path string, payload any) (int, []byte, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return 0, nil, err + } + req, err := NewGlob.QueryClient.NewRequestAtPath("POST", path, bytes.NewReader(encoded)) + if err != nil { + return 0, nil, err + } + response, err := NewGlob.QueryClient.Do(req) + if err != nil { + return 0, nil, err + } + defer response.Body.Close() + body := []byte(readAsString(response.Body)) + return response.StatusCode, body, nil +} + +func waitForTestTrace(t *testing.T, payload map[string]any) testTraceListResponse { + t.Helper() + deadline := time.Now().Add(traceVisibilityTimeout) + var lastStatus int + var lastBody []byte + var lastErr error + for time.Now().Before(deadline) { + lastStatus, lastBody, lastErr = traceAPIRequest("api/prism/v1/services/traces/list", payload) + if lastErr == nil && lastStatus == 200 { + var result testTraceListResponse + if err := json.Unmarshal(lastBody, &result); err == nil && len(result.Records) > 0 { + return result + } + } + time.Sleep(5 * time.Second) + } + require.NoErrorf(t, lastErr, "trace did not become queryable within %s", traceVisibilityTimeout) + require.Failf(t, "trace did not become queryable", "last status=%d body=%s", lastStatus, lastBody) + return testTraceListResponse{} +} + +func TestSmokeTraceAPIs(t *testing.T) { + // Verifies real OTLP trace ingestion, trace listing, detail, and field queries. + t.Parallel() + dataset := NewGlob.Stream + "traces" + traceID := fmt.Sprintf("%032x", time.Now().UnixNano()) + startTime := time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano) + createTraceDataset(t, dataset) + ingestTestTraces(t, dataset, traceID) + endTime := time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339Nano) + + listPayload := map[string]any{ + "dataset": dataset, + "startTime": startTime, + "endTime": endTime, + "sortBy": "mostRecent", + "options": "spans", + "limit": 10, + "offset": 0, + } + listed := waitForTestTrace(t, listPayload) + require.GreaterOrEqual(t, listed.Count, uint64(1)) + require.Equal(t, 0, listed.Offset) + require.Equal(t, 10, listed.Limit) + trace := listed.Records[0] + require.Equal(t, traceID, trace.TraceID) + require.NotEmpty(t, trace.ServiceName) + require.NotEmpty(t, trace.SpanName) + require.NotEmpty(t, trace.TraceID) + require.NotEmpty(t, trace.SpanID) + + t.Run("GetTraceDetail", func(t *testing.T) { + status, body, err := traceAPIRequest("api/prism/v1/services/traces/detail", map[string]any{ + "dataset": dataset, + "traceId": trace.TraceID, + "startTime": startTime, + "endTime": endTime, + }) + require.NoError(t, err) + require.Equalf(t, 200, status, "Server returned body: %s", body) + var detail testTraceDetailResponse + require.NoError(t, json.Unmarshal(body, &detail)) + require.NotEmpty(t, detail.StartTime) + require.NotEmpty(t, detail.EndTime) + require.NotEmpty(t, detail.Records) + for _, span := range detail.Records { + require.Equal(t, trace.TraceID, span.TraceID) + require.NotEmpty(t, span.SpanID) + require.NotEmpty(t, span.SpanName) + } + }) + + t.Run("QueryTraceFields", func(t *testing.T) { + queryPayload := map[string]string{ + "query": fmt.Sprintf(`SELECT span_trace_id, span_name FROM "%s" WHERE span_trace_id = '%s' LIMIT 1`, dataset, trace.TraceID), + "startTime": startTime, + "endTime": endTime, + } + status, body, err := traceAPIRequest("api/v1/query?fields=true", queryPayload) + require.NoError(t, err) + require.Equalf(t, 200, status, "Server returned body: %s", body) + var result queryWithFieldsResponse + require.NoError(t, json.Unmarshal(body, &result)) + require.ElementsMatch(t, []string{"span_name", "span_trace_id"}, result.Fields) + require.NotEmpty(t, result.Records) + require.Contains(t, string(result.Records[0]), trace.TraceID) + }) +} diff --git a/tests/integration/usergroup_test.go b/tests/integration/usergroup_test.go new file mode 100644 index 0000000..4522bd4 --- /dev/null +++ b/tests/integration/usergroup_test.go @@ -0,0 +1,295 @@ +// Copyright (c) 2023 Cloudnatively Services Pvt Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +type testUserGroupUser struct { + UserID string `json:"userid"` + Username string `json:"username"` + Method string `json:"method"` +} + +type testUserGroupResponse struct { + Name string `json:"name"` + Roles []string `json:"roles"` + Users []testUserGroupUser `json:"users"` +} + +type testUserGroupUserIdentifier struct { + UserID string `json:"userid"` + Method string `json:"method"` +} + +type testUserGroupMembers struct { + Roles []string `json:"roles"` + Users []testUserGroupUserIdentifier `json:"users"` +} + +type testUserGroupRoleResponse struct { + Roles map[string]json.RawMessage `json:"roles"` + GroupRoles map[string]map[string]json.RawMessage `json:"group_roles"` +} + +func userGroupRequest(t *testing.T, method, path string, payload any, expectedStatus int) *testUserGroupResponse { + t.Helper() + var body *bytes.Reader + if payload == nil { + body = bytes.NewReader(nil) + } else { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + body = bytes.NewReader(encoded) + } + + req, err := NewGlob.QueryClient.NewRequestAtPath(method, path, body) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + responseBody := readAsString(response.Body) + require.Equalf(t, expectedStatus, response.StatusCode, "Server returned http code: %s and response: %s", response.Status, responseBody) + if expectedStatus != 200 { + return nil + } + + var group testUserGroupResponse + err = json.Unmarshal([]byte(responseBody), &group) + require.NoError(t, err) + return &group +} + +func listTestUserGroups(t *testing.T) []testUserGroupResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequestAtPath("GET", "api/prism/v1/usergroup", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + groups, err := readJsonBody[[]testUserGroupResponse](response.Body) + require.NoError(t, err) + return groups +} + +func requireTestUserGroupListed(t *testing.T, groups []testUserGroupResponse, name string) testUserGroupResponse { + t.Helper() + for _, group := range groups { + if group.Name == name { + return group + } + } + t.Fatalf("user group %q was not returned", name) + return testUserGroupResponse{} +} + +func requireTestUserGroupMembers(t *testing.T, group testUserGroupResponse, role string, users ...string) { + t.Helper() + require.ElementsMatch(t, []string{role}, group.Roles) + require.Len(t, group.Users, len(users)) + groupUsers := make(map[string]testUserGroupUser, len(group.Users)) + for _, user := range group.Users { + groupUsers[user.UserID] = user + } + for _, expectedUser := range users { + user, exists := groupUsers[expectedUser] + require.Truef(t, exists, "user %q was not returned in group %q", expectedUser, group.Name) + require.Equal(t, expectedUser, user.Username) + require.Equal(t, "native", user.Method) + } +} + +func getTestUserGroupRoles(t *testing.T, user string) testUserGroupRoleResponse { + t.Helper() + req, err := NewGlob.QueryClient.NewRequest("GET", "user/"+url.PathEscape(user)+"/role", nil) + require.NoError(t, err) + response, err := NewGlob.QueryClient.Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equalf(t, 200, response.StatusCode, "Server returned http code: %s", response.Status) + result, err := readJsonBody[testUserGroupRoleResponse](response.Body) + require.NoError(t, err) + return result +} + +func cleanupTestUserGroup(group string, members testUserGroupMembers) { + encoded, err := json.Marshal(members) + if err == nil { + req, requestErr := NewGlob.QueryClient.NewRequestAtPath( + "PATCH", + "api/prism/v1/usergroup/"+url.PathEscape(group)+"/remove", + bytes.NewReader(encoded), + ) + if requestErr == nil { + if response, responseErr := NewGlob.QueryClient.Do(req); responseErr == nil { + response.Body.Close() + } + } + } + + req, err := NewGlob.QueryClient.NewRequestAtPath( + "DELETE", + "api/prism/v1/usergroup/"+url.PathEscape(group), + nil, + ) + if err == nil { + if response, responseErr := NewGlob.QueryClient.Do(req); responseErr == nil { + response.Body.Close() + } + } +} + +func TestEnterpriseUserGroupLifecycle(t *testing.T) { + // Verifies Enterprise user-group create, list, get, membership, and delete APIs. + if NewGlob.Edition != "enterprise" { + t.Skip("user groups are only available in Enterprise") + } + t.Parallel() + rbacMu.Lock() + t.Cleanup(rbacMu.Unlock) + + groupName := NewGlob.Stream + "usergroup" + roleName := NewGlob.Stream + "usergrouprole" + baseRoleName := NewGlob.Stream + "usergroupbaserole" + usernameOne := NewGlob.Stream + "usergroupuserone" + usernameTwo := NewGlob.Stream + "usergroupusertwo" + memberPayload := testUserGroupMembers{ + Roles: []string{roleName}, + Users: []testUserGroupUserIdentifier{ + {UserID: usernameOne, Method: "native"}, + {UserID: usernameTwo, Method: "native"}, + }, + } + + CreateRole(t, NewGlob.QueryClient, roleName, dummyRole) + t.Cleanup(func() { + DeleteRole(t, NewGlob.PBClient, roleName) + }) + CreateRole(t, NewGlob.QueryClient, baseRoleName, dummyRole) + t.Cleanup(func() { + DeleteRole(t, NewGlob.PBClient, baseRoleName) + }) + CreateUserWithRole(t, NewGlob.PBClient, usernameOne, []string{baseRoleName}) + t.Cleanup(func() { + DeleteUser(t, NewGlob.PBClient, usernameOne) + }) + CreateUserWithRole(t, NewGlob.PBClient, usernameTwo, []string{baseRoleName}) + t.Cleanup(func() { + DeleteUser(t, NewGlob.PBClient, usernameTwo) + }) + + created := userGroupRequest(t, "POST", "api/prism/v1/usergroup", map[string]any{ + "name": groupName, + "roles": []string{}, + "users": []testUserGroupUserIdentifier{}, + }, 200) + require.Equal(t, groupName, created.Name) + require.Empty(t, created.Roles) + require.Empty(t, created.Users) + + groupDeleted := false + t.Cleanup(func() { + if !groupDeleted { + cleanupTestUserGroup(groupName, memberPayload) + } + }) + + t.Run("ListGroup", func(t *testing.T) { + listed := requireTestUserGroupListed(t, listTestUserGroups(t), groupName) + require.Empty(t, listed.Roles) + require.Empty(t, listed.Users) + }) + + t.Run("GetGroup", func(t *testing.T) { + group := userGroupRequest(t, "GET", "api/prism/v1/usergroup/"+url.PathEscape(groupName), nil, 200) + require.Equal(t, groupName, group.Name) + require.Empty(t, group.Roles) + require.Empty(t, group.Users) + }) + + t.Run("AddUserAndRole", func(t *testing.T) { + group := userGroupRequest( + t, + "PATCH", + "api/prism/v1/usergroup/"+url.PathEscape(groupName)+"/add", + memberPayload, + 200, + ) + require.Equal(t, groupName, group.Name) + requireTestUserGroupMembers(t, *group, roleName, usernameOne, usernameTwo) + + persisted := userGroupRequest(t, "GET", "api/prism/v1/usergroup/"+url.PathEscape(groupName), nil, 200) + requireTestUserGroupMembers(t, *persisted, roleName, usernameOne, usernameTwo) + + for _, username := range []string{usernameOne, usernameTwo} { + inheritedRoles := getTestUserGroupRoles(t, username) + require.Contains(t, inheritedRoles.Roles, baseRoleName) + require.NotContains(t, inheritedRoles.Roles, roleName) + require.Contains(t, inheritedRoles.GroupRoles, groupName) + require.Contains(t, inheritedRoles.GroupRoles[groupName], roleName) + } + }) + + t.Run("RejectNonEmptyGroupDeletion", func(t *testing.T) { + userGroupRequest(t, "DELETE", "api/prism/v1/usergroup/"+url.PathEscape(groupName), nil, 400) + persisted := userGroupRequest(t, "GET", "api/prism/v1/usergroup/"+url.PathEscape(groupName), nil, 200) + requireTestUserGroupMembers(t, *persisted, roleName, usernameOne, usernameTwo) + }) + + t.Run("RemoveUserAndRole", func(t *testing.T) { + group := userGroupRequest( + t, + "PATCH", + "api/prism/v1/usergroup/"+url.PathEscape(groupName)+"/remove", + memberPayload, + 200, + ) + require.Equal(t, groupName, group.Name) + require.Empty(t, group.Roles) + require.Empty(t, group.Users) + + persisted := userGroupRequest(t, "GET", "api/prism/v1/usergroup/"+url.PathEscape(groupName), nil, 200) + require.Empty(t, persisted.Roles) + require.Empty(t, persisted.Users) + + for _, username := range []string{usernameOne, usernameTwo} { + inheritedRoles := getTestUserGroupRoles(t, username) + require.Contains(t, inheritedRoles.Roles, baseRoleName) + require.NotContains(t, inheritedRoles.Roles, roleName) + require.NotContains(t, inheritedRoles.GroupRoles, groupName) + } + }) + + t.Run("DeleteGroup", func(t *testing.T) { + deleted := userGroupRequest(t, "DELETE", "api/prism/v1/usergroup/"+url.PathEscape(groupName), nil, 200) + groupDeleted = true + require.Equal(t, groupName, deleted.Name) + require.Empty(t, deleted.Roles) + require.Empty(t, deleted.Users) + + userGroupRequest(t, "GET", "api/prism/v1/usergroup/"+url.PathEscape(groupName), nil, 400) + for _, group := range listTestUserGroups(t) { + require.NotEqual(t, groupName, group.Name) + } + }) +}