Files
akvorado/console/graph_test.go
2022-05-16 10:19:53 +02:00

592 lines
15 KiB
Go

package console
import (
"bytes"
"encoding/json"
"fmt"
netHTTP "net/http"
"sort"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang/mock/gomock"
"akvorado/common/clickhousedb"
"akvorado/common/daemon"
"akvorado/common/helpers"
"akvorado/common/http"
"akvorado/common/reporter"
)
func TestGraphFilterGroupSQLWhere(t *testing.T) {
cases := []struct {
Description string
Input graphFilterGroup
Expected string
}{
{
Description: "empty group",
Expected: "",
}, {
Description: "all group",
Input: graphFilterGroup{
Operator: graphFilterGroupOperatorAll,
Rules: []graphFilterRule{
{
Column: graphColumnDstCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "FR",
}, {
Column: graphColumnSrcCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "US",
},
},
},
Expected: `(DstCountry = 'FR') AND (SrcCountry = 'US')`,
}, {
Description: "any group",
Input: graphFilterGroup{
Operator: graphFilterGroupOperatorAny,
Rules: []graphFilterRule{
{
Column: graphColumnDstCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "FR",
}, {
Column: graphColumnSrcCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "US",
},
},
},
Expected: `(DstCountry = 'FR') OR (SrcCountry = 'US')`,
}, {
Description: "nested group",
Input: graphFilterGroup{
Operator: graphFilterGroupOperatorAll,
Rules: []graphFilterRule{
{
Column: graphColumnDstCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "FR",
},
},
Children: []graphFilterGroup{
{
Operator: graphFilterGroupOperatorAny,
Rules: []graphFilterRule{
{
Column: graphColumnSrcCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "US",
}, {
Column: graphColumnSrcCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "IE",
},
},
},
},
},
Expected: `((SrcCountry = 'US') OR (SrcCountry = 'IE')) AND (DstCountry = 'FR')`,
},
}
for _, tc := range cases {
t.Run(tc.Description, func(t *testing.T) {
got, _ := tc.Input.toSQLWhere()
if diff := helpers.Diff(got, tc.Expected); diff != "" {
t.Errorf("toSQLWhere (-got, +want):\n%s", diff)
}
})
}
}
func TestGraphFilterRuleSQLWhere(t *testing.T) {
cases := []struct {
Description string
Input graphFilterRule
Expected string
}{
{
Description: "source IP (v4)",
Input: graphFilterRule{
Column: graphColumnSrcAddr,
Operator: graphFilterRuleOperatorEqual,
Value: "192.0.2.11",
},
Expected: `SrcAddr = IPv6StringToNum('192.0.2.11')`,
}, {
Description: "source IP (v6)",
Input: graphFilterRule{
Column: graphColumnSrcAddr,
Operator: graphFilterRuleOperatorEqual,
Value: "2001:db8::1",
},
Expected: `SrcAddr = IPv6StringToNum('2001:db8::1')`,
}, {
Description: "source IP (bad)",
Input: graphFilterRule{
Column: graphColumnSrcAddr,
Operator: graphFilterRuleOperatorEqual,
Value: "alfred",
},
Expected: "",
}, {
Description: "boundary",
Input: graphFilterRule{
Column: graphColumnInIfBoundary,
Operator: graphFilterRuleOperatorNotEqual,
Value: "external",
},
Expected: `InIfBoundary != 'external'`,
}, {
Description: "boundary (bad)",
Input: graphFilterRule{
Column: graphColumnInIfBoundary,
Operator: graphFilterRuleOperatorNotEqual,
Value: "eternal",
},
Expected: "",
}, {
Description: "speed",
Input: graphFilterRule{
Column: graphColumnInIfSpeed,
Operator: graphFilterRuleOperatorLessThan,
Value: "1000",
},
Expected: `InIfSpeed < 1000`,
}, {
Description: "speed (bad)",
Input: graphFilterRule{
Column: graphColumnInIfSpeed,
Operator: graphFilterRuleOperatorLessThan,
Value: "-1000",
},
Expected: "",
}, {
Description: "source port",
Input: graphFilterRule{
Column: graphColumnSrcPort,
Operator: graphFilterRuleOperatorLessThan,
Value: "1000",
},
Expected: `SrcPort < 1000`,
}, {
Description: "source port (bad)",
Input: graphFilterRule{
Column: graphColumnSrcPort,
Operator: graphFilterRuleOperatorLessThan,
Value: "10000000",
},
Expected: "",
}, {
Description: "source AS",
Input: graphFilterRule{
Column: graphColumnSrcAS,
Operator: graphFilterRuleOperatorEqual,
Value: "2906",
},
Expected: "SrcAS = 2906",
}, {
Description: "source AS (prefixed)",
Input: graphFilterRule{
Column: graphColumnSrcAS,
Operator: graphFilterRuleOperatorEqual,
Value: "AS2906",
},
Expected: "SrcAS = 2906",
}, {
Description: "source AS (bad)",
Input: graphFilterRule{
Column: graphColumnSrcAS,
Operator: graphFilterRuleOperatorEqual,
Value: "ASMN2906",
},
Expected: "",
}, {
Description: "EType",
Input: graphFilterRule{
Column: graphColumnEType,
Operator: graphFilterRuleOperatorEqual,
Value: "IPv6",
},
Expected: "EType = 34525",
}, {
Description: "EType (bad)",
Input: graphFilterRule{
Column: graphColumnEType,
Operator: graphFilterRuleOperatorEqual,
Value: "IPv4+",
},
Expected: "",
}, {
Description: "Proto (string)",
Input: graphFilterRule{
Column: graphColumnProto,
Operator: graphFilterRuleOperatorEqual,
Value: "TCP",
},
Expected: `dictGetOrDefault('protocols', 'name', Proto, '???') = 'TCP'`,
}, {
Description: "Proto (int)",
Input: graphFilterRule{
Column: graphColumnProto,
Operator: graphFilterRuleOperatorEqual,
Value: "47",
},
Expected: `Proto = 47`,
},
}
for _, tc := range cases {
t.Run(tc.Description, func(t *testing.T) {
got, _ := tc.Input.toSQLWhere()
if diff := helpers.Diff(got, tc.Expected); diff != "" {
t.Errorf("toSQLWhere (-got, +want):\n%s", diff)
}
})
}
}
func TestGraphColumnSQLSelect(t *testing.T) {
cases := []struct {
Input graphColumn
Expected string
}{
{
Input: graphColumnSrcAddr,
Expected: `IPv6NumToString(SrcAddr)`,
}, {
Input: graphColumnDstAS,
Expected: `concat(toString(DstAS), ': ', dictGetOrDefault('asns', 'name', DstAS, '???'))`,
}, {
Input: graphColumnProto,
Expected: `dictGetOrDefault('protocols', 'name', Proto, '???')`,
}, {
Input: graphColumnEType,
Expected: `if(EType = 0x800, 'IPv4', if(EType = 0x86dd, 'IPv6', '???'))`,
}, {
Input: graphColumnOutIfSpeed,
Expected: `toString(OutIfSpeed)`,
}, {
Input: graphColumnExporterName,
Expected: `ExporterName`,
},
}
for _, tc := range cases {
t.Run(tc.Input.String(), func(t *testing.T) {
got := tc.Input.toSQLSelect()
if diff := helpers.Diff(got, tc.Expected); diff != "" {
t.Errorf("toSQLWhere (-got, +want):\n%s", diff)
}
})
}
}
func TestGraphQuerySQL(t *testing.T) {
cases := []struct {
Description string
Input graphQuery
Expected string
}{
{
Description: "no dimensions, no filters",
Input: graphQuery{
Start: time.Date(2022, 04, 10, 15, 45, 10, 0, time.UTC),
End: time.Date(2022, 04, 11, 15, 45, 10, 0, time.UTC),
Points: 100,
Dimensions: []graphColumn{},
Filter: graphFilterGroup{},
},
Expected: `
WITH
intDiv(864, {resolution})*{resolution} AS slot
SELECT
toStartOfInterval(TimeReceived, INTERVAL slot second) AS time,
SUM(Bytes*SamplingRate*8/slot) AS bps,
emptyArrayString() AS dimensions
FROM {table}
WHERE {timefilter}
GROUP BY time, dimensions
ORDER BY time`,
}, {
Description: "no dimensions",
Input: graphQuery{
Start: time.Date(2022, 04, 10, 15, 45, 10, 0, time.UTC),
End: time.Date(2022, 04, 11, 15, 45, 10, 0, time.UTC),
Points: 100,
Dimensions: []graphColumn{},
Filter: graphFilterGroup{
Operator: graphFilterGroupOperatorAll,
Rules: []graphFilterRule{
{
Column: graphColumnDstCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "FR",
}, {
Column: graphColumnSrcCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "US",
},
},
},
},
Expected: `
WITH
intDiv(864, {resolution})*{resolution} AS slot
SELECT
toStartOfInterval(TimeReceived, INTERVAL slot second) AS time,
SUM(Bytes*SamplingRate*8/slot) AS bps,
emptyArrayString() AS dimensions
FROM {table}
WHERE {timefilter} AND ((DstCountry = 'FR') AND (SrcCountry = 'US'))
GROUP BY time, dimensions
ORDER BY time`,
}, {
Description: "no filters",
Input: graphQuery{
Start: time.Date(2022, 04, 10, 15, 45, 10, 0, time.UTC),
End: time.Date(2022, 04, 11, 15, 45, 10, 0, time.UTC),
Points: 100,
Limit: 20,
Dimensions: []graphColumn{
graphColumnExporterName,
graphColumnInIfProvider,
},
Filter: graphFilterGroup{},
},
Expected: `
WITH
intDiv(864, {resolution})*{resolution} AS slot,
rows AS (SELECT ExporterName, InIfProvider FROM {table} WHERE {timefilter} GROUP BY ExporterName, InIfProvider ORDER BY SUM(Bytes) DESC LIMIT 20)
SELECT
toStartOfInterval(TimeReceived, INTERVAL slot second) AS time,
SUM(Bytes*SamplingRate*8/slot) AS bps,
if((ExporterName, InIfProvider) IN rows, [ExporterName, InIfProvider], ['Other', 'Other']) AS dimensions
FROM {table}
WHERE {timefilter}
GROUP BY time, dimensions
ORDER BY time`,
},
}
for _, tc := range cases {
t.Run(tc.Description, func(t *testing.T) {
got, _ := tc.Input.toSQL()
if diff := helpers.Diff(strings.Split(got, "\n"), strings.Split(tc.Expected, "\n")); diff != "" {
t.Errorf("toSQL (-got, +want):\n%s", diff)
}
})
}
}
func TestGraphHandler(t *testing.T) {
r := reporter.NewMock(t)
ch, mockConn := clickhousedb.NewMock(t, r)
h := http.NewMock(t, r)
c, err := New(r, Configuration{}, Dependencies{
Daemon: daemon.NewMock(t),
HTTP: h,
ClickHouseDB: ch,
})
if err != nil {
t.Fatalf("New() error:\n%+v", err)
}
helpers.StartStop(t, c)
base := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
expectedSQL := []struct {
Time time.Time `ch:"time"`
Bps float64 `ch:"bps"`
Dimensions []string `ch:"dimensions"`
}{
{base, 1000, []string{"router1", "provider1"}},
{base, 2000, []string{"router1", "provider2"}},
{base, 1200, []string{"router2", "provider2"}},
{base, 1100, []string{"router2", "provider3"}},
{base, 1900, []string{"Other", "Other"}},
{base.Add(time.Minute), 500, []string{"router1", "provider1"}},
{base.Add(time.Minute), 5000, []string{"router1", "provider2"}},
{base.Add(time.Minute), 900, []string{"router2", "provider4"}},
{base.Add(time.Minute), 100, []string{"Other", "Other"}},
{base.Add(2 * time.Minute), 100, []string{"router1", "provider1"}},
{base.Add(2 * time.Minute), 3000, []string{"router1", "provider2"}},
{base.Add(2 * time.Minute), 100, []string{"router2", "provider4"}},
{base.Add(2 * time.Minute), 100, []string{"Other", "Other"}},
}
expected := gin.H{
// Sorted by sum of bps
"rows": [][]string{
{"router1", "provider2"}, // 10000
{"router1", "provider1"}, // 1600
{"router2", "provider2"}, // 1200
{"router2", "provider3"}, // 1100
{"router2", "provider4"}, // 1000
{"Other", "Other"}, // 2100
},
"t": []string{
"2009-11-10T23:00:00Z",
"2009-11-10T23:01:00Z",
"2009-11-10T23:02:00Z",
},
"points": [][]int{
{2000, 5000, 3000},
{1000, 500, 100},
{1200, 0, 0},
{1100, 0, 0},
{0, 900, 100},
{1900, 100, 100},
},
"min": []int{
2000,
100,
0,
0,
0,
100,
},
"max": []int{
5000,
1000,
1200,
1100,
900,
1900,
},
"average": []int{
3333,
533,
400,
366,
333,
700,
},
}
mockConn.EXPECT().
Select(gomock.Any(), gomock.Any(), gomock.Any()).
SetArg(1, expectedSQL).
Return(nil)
input := graphQuery{
Start: time.Date(2022, 04, 10, 15, 45, 10, 0, time.UTC),
End: time.Date(2022, 04, 11, 15, 45, 10, 0, time.UTC),
Points: 100,
Limit: 20,
Dimensions: []graphColumn{
graphColumnExporterName,
graphColumnInIfProvider,
},
Filter: graphFilterGroup{
Operator: graphFilterGroupOperatorAll,
Rules: []graphFilterRule{
{
Column: graphColumnDstCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "FR",
}, {
Column: graphColumnSrcCountry,
Operator: graphFilterRuleOperatorEqual,
Value: "US",
},
},
},
}
payload := new(bytes.Buffer)
err = json.NewEncoder(payload).Encode(input)
if err != nil {
t.Fatalf("Encode() error:\n%+v", err)
}
resp, err := netHTTP.Post(fmt.Sprintf("http://%s/api/v0/console/graph", h.Address),
"application/json", payload)
if err != nil {
t.Fatalf("POST /api/v0/console/graph:\n%+v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("POST /api/v0/console/graph: got status code %d, not 200", resp.StatusCode)
}
gotContentType := resp.Header.Get("Content-Type")
if gotContentType != "application/json; charset=utf-8" {
t.Errorf("POST /api/v0/console/graph Content-Type (-got, +want):\n-%s\n+%s",
gotContentType, "application/json; charset=utf-8")
}
decoder := json.NewDecoder(resp.Body)
var got gin.H
if err := decoder.Decode(&got); err != nil {
t.Fatalf("POST /api/v0/console/graph error:\n%+v", err)
}
if diff := helpers.Diff(got, expected); diff != "" {
t.Fatalf("POST /api/v0/console/graph (-got, +want):\n%s", diff)
}
}
func TestGraphFieldsHandler(t *testing.T) {
r := reporter.NewMock(t)
ch, _ := clickhousedb.NewMock(t, r)
h := http.NewMock(t, r)
c, err := New(r, Configuration{}, Dependencies{
Daemon: daemon.NewMock(t),
HTTP: h,
ClickHouseDB: ch,
})
if err != nil {
t.Fatalf("New() error:\n%+v", err)
}
helpers.StartStop(t, c)
resp, err := netHTTP.Get(fmt.Sprintf("http://%s/api/v0/console/graph/fields", h.Address))
if err != nil {
t.Fatalf("POST /api/v0/console/graph/fields:\n%+v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("POST /api/v0/console/graph/fields: got status code %d, not 200", resp.StatusCode)
}
gotContentType := resp.Header.Get("Content-Type")
if gotContentType != "application/json; charset=utf-8" {
t.Errorf("POST /api/v0/console/graph/fields Content-Type (-got, +want):\n-%s\n+%s",
gotContentType, "application/json; charset=utf-8")
}
decoder := json.NewDecoder(resp.Body)
var got []string
if err := decoder.Decode(&got); err != nil {
t.Fatalf("POST /api/v0/console/graph error:\n%+v", err)
}
expected := []string{
"ExporterAddress",
"ExporterName",
"ExporterGroup",
"SrcAddr",
"DstAddr",
"SrcAS",
"DstAS",
"SrcCountry",
"DstCountry",
"InIfName",
"OutIfName",
"InIfDescription",
"OutIfDescription",
"InIfSpeed",
"OutIfSpeed",
"InIfConnectivity",
"OutIfConnectivity",
"InIfProvider",
"OutIfProvider",
"InIfBoundary",
"OutIfBoundary",
"EType",
"Proto",
"SrcPort",
"DstPort",
"ForwardingStatus",
}
sort.Strings(expected)
sort.Strings(got)
if diff := helpers.Diff(got, expected); diff != "" {
t.Fatalf("POST /api/v0/console/graph/fields (-got, +want):\n%s", diff)
}
}