Files
releases/v1/test/e2e/testing.go
T
Anders Eknert e03ac2f200 Bump golangci-lint, more gocritic linters (#8052)
- Bump golangci-lint -> 2.6.2
- Fix all `deprecatedComment` "notices should be in a dedicated paragraph, separated from the rest" reports
- Enable `appendCombine` and fix all "appendCombine: can combine chain of X appends into one" notices
- Enable `preferFprint` and fix the few reported issues
- Fix various issues reported only once or twice, like `zeroByteRepeat`

Signed-off-by: Anders Eknert <anders.eknert@apple.com>
2025-11-17 11:08:39 +01:00

517 lines
14 KiB
Go

// Copyright 2019 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package e2e
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/open-policy-agent/opa/internal/uuid"
"github.com/open-policy-agent/opa/v1/logging"
"github.com/open-policy-agent/opa/v1/logging/test"
"github.com/open-policy-agent/opa/v1/runtime"
"github.com/open-policy-agent/opa/v1/server/types"
"github.com/open-policy-agent/opa/v1/util"
)
func init() {
// Avoid port exhaustion in concurrent tests: https://github.com/golang/go/issues/16012
http.DefaultTransport.(*http.Transport).MaxIdleConnsPerHost = 100
}
const (
defaultAddr = "localhost:0" // default listening address for server, use a random open port
)
// NewAPIServerTestParams creates a new set of runtime.Params with enough
// default values filled in to start the server. Options can/should
// be customized for the test case.
func NewAPIServerTestParams() runtime.Params {
params := runtime.NewParams()
// Add in some defaults
params.Addrs = &[]string{defaultAddr}
params.Logging = runtime.LoggingConfig{
Level: "debug",
Format: "json-pretty",
}
// unless overridden, don't log from tests
params.Logger = logging.NewNoOpLogger()
params.GracefulShutdownPeriod = 10 // seconds
params.DecisionIDFactory = func() string {
id, err := uuid.New(rand.Reader)
if err != nil {
return ""
}
return id
}
return params
}
// TestRuntime holds metadata and provides helper methods
// to interact with the runtime being tested.
type TestRuntime struct {
Params runtime.Params
Runtime *runtime.Runtime
Ctx context.Context
Cancel context.CancelFunc
Client *http.Client
ConsoleLogger *test.Logger
url string
urlMtx *sync.Mutex
waitForBundles bool
}
// NewTestRuntime returns a new TestRuntime.
func NewTestRuntime(params runtime.Params) (*TestRuntime, error) {
return NewTestRuntimeWithOpts(TestRuntimeOpts{}, params)
}
// NewTestRuntimeWithOpts returns a new TestRuntime.
func NewTestRuntimeWithOpts(opts TestRuntimeOpts, params runtime.Params) (*TestRuntime, error) {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
rt, err := runtime.NewRuntime(ctx, params)
if err != nil {
cancel()
return nil, fmt.Errorf("create new runtime: %w", err)
}
return &TestRuntime{
Params: params,
Runtime: rt,
Ctx: ctx,
Cancel: cancel,
Client: &http.Client{},
urlMtx: new(sync.Mutex),
waitForBundles: opts.WaitForBundles,
}, nil
}
// WrapRuntime creates a new TestRuntime by wrapping an existing runtime
func WrapRuntime(ctx context.Context, cancel context.CancelFunc, rt *runtime.Runtime) *TestRuntime {
return &TestRuntime{
Params: rt.Params,
Runtime: rt,
Ctx: ctx,
Cancel: cancel,
Client: &http.Client{},
urlMtx: new(sync.Mutex),
}
}
// RunAPIServerTests will start the OPA runtime serving with a given
// configuration. This is essentially a wrapper for `m.Run()` that
// handles starting and stopping the local API server. The return
// value is what should be used as the code in `os.Exit` in the
// `TestMain` function.
//
// Deprecated: Use RunTests instead
func (t *TestRuntime) RunAPIServerTests(m *testing.M) int {
return t.runTests(m, true)
}
// RunAPIServerBenchmarks will start the OPA runtime and do
// `m.Run()` similar to how RunAPIServerTests works. This
// will suppress logging output on stdout to prevent the tests
// from being overly verbose. If log output is desired set
// the `test.v` flag.
//
// Deprecated: Use RunTests instead
func (t *TestRuntime) RunAPIServerBenchmarks(m *testing.M) int {
return t.runTests(m, !testing.Verbose())
}
// RunTests will start the OPA runtime serving with a given
// configuration. This is essentially a wrapper for `m.Run()` that
// handles starting and stopping the local API server. The return
// value is what should be used as the code in `os.Exit` in the
// `TestMain` function.
func (t *TestRuntime) RunTests(m *testing.M) int {
return t.runTests(m, !testing.Verbose())
}
// URL will return the URL that the server is listening on. If
// the server hasn't started listening this will return an empty string.
// It is not expected for the URL to change throughout the lifetime of the
// TestRuntime. Runtimes configured with >1 address will only get the
// first URL.
func (t *TestRuntime) URL() string {
if t.url != "" {
// fast path once it has been computed
return t.url
}
t.urlMtx.Lock()
defer t.urlMtx.Unlock()
// check again in the lock, it might have changed on us..
if t.url != "" {
return t.url
}
addrs := t.Runtime.Addrs()
if len(addrs) == 0 {
return ""
}
// Just pick the first one, if a test was configured with >1 they
// will need to determine the URLs themselves.
addr := addrs[0]
parsed, err := t.AddrToURL(addr)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
t.url = parsed
return t.url
}
// AddrToURL generates a full URL from an address, as configured on the runtime.
// This can include fully qualified urls, just host/ip, with port, or only port
// (eg, "localhost", ":8181", "http://foo", etc). If the runtime is configured
// with HTTPS certs it will generate an appropriate URL.
func (t *TestRuntime) AddrToURL(addr string) (string, error) {
if strings.HasPrefix(addr, ":") {
addr = "localhost" + addr
}
if !strings.Contains(addr, "://") {
scheme := "http://"
if t.Params.Certificate != nil {
scheme = "https://"
}
addr = scheme + addr
}
parsed, err := url.Parse(addr)
if err != nil {
return "", fmt.Errorf("failed to parse listening address of server: %s", err)
}
return parsed.String(), nil
}
func (t *TestRuntime) runTests(m *testing.M, suppressLogs bool) int {
// Start serving API requests in the background
done := make(chan error)
go func() {
// Suppress the stdlogger in the server
if suppressLogs {
logging.Get().SetOutput(io.Discard)
}
err := t.Runtime.Serve(t.Ctx)
done <- err
}()
// Turns out this thread gets a different stdlogger
// so we need to set the output on it here too.
if suppressLogs {
logging.Get().SetOutput(io.Discard)
}
// wait for the server to be ready
err := t.WaitForServer()
if err != nil {
return 1
}
// Actually run the unit tests/benchmarks
errc := m.Run()
// Wait for the API server to stop
t.Cancel()
err = <-done
if err != nil && errc == 0 {
// even if the tests passed return an error code if
// the server encountered an error
errc = 1
}
return errc
}
// TestRuntimeOpts contains parameters for the test runtime.
type TestRuntimeOpts struct {
WaitForBundles bool // indicates if readiness check should depend on bundle activation
PostServeActions func(rt *TestRuntime) error
}
// WithRuntime invokes f with a new TestRuntime after waiting for server
// readiness. This function can be called inside of each test that requires a
// runtime as opposed to RunTests which can only be called once.
func WithRuntime(t *testing.T, opts TestRuntimeOpts, params runtime.Params, f func(rt *TestRuntime)) {
t.Helper()
rt, err := NewTestRuntimeWithOpts(opts, params)
if err != nil {
t.Fatal(err)
}
done := make(chan error)
go func() {
err := rt.Runtime.Serve(rt.Ctx)
done <- err
}()
err = rt.WaitForServerStatus(runtime.ServerWaitingForPlugins)
if err != nil {
t.Fatal(err)
}
if opts.PostServeActions != nil {
err = opts.PostServeActions(rt)
if err != nil {
t.Fatal(err)
}
}
err = rt.WaitForServer()
if err != nil {
t.Fatal(err)
}
f(rt)
rt.Cancel()
err = <-done
if err != nil {
t.Fatal(err)
}
}
// WaitForServer will block until the server is running and passes a health check.
func (t *TestRuntime) WaitForServer() error {
delay := time.Duration(100) * time.Millisecond
retries := 100 // 10 seconds before we give up
for range retries {
// First make sure it has started listening and we have an address
if t.URL() != "" {
// Then make sure it has started serving
err := t.HealthCheck(t.URL())
if err == nil {
logging.Get().Info("Test server ready and listening on: %s", t.URL())
return nil
}
}
time.Sleep(delay)
}
return errors.New("API Server not ready in time")
}
func (t *TestRuntime) WaitForServerStatus(status runtime.ServerStatus) error {
delay := time.Duration(100) * time.Millisecond
retries := 100 // 10 seconds before we give up
for range retries {
if t.Runtime.ServerStatus() >= status {
return nil
}
time.Sleep(delay)
}
return fmt.Errorf("API Server did not reach status %d in time", status)
}
// DeletePolicy will delete the given policy in the runtime via the v1 policy API
func (t *TestRuntime) DeletePolicy(name string) error {
req, err := http.NewRequest("DELETE", t.URL()+"/v1/policies/"+name, nil)
if err != nil {
return err
}
resp, err := t.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to DELETE the test policy: %s", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected response: %d %s", resp.StatusCode, resp.Status)
}
return nil
}
// UploadPolicy will upload the given policy to the runtime via the v1 policy API
func (t *TestRuntime) UploadPolicy(name string, policy io.Reader) error {
req, err := http.NewRequest("PUT", t.URL()+"/v1/policies/"+name, policy)
if err != nil {
return fmt.Errorf("unexpected error creating request: %s", err)
}
resp, err := t.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to PUT the test policy: %s", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected response: %d %s", resp.StatusCode, resp.Status)
}
return nil
}
// UploadData will upload the given data to the runtime via the v1 data API
func (t *TestRuntime) UploadData(data io.Reader) error {
return t.UploadDataToPath("/", data)
}
// UploadDataToPath will upload the given data to the runtime via the v1 data API
func (t *TestRuntime) UploadDataToPath(path string, data io.Reader) error {
client := &http.Client{}
urlPath := strings.TrimSuffix("/v1/data"+path, "/")
req, err := http.NewRequest("PUT", t.URL()+urlPath, data)
if err != nil {
return fmt.Errorf("unexpected error creating request: %s", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to PUT data: %s", err)
}
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("unexpected response: %d %s", resp.StatusCode, resp.Status)
}
return nil
}
// GetDataWithInput will use the v1 data API and POST with the given input. The returned
// value is the full response body.
func (t *TestRuntime) GetDataWithInput(path string, input any) ([]byte, error) {
inputPayload := util.MustMarshalJSON(map[string]any{
"input": input,
})
path = strings.TrimPrefix(path, "/")
if !strings.HasPrefix(path, "data") {
path = "data/" + path
}
resp, err := t.GetDataWithRawInput(t.URL()+"/v1/"+path, bytes.NewReader(inputPayload))
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp)
if err != nil {
return nil, fmt.Errorf("unexpected error reading response body: %s", err)
}
resp.Close()
return body, nil
}
// GetDataWithRawInput will use the v1 data API and POST with the given input. The returned
// value is the full response body.
func (t *TestRuntime) GetDataWithRawInput(url string, input io.Reader) (io.ReadCloser, error) {
return t.request("POST", url, input)
}
// GetData will use the v1 data API and GET without input. The returned value is the full
// response body.
func (t *TestRuntime) GetData(url string) (io.ReadCloser, error) {
return t.request("GET", url, nil)
}
// CompileRequestWithInstrumentation will use the v1 compile API and POST with the given request and instrumentation enabled.
func (t *TestRuntime) CompileRequestWithInstrumentation(req types.CompileRequestV1) (*types.CompileResponseV1, error) {
return t.compileRequest(req, true)
}
// CompileRequest will use the v1 compile API and POST with the given request.
func (t *TestRuntime) CompileRequest(req types.CompileRequestV1) (*types.CompileResponseV1, error) {
return t.compileRequest(req, false)
}
func (t *TestRuntime) compileRequest(req types.CompileRequestV1, instrument bool) (*types.CompileResponseV1, error) {
inputPayload := util.MustMarshalJSON(req)
url := t.URL() + "/v1/compile"
if instrument {
url += "?instrument"
}
resp, err := t.request("POST", url, bytes.NewReader(inputPayload))
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp)
if err != nil {
return nil, fmt.Errorf("unexpected error reading response body: %s", err)
}
resp.Close()
var typedResp types.CompileResponseV1
err = json.Unmarshal(body, &typedResp)
if err != nil {
return nil, err
}
return &typedResp, nil
}
func (*TestRuntime) request(method, url string, input io.Reader) (io.ReadCloser, error) {
req, err := http.NewRequest(method, url, input)
if err != nil {
return nil, fmt.Errorf("unexpected error: %w", err)
}
req.Header.Set("content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("unexpected error: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected response status: %s", resp.Status)
}
return resp.Body, nil
}
// GetDataWithInputTyped returns an unmarshalled response from GetDataWithInput.
func (t *TestRuntime) GetDataWithInputTyped(path string, input any, response any) error {
bs, err := t.GetDataWithInput(path, input)
if err != nil {
return err
}
return json.Unmarshal(bs, response)
}
// HealthCheck will query /health and return an error if the server is not healthy
func (t *TestRuntime) HealthCheck(url string) error {
url += "/health"
if t.waitForBundles {
url += "?bundles"
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("unexpected error creating request: %s", err)
}
resp, err := t.Client.Do(req)
if err != nil {
return fmt.Errorf("unexpected error: %s", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected response: %d %s", resp.StatusCode, resp.Status)
}
return nil
}