Merge pull request #1131 from fredbi/test/more-tests-report-receiver

test(reports): adds unit test to the report receiver
This commit is contained in:
Matthias Bertschy
2023-03-08 16:56:51 +01:00
committed by GitHub
3 changed files with 63478 additions and 57 deletions

View File

@@ -1,10 +1,37 @@
package reporter
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/armosec/armoapi-go/armotypes"
"github.com/kubescape/k8s-interface/workloadinterface"
"github.com/kubescape/kubescape/v2/core/cautils"
"github.com/kubescape/kubescape/v2/core/pkg/resultshandling/reporter"
"github.com/kubescape/opa-utils/reporthandling"
"github.com/kubescape/opa-utils/reporthandling/apis"
"github.com/kubescape/opa-utils/reporthandling/attacktrack/v1alpha1"
"github.com/kubescape/opa-utils/reporthandling/results/v1/prioritization"
"github.com/kubescape/opa-utils/reporthandling/results/v1/resourcesresults"
reporthandlingv2 "github.com/kubescape/opa-utils/reporthandling/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReportMock_GetURL(t *testing.T) {
func TestReportMockGetURL(t *testing.T) {
t.Parallel()
type fields struct {
query string
message string
@@ -31,15 +58,44 @@ func TestReportMock_GetURL(t *testing.T) {
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reportMock := &ReportMock{
query: tt.fields.query,
message: tt.fields.message,
}
if got := reportMock.GetURL(); got != tt.want {
t.Errorf("ReportMock.GetURL() = %v, want %v", got, tt.want)
}
for _, toPin := range tests {
tc := toPin
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var reportMock reporter.IReport = NewReportMock(tc.fields.query, tc.fields.message)
t.Run("mock reports should support GetURL", func(t *testing.T) {
got := reportMock.GetURL()
require.Equalf(t, tc.want, got,
"ReportMock.GetURL() = %v, want %v", got, tc.want,
)
})
t.Run("mock reports should support DisplayReportURL", func(t *testing.T) {
capture, clean := captureStderr(t)
defer clean()
reportMock.DisplayReportURL()
require.NoError(t, capture.Close())
buf, err := os.ReadFile(capture.Name())
require.NoError(t, err)
if tc.fields.message != "" {
require.NotEmpty(t, buf)
} else {
require.Empty(t, buf)
}
})
t.Run("mock reports should support Submit", func(t *testing.T) {
require.NoError(t,
reportMock.Submit(context.Background(), &cautils.OPASessionObj{}),
)
})
})
}
}
@@ -83,3 +139,219 @@ func TestReportMock_strToDisplay(t *testing.T) {
})
}
}
const pathTestReport = "/k8s/v2/postureReport"
type (
// mockableOPASessionObj reproduces OPASessionObj with concrete types instead of interfaces.
// It may be unmarshaled from a JSON fixture.
mockableOPASessionObj struct {
K8SResources *cautils.K8SResources
ArmoResource *cautils.KSResources
AllPolicies *cautils.Policies
AllResources map[string]*workloadinterface.Workload
ResourcesResult map[string]resourcesresults.Result
ResourceSource map[string]reporthandling.Source
ResourcesPrioritized map[string]prioritization.PrioritizedResource
ResourceAttackTracks map[string]*v1alpha1.AttackTrack
AttackTracks map[string]*v1alpha1.AttackTrack
Report *reporthandlingv2.PostureReport
RegoInputData cautils.RegoInputData
Metadata *reporthandlingv2.Metadata
InfoMap map[string]apis.StatusInfo
ResourceToControlsMap map[string][]string
SessionID string
Policies []reporthandling.Framework
Exceptions []armotypes.PostureExceptionPolicy
OmitRawResources bool
}
// testServer wraps a mock http server.
//
// It exposes a route to POST reports and asserts the submitted requests.
testServer struct {
*httptest.Server
}
// interceptor is a http.RoundTripper used to re-route the calls to the mock API server.
//
// NOTE(fredbi): ideally, the target URL is configurable so we don't need to resort to this to run tests.
interceptor struct {
original http.RoundTripper
host string
}
)
// mockOPASessionObj builds an OPASessionObj from a JSON fixture.
func mockOPASessionObj(t testing.TB) *cautils.OPASessionObj {
buf, err := os.ReadFile(filepath.Join(currentDir(), "testdata", "mock_opasessionobj.json"))
require.NoError(t, err)
var v mockableOPASessionObj
require.NoError(t,
json.Unmarshal(buf, &v),
)
o := cautils.OPASessionObj{
K8SResources: v.K8SResources,
ArmoResource: v.ArmoResource,
AllPolicies: v.AllPolicies,
//AllResources map[string]*workloadinterface.Workload // all scanned resources, map[<resource ID>]<resource>
ResourcesResult: v.ResourcesResult,
ResourceSource: v.ResourceSource,
ResourcesPrioritized: v.ResourcesPrioritized,
//ResourceAttackTracks map[string]*v1alpha1.AttackTrack // resources attack tracks, map[<resource ID>]<attack track>
//AttackTracks map[string]*v1alpha1.AttackTrack
Report: v.Report,
RegoInputData: v.RegoInputData,
Metadata: v.Metadata,
InfoMap: v.InfoMap,
ResourceToControlsMap: v.ResourceToControlsMap,
SessionID: v.SessionID,
Policies: v.Policies,
Exceptions: v.Exceptions,
OmitRawResources: v.OmitRawResources,
}
o.AllResources = make(map[string]workloadinterface.IMetadata, len(v.AllResources))
for k, val := range v.AllResources {
o.AllResources[k] = val
}
o.ResourceAttackTracks = make(map[string]v1alpha1.IAttackTrack, len(v.ResourceAttackTracks))
for k, val := range v.ResourceAttackTracks {
o.ResourceAttackTracks[k] = val
}
o.AttackTracks = make(map[string]v1alpha1.IAttackTrack, len(v.AttackTracks))
for k, val := range v.AttackTracks {
o.AttackTracks[k] = val
}
return &o
}
func (s *testServer) Root() string {
return s.Server.URL
}
func (s *testServer) URL(pth string) string {
pth = strings.TrimLeft(pth, "/")
return fmt.Sprintf("%s/%s", s.Server.URL, pth)
}
// mockAPIServer builds a mock API running with a TLS endpoint.
//
// Running tests with the DEBUG_TEST=1 environment will result in dumping a trace of
// the incoming requests.
func mockAPIServer(t testing.TB) *testServer {
h := http.NewServeMux()
server := &testServer{
Server: httptest.NewUnstartedServer(h),
}
h.HandleFunc(pathTestReport, func(w http.ResponseWriter, r *http.Request) {
if os.Getenv("DEBUG_TEST") != "" {
dump, _ := httputil.DumpRequest(r, true)
t.Logf("%s\n", dump)
}
if !assert.Equal(t, http.MethodPost, r.Method) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !assert.NoErrorf(t, r.ParseForm(), "expected params to parse") {
w.WriteHeader(http.StatusBadRequest)
return
}
cluster := r.Form.Get("clusterName")
contextName := r.Form.Get("contextName")
customer := r.Form.Get("customerGUID")
report := r.Form.Get("reportGUID")
if cluster == "" || contextName == "" || customer == "" || report == "" {
t.Error("missing query parameter")
w.WriteHeader(http.StatusBadRequest)
return
}
// NOTE(fredbi): (i) requests should have header Content-Type: "application/json"
// NOTE(fredbi): (ii) shouldn't we require an extra authentication (e.g. secretKey or Token)?
buf, err := io.ReadAll(r.Body)
defer func() {
_ = r.Body.Close()
}()
if !assert.NoError(t, err) {
w.WriteHeader(http.StatusInternalServerError)
return
}
var input reporthandlingv2.PostureReport
if !assert.NoError(t, json.Unmarshal(buf, &input)) {
w.WriteHeader(http.StatusInternalServerError)
return
}
})
h.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
dump, _ := httputil.DumpRequest(r, true)
t.Logf("%s\n", dump)
t.Errorf("unexpected route in input request: %v", r.URL)
w.WriteHeader(http.StatusNotFound)
})
server.StartTLS()
return server
}
// newInterceptor builds a new http.RoundTripper to re-route outgoing requests.
func newInterceptor(transport http.RoundTripper, host string) *interceptor {
return &interceptor{
original: transport,
host: host,
}
}
func (i *interceptor) RoundTrip(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
hijacked := r.Clone(r.Context())
hijacked.URL.Host = i.host
return i.original.RoundTrip(hijacked)
}
// hijackedClient builds an HTTP client suited for working against a mock server.
//
// This client supports mocked TLS and re-routes outgoing calls to the local mock server.
func hijackedClient(t testing.TB, srv *testServer) *http.Client {
tlsClient := srv.Client()
transport, ok := tlsClient.Transport.(*http.Transport)
require.True(t, ok)
mockURL, err := url.Parse(srv.Root())
require.NoError(t, err)
return &http.Client{
Transport: newInterceptor(transport, mockURL.Host),
}
}
func currentDir() string {
_, filename, _, _ := runtime.Caller(1)
return filepath.Dir(filename)
}

View File

@@ -1,15 +1,28 @@
package reporter
import (
"context"
"math/rand"
"net/url"
"os"
"strconv"
"sync"
"testing"
logger "github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/prettylogger"
"github.com/kubescape/kubescape/v2/core/cautils"
reporthandlingv2 "github.com/kubescape/opa-utils/reporthandling/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mxStdio serializes the capture of os.Stderr or os.Stdout
var mxStdio sync.Mutex
func TestReportEventReceiver_addPathURL(t *testing.T) {
t.Parallel()
tests := []struct {
report *ReportEventReceiver
urlObj *url.URL
@@ -57,19 +70,146 @@ func TestReportEventReceiver_addPathURL(t *testing.T) {
RawQuery: "customerGUID=FFFF&invitationToken=XXXX&utm_medium=createaccount&utm_source=ARMOgithub",
},
},
{
name: "add rbac path",
report: &ReportEventReceiver{
clusterName: "test",
customerGUID: "FFFF",
token: "XXXX",
customerAdminEMail: "test@test",
reportID: "1234",
submitContext: SubmitContextRBAC,
},
urlObj: &url.URL{
Scheme: "https",
Host: "localhost:8080",
},
want: &url.URL{
Scheme: "https",
Host: "localhost:8080",
Path: "rbac-visualizer",
},
},
{
name: "add repository path",
report: &ReportEventReceiver{
clusterName: "test",
customerGUID: "FFFF",
token: "XXXX",
customerAdminEMail: "test@test",
reportID: "1234",
submitContext: SubmitContextRepository,
},
urlObj: &url.URL{
Scheme: "https",
Host: "localhost:8080",
},
want: &url.URL{
Scheme: "https",
Host: "localhost:8080",
Path: "repository-scanning/1234",
},
},
{
name: "add default path",
report: &ReportEventReceiver{
clusterName: "test",
customerGUID: "FFFF",
token: "XXXX",
customerAdminEMail: "test@test",
reportID: "1234",
submitContext: SubmitContext("invalid"),
},
urlObj: &url.URL{
Scheme: "https",
Host: "localhost:8080",
},
want: &url.URL{
Scheme: "https",
Host: "localhost:8080",
Path: "dashboard",
},
},
{
name: "path when no email and no token",
report: &ReportEventReceiver{
clusterName: "test",
customerGUID: "FFFF",
token: "",
customerAdminEMail: "",
reportID: "1234",
submitContext: SubmitContextScan,
},
urlObj: &url.URL{
Scheme: "https",
Host: "localhost:8080",
},
want: &url.URL{
Scheme: "https",
Host: "localhost:8080",
Path: "compliance/test",
},
},
{
name: "path when email and no token",
report: &ReportEventReceiver{
clusterName: "test",
customerGUID: "FFFF",
token: "",
customerAdminEMail: "test@test",
reportID: "1234",
submitContext: SubmitContextScan,
},
urlObj: &url.URL{
Scheme: "https",
Host: "localhost:8080",
},
want: &url.URL{
Scheme: "https",
Host: "localhost:8080",
Path: "compliance/test",
},
},
{
name: "path when no email and token",
report: &ReportEventReceiver{
clusterName: "test",
customerGUID: "FFFF",
token: "XYZ",
customerAdminEMail: "",
reportID: "1234",
submitContext: SubmitContextScan,
},
urlObj: &url.URL{
Scheme: "https",
Host: "localhost:8080",
},
want: &url.URL{
Scheme: "https",
Host: "localhost:8080",
Path: "account/sign-up",
RawQuery: "customerGUID=FFFF&invitationToken=XYZ&utm_medium=createaccount&utm_source=ARMOgithub",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.report.addPathURL(tt.urlObj)
assert.Equal(t, tt.want.String(), tt.urlObj.String())
for _, toPin := range tests {
tc := toPin
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.report.addPathURL(tc.urlObj)
require.Equal(t, tc.want.String(), tc.urlObj.String())
})
}
}
func TestGetURL(t *testing.T) {
// Test submit and registered url
{
t.Parallel()
t.Run("with scan submit and registered url", func(t *testing.T) {
t.Parallel()
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1234",
@@ -81,10 +221,11 @@ func TestGetURL(t *testing.T) {
SubmitContextScan,
)
assert.Equal(t, "https://cloud.armosec.io/compliance/test", reporter.GetURL())
}
})
t.Run("with rbac submit and registered url", func(t *testing.T) {
t.Parallel()
// Test rbac submit and registered url
{
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1234",
@@ -96,10 +237,11 @@ func TestGetURL(t *testing.T) {
SubmitContextRBAC,
)
assert.Equal(t, "https://cloud.armosec.io/rbac-visualizer", reporter.GetURL())
}
})
t.Run("with repository submit and registered url", func(t *testing.T) {
t.Parallel()
// Test repo submit and registered url
{
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1234",
@@ -111,10 +253,10 @@ func TestGetURL(t *testing.T) {
SubmitContextRepository,
)
assert.Equal(t, "https://cloud.armosec.io/repository-scanning/XXXX", reporter.GetURL())
}
})
// Test submit and NOT registered url
{
t.Run("with scan submit and NOT registered url", func(t *testing.T) {
t.Parallel()
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
@@ -126,51 +268,286 @@ func TestGetURL(t *testing.T) {
SubmitContextScan,
)
assert.Equal(t, "https://cloud.armosec.io/account/sign-up?customerGUID=1234&invitationToken=token&utm_medium=createaccount&utm_source=ARMOgithub", reporter.GetURL())
}
})
t.Run("with unknown submit and NOT registered url (default route)", func(t *testing.T) {
t.Parallel()
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1234",
ClusterName: "test",
},
"",
SubmitContext("unknown"),
)
assert.Equal(t, "https://cloud.armosec.io/dashboard", reporter.GetURL())
})
}
func Test_prepareReportKeepsOriginalScanningTarget(t *testing.T) {
func TestDisplayReportURL(t *testing.T) {
t.Parallel()
// prepareReport should keep the original scanning target it received, and not mutate it
testCases := []struct {
Name string
Want reporthandlingv2.ScanningTarget
}{
{"Cluster", reporthandlingv2.Cluster},
{"File", reporthandlingv2.File},
{"Repo", reporthandlingv2.Repo},
{"GitLocal", reporthandlingv2.GitLocal},
{"Directory", reporthandlingv2.Directory},
t.Run("should display an empty message", func(t *testing.T) {
t.Parallel()
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1234",
Token: "token",
ClusterName: "test",
},
"",
SubmitContextScan,
)
capture, clean := captureStderr(t)
defer clean()
reporter.DisplayReportURL()
require.NoError(t, capture.Close())
buf, err := os.ReadFile(capture.Name())
require.NoError(t, err)
require.Empty(t, buf)
})
t.Run("should display a non-empty message", func(t *testing.T) {
t.Parallel()
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1234",
Token: "token",
ClusterName: "test",
},
"",
SubmitContextScan,
)
reporter.generateMessage()
capture, clean := captureStderr(t)
defer clean()
reporter.DisplayReportURL()
require.NoError(t, capture.Close())
buf, err := os.ReadFile(capture.Name())
require.NoError(t, err)
require.NotEmpty(t, buf)
assert.Contains(t, string(buf), "WOW!")
assert.Contains(t, string(buf), "https://cloud.armosec.io/account/sign-up")
t.Log(string(buf))
})
}
func TestPrepareReport(t *testing.T) {
t.Parallel()
t.Run("should keep the original scanning target it received and not mutate it", func(t *testing.T) {
testCases := []struct {
Name string
Want reporthandlingv2.ScanningTarget
}{
{"Cluster", reporthandlingv2.Cluster},
{"File", reporthandlingv2.File},
{"Repo", reporthandlingv2.Repo},
{"GitLocal", reporthandlingv2.GitLocal},
{"Directory", reporthandlingv2.Directory},
}
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1e3ae7c4-a8bb-4d7c-9bdf-eb86bc25e6bb",
Token: "token",
ClusterName: "test",
},
"",
SubmitContextScan,
)
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
want := tc.Want
opaSessionObj := &cautils.OPASessionObj{
Report: &reporthandlingv2.PostureReport{},
Metadata: &reporthandlingv2.Metadata{
ScanMetadata: reporthandlingv2.ScanMetadata{ScanningTarget: want},
},
}
reporter.prepareReport(opaSessionObj)
got := opaSessionObj.Metadata.ScanMetadata.ScanningTarget
require.Equalf(t, want, got,
"Scanning targets dont match after preparing report. Got: %v, want %v", got, want,
)
})
}
})
}
func TestSubmit(t *testing.T) {
ctx := context.Background()
srv := mockAPIServer(t)
t.Cleanup(srv.Close)
t.Run("should submit simple report", func(t *testing.T) {
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1e3ae7c4-a8bb-4d7c-9bdf-eb86bc25e6bb",
Token: "",
ClusterName: "test",
},
"cbabd56f-bac6-416a-836b-b815ef347647",
SubmitContextScan,
)
opaSession := mockOPASessionObj(t)
reporter.httpClient = hijackedClient(t, srv) // re-route the http client to our mock server, as this is not easily configurable in the reporter.
require.NoError(t,
reporter.Submit(ctx, opaSession),
)
})
t.Run("should warn when no customerGUID", func(t *testing.T) {
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
Token: "",
ClusterName: "test",
},
"cbabd56f-bac6-416a-836b-b815ef347647",
SubmitContextScan,
)
opaSession := mockOPASessionObj(t)
reporter.httpClient = hijackedClient(t, srv)
capture, clean := captureStderr(t)
if pretty, ok := logger.L().(*prettylogger.PrettyLogger); ok {
pretty.SetWriter(capture)
}
defer func() {
clean()
if pretty, ok := logger.L().(*prettylogger.PrettyLogger); ok {
pretty.SetWriter(os.Stderr)
}
}()
require.NoError(t,
reporter.Submit(ctx, opaSession),
)
require.NoError(t, capture.Close())
buf, err := os.ReadFile(capture.Name())
require.NoError(t, err)
assert.Contains(t, string(buf), "failed to publish result")
assert.Contains(t, string(buf), "Unknown acc")
})
t.Run("should warn when no cluster name", func(t *testing.T) {
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1e3ae7c4-a8bb-4d7c-9bdf-eb86bc25e6bb",
Token: "",
},
"cbabd56f-bac6-416a-836b-b815ef347647",
SubmitContextScan,
)
opaSession := mockOPASessionObj(t)
opaSession.Metadata.ScanMetadata.ScanningTarget = reporthandlingv2.Cluster
reporter.httpClient = hijackedClient(t, srv)
capture, clean := captureStderr(t)
if pretty, ok := logger.L().(*prettylogger.PrettyLogger); ok {
pretty.SetWriter(capture)
}
defer func() {
clean()
if pretty, ok := logger.L().(*prettylogger.PrettyLogger); ok {
pretty.SetWriter(os.Stderr)
}
}()
require.NoError(t,
reporter.Submit(ctx, opaSession),
)
require.NoError(t, capture.Close())
buf, err := os.ReadFile(capture.Name())
require.NoError(t, err)
assert.Contains(t, string(buf), "failed to publish result")
assert.Contains(t, string(buf), "cluster name")
})
}
func TestSetters(t *testing.T) {
t.Parallel()
pickString := func() string {
return strconv.Itoa(rand.Intn(10000)) //nolint:gosec
}
reporter := NewReportEventReceiver(
&cautils.ConfigObj{
AccountID: "1e3ae7c4-a8bb-4d7c-9bdf-eb86bc25e6bb",
Token: "token",
ClusterName: "test",
AccountID: "1e3ae7c4-a8bb-4d7c-9bdf-eb86bc25e6bb",
Token: "",
},
"",
"cbabd56f-bac6-416a-836b-b815ef347647",
SubmitContextScan,
)
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
want := tc.Want
t.Run("should set customerID", func(t *testing.T) {
guid := pickString()
reporter.SetCustomerGUID(guid)
opaSessionObj := &cautils.OPASessionObj{
Report: &reporthandlingv2.PostureReport{},
Metadata: &reporthandlingv2.Metadata{
ScanMetadata: reporthandlingv2.ScanMetadata{ScanningTarget: want},
},
}
require.Equal(t, guid, reporter.customerGUID)
})
reporter.prepareReport(opaSessionObj)
t.Run("should set cluster name", func(t *testing.T) {
cluster := pickString()
reporter.SetClusterName(cluster)
got := opaSessionObj.Metadata.ScanMetadata.ScanningTarget
if got != want {
t.Errorf("Scanning targets dont match after preparing report. Got: %v, want %v", got, want)
}
},
)
require.Equal(t, cluster, reporter.clusterName)
})
t.Run("should normalize cluster name", func(t *testing.T) {
const cluster = " x y\t\tz"
reporter.SetClusterName(cluster)
require.Equal(t, "-x-y-z", reporter.clusterName)
})
}
func captureStderr(t testing.TB) (*os.File, func()) {
mxStdio.Lock()
saved := os.Stderr
capture, err := os.CreateTemp("", "stderr")
if !assert.NoError(t, err) {
mxStdio.Unlock()
t.FailNow()
return nil, nil
}
os.Stderr = capture
return capture, func() {
_ = capture.Close()
_ = os.Remove(capture.Name())
os.Stderr = saved
mxStdio.Unlock()
}
}

File diff suppressed because one or more lines are too long