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)
+ }
+ })
+}