pkg/targettesting/targettest.go (221 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package targettesting
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
"regexp"
"strings"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/open-policy-agent/frameworks/constraint/pkg/client"
"github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers"
"github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/rego"
"github.com/open-policy-agent/frameworks/constraint/pkg/core/templates"
"github.com/open-policy-agent/frameworks/constraint/pkg/handler"
"github.com/open-policy-agent/frameworks/constraint/pkg/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/GoogleCloudPlatform/config-validator/pkg/gcv/configs"
)
// defaultConstraintTemplate will fail or pass a resource depending on the
// constraint's configuration. This does not actually inspect the object
// being reviewed.
const defaultConstraintTemplateRego = `
package testconstraint
violation[{"msg": msg}] {
input.parameters.fail == true
msg := input.parameters.msg
}
`
const testVersion = "v1beta1"
const testConstraintKind = "TestConstraint"
func newConstraintTemplate(targetName, rego string) *templates.ConstraintTemplate {
// Building a correct constraint template is difficult based on the struct. It's easier
// to reason about yaml files and rely on existing conversion code.
ctSpec := map[string]interface{}{
"crd": map[string]interface{}{
"spec": map[string]interface{}{
"names": map[string]interface{}{
"kind": testConstraintKind,
},
"validation": map[string]interface{}{
"openAPIV3Schema": map[string]interface{}{
"type": "object",
},
},
},
},
"targets": []map[string]interface{}{
{
"target": targetName,
"rego": rego,
},
},
}
ct := map[string]interface{}{
"apiVersion": fmt.Sprintf("templates.gatekeeper.sh/%s", testVersion),
"kind": "ConstraintTemplate",
"metadata": map[string]interface{}{
"name": strings.ToLower(testConstraintKind),
},
"spec": ctSpec,
}
config, err := configs.NewConfigurationFromContents([]*unstructured.Unstructured{&unstructured.Unstructured{Object: ct}}, []string{})
if err != nil {
// This represents an error in a test case
panic(err)
}
var templates []*templates.ConstraintTemplate
templates = append(templates, config.GCPTemplates...)
templates = append(templates, config.K8STemplates...)
templates = append(templates, config.TFTemplates...)
return templates[0]
}
func CreateTargetHandler(t *testing.T, target handler.TargetHandler, tcs []*ReviewTestcase) *TargetHandlerTest {
var targetHandlerTest = TargetHandlerTest{
NewTargetHandler: func(t *testing.T) handler.TargetHandler {
return target
},
}
targetHandlerTest.ReviewTestcases = tcs
return &targetHandlerTest
}
// FromJSON returns a function that will unmarshal the JSON string and handle
// errors appropriately.
func FromJSON(data string) func(t *testing.T) interface{} {
return func(t *testing.T) interface{} {
t.Helper()
var item interface{}
if err := json.Unmarshal([]byte(data), &item); err != nil {
t.Fatal(err)
}
return item
}
}
// TargetHandlerTest is a test harness for target handler
type TargetHandlerTest struct {
// NewTargetHandler returns a new target handler. This should call t.Helper()
// and t.Fatal() on any errors encountered during creation.
NewTargetHandler func(t *testing.T) handler.TargetHandler
// ReviewTestcases are the testcases that will be run against client.Review.
ReviewTestcases []*ReviewTestcase
}
// Test runs all testcases in the TargetHandlerTest
func (tt *TargetHandlerTest) Test(t *testing.T) {
t.Helper()
targetName := tt.NewTargetHandler(t).GetName()
testBase := testcaseBase{
newTargetHandler: tt.NewTargetHandler,
targetName: targetName,
constraintTemplate: newConstraintTemplate(
targetName,
defaultConstraintTemplateRego,
),
}
t.Run("matching_constraints", func(t *testing.T) {
for _, tc := range tt.ReviewTestcases {
tc.testcaseBase = testBase
t.Run(tc.Name, tc.run)
}
})
}
// testcaseBase contains params that are populated by the top level test
type testcaseBase struct {
newTargetHandler func(t *testing.T) handler.TargetHandler
targetName string
constraintTemplate *templates.ConstraintTemplate
}
// ReviewTestcase exercises the TargetHandler's HandleReview and Library
// matching_constraints functions.
type ReviewTestcase struct {
testcaseBase // stuff filled in by the test framework
Name string // Name of the testcase
Match map[string]interface{} // Constraint's Match block
Object func(t *testing.T) interface{} // function which returns an Object that's getting passed to the Review call
WantMatch bool // true if the match should succeed
WantConstraintError bool // true if adding the constraint should fail
WantLogged *regexp.Regexp // regexp to check against logged messages
}
// Run will set up the client with the TargetHandler and a test constraint template
// and constraint then run review.
func (tc *ReviewTestcase) run(t *testing.T) {
// matching_constraints needs differing constraints for the match blocks,
// to get test coverage, this gets exercised on calls to client.Review
ctx := context.Background()
var logOutput bytes.Buffer
log.SetOutput(&logOutput)
defer func() {
log.SetOutput(os.Stderr)
}()
// create client
cfClient := createClient(t, tc.newTargetHandler)
// add template
resp, err := cfClient.AddTemplate(ctx, tc.constraintTemplate)
if err != nil {
t.Fatalf("loading template %v: %v", tc.constraintTemplate, err)
}
if !resp.Handled[tc.targetName] {
t.Fatal("expected target name")
}
// Create synthetic constraint
constraintSpec := map[string]interface{}{
"parameters": map[string]interface{}{
"fail": true,
"msg": "it matched",
},
}
if tc.Match != nil {
constraintSpec["match"] = tc.Match
}
constraint := map[string]interface{}{
"apiVersion": "constraints.gatekeeper.sh/v1beta1",
"kind": testConstraintKind,
"metadata": map[string]interface{}{
"name": strings.ToLower(testConstraintKind),
},
"spec": constraintSpec,
}
resp, err = cfClient.AddConstraint(ctx, &unstructured.Unstructured{Object: constraint})
if tc.WantConstraintError {
if err == nil {
t.Fatal("expected constraint add error, got none")
}
return
}
if err != nil {
t.Fatal(err)
}
if !resp.Handled[tc.targetName] {
t.Fatal("expected target name")
}
// create review from tc, input needs to be GCP hierarchy path
reviewObj := tc.Object(t)
resp, err = cfClient.Review(ctx, reviewObj, drivers.Tracing(true))
if err != nil {
t.Fatal(err)
}
review, ok := resp.ByTarget[tc.targetName]
if !ok {
t.Fatal("expected target name in reviews")
}
if tc.WantMatch {
if len(review.Results) != 1 {
unitTestTraceDump(t, review)
t.Logf("match block: %v", tc.Match)
t.Fatalf("expected exactly one results in review, got %d", len(review.Results))
}
} else {
if len(review.Results) != 0 {
unitTestTraceDump(t, review)
t.Logf("match block: %v", tc.Match)
t.Fatalf("unexpected results in review")
}
}
if tc.WantLogged != nil {
if !tc.WantLogged.Match(logOutput.Bytes()) {
t.Fatalf("expected log output to match %s; got %s", tc.WantLogged.String(), logOutput.String())
}
}
}
func unitTestTraceDump(t *testing.T, review *types.Response) {
t.Helper()
// t.Logf("Trace:\n%s", *review.Trace)
t.Logf("Target: %s", review.Target)
t.Logf("Results(%d)", len(review.Results))
for idx, result := range review.Results {
t.Logf(" %d:\n%#v", idx, spew.Sdump(result))
}
}
func createClient(t *testing.T, newTargetHandler func(t *testing.T) handler.TargetHandler) *client.Client {
t.Helper()
target := newTargetHandler(t)
if target == nil {
t.Fatalf("newTargetHandler returned nil")
}
driver, err := rego.New(rego.Tracing(true))
if err != nil {
t.Fatalf("Could not initialize driver: %s", err)
}
cfClient, err := client.NewClient(client.Driver(driver), client.Targets(target))
if err != nil {
t.Fatalf("unable to set up OPA client: %s", err)
}
return cfClient
}