Files
gateway/tests/integration.go
zooqueenandGitHub b3fd4513ca fix: configure test fixtures to bypass JWT validation (#5)
Add AUTH_ENABLED env var to disable the auth middleware entirely, and set
it to false in the integration test environment so HS256 test JWTs and
opaque bearer tokens are not rejected by the gateway's IAM JWT validator.

Also update param_forwarding test fixtures to use hk-* prefixed tokens
instead of bare "123456" to exercise the API key passthrough path.
2026-03-01 19:21:30 -08:00

690 lines
16 KiB
Go

// Package tests implements utility functions to help with API Gateway testing.
package tests
import (
"bytes"
"compress/gzip"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/textproto"
"os"
"os/exec"
"path"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/xeipuuv/gojsonschema"
)
var (
defaultBinPath *string = flag.String("gateway_bin_path", ".././gateway", "The default path to the gateway bin")
defaultSpecsPath *string = flag.String("gateway_specs_path", "./fixtures/specs", "The default path to the specs folder")
defaultBackendPort *int = flag.Int("gateway_backend_port", 8081, "The port for the mocked backend api")
defaultCfgPath *string = flag.String(
"gateway_config_path",
"fixtures/gateway.json",
"The default path to the gateway config",
)
defaultDelay *time.Duration = flag.Duration(
"gateway_delay",
200*time.Millisecond,
"The delay for the delayed backend endpoint",
)
defaultEnvironPatterns *string = flag.String(
"gateway_envar_pattern",
"",
"Comma separated list of patterns to use to filter the envars to pass (set to \".*\" to pass everything)",
)
notFollowRedirects = flag.Bool("client_not_follow_redirects", false, "The test http client should not follow http redirects")
defaultStartupWait *time.Duration = flag.Duration(
"gateway_startup_wait",
1500*time.Millisecond,
"The time to wait to let servers startup before start testing",
)
defaultReadyURL *string = flag.String(
"gateway_ready_url",
"",
"The url to check for system under test readiness.",
)
defaultReadyURLWait *time.Duration = flag.Duration(
"gateway_ready_url_wait",
1500*time.Millisecond,
"The maximum time to wait for the ready url to return a 200 Ok response.",
)
)
// TestCase defines a single case to be tested
type TestCase struct {
Name string `json:"name"`
Err string `json:"error"`
In Input `json:"in"`
Out Output `json:"out"`
Next []TestCase `json:"next"`
}
// Input is the definition of the request to send in a given TestCase
type Input struct {
URL string `json:"url"`
Method string `json:"method"`
Header map[string]string `json:"header"`
Body interface{} `json:"body"`
}
// Output contains the data required to verify the response received in a given TestCase
type Output struct {
StatusCode int `json:"status_code"`
Body interface{} `json:"body"`
Header map[string][]string `json:"header"`
Schema map[string]interface{} `json:"schema"`
}
// CmdBuilder defines an interface for building the cmd to be managed by the Runner
type CmdBuilder interface {
New(*Config) *exec.Cmd
}
// BackendBuilder defines an interface for building a server as a backend for the tests
type BackendBuilder interface {
New(*Config) http.Server
}
// GenericServer defines an interface to launch a server that
// could be an http.Server, a different type, or a wrapper
// around multiple servers.
type GenericServer interface {
Close() error
ListenAndServe() error
}
// ComposableBackendBuilder allows us to return
// a more generic interface for any kind of server.
type ComposableBackendBuilder interface {
NewGenericServer(*Config) GenericServer
}
// Config contains options for running a test.
type Config struct {
BinPath string
CfgPath string
SpecsPath string
EnvironPatterns string
BackendPort int
Delay time.Duration
HttpClient *http.Client
StartupWait time.Duration
ReadyURL string
ReadyURLWait time.Duration
}
func (c *Config) getBinPath() string {
if c.BinPath != "" {
return c.BinPath
}
return *defaultBinPath
}
func (c *Config) getCfgPath() string {
if c.CfgPath != "" {
return c.CfgPath
}
return *defaultCfgPath
}
func (c *Config) getSpecsPath() string {
if c.SpecsPath != "" {
return c.SpecsPath
}
return *defaultSpecsPath
}
func (c *Config) getBackendPort() int {
if c.BackendPort != 0 {
return c.BackendPort
}
return *defaultBackendPort
}
func (c *Config) getDelay() time.Duration {
if c.Delay != 0 {
return c.Delay
}
return *defaultDelay
}
func (c *Config) getReadyURL() string {
if c.ReadyURL != "" {
return c.ReadyURL
}
return *defaultReadyURL
}
func (c *Config) getReadyURLWait() time.Duration {
if c.ReadyURLWait != 0 {
return c.ReadyURLWait
}
return *defaultReadyURLWait
}
func (c *Config) getStartupWait() time.Duration {
if c.StartupWait != 0 {
return c.StartupWait
}
return *defaultStartupWait
}
func (c *Config) getEnvironPatterns() string {
if c.EnvironPatterns != "" {
return c.EnvironPatterns
}
return *defaultEnvironPatterns
}
func (c *Config) getHttpClient() *http.Client {
if c.HttpClient != nil {
return c.HttpClient
}
return defaultHttpClient()
}
func defaultHttpClient() *http.Client {
if *notFollowRedirects {
return &http.Client{
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
return http.DefaultClient
}
var defaultConfig Config
// NewIntegration sets up a runner for the integration test and returns it with the parsed specs from the specs folder
// and an error signaling if something went wrong. It uses the default values for any nil argument
func NewIntegration(cfg *Config, cb CmdBuilder, bb BackendBuilder) (*Runner, []TestCase, error) {
if cfg == nil {
cfg = &defaultConfig
}
if cb == nil {
cb = defaultCmdBuilder
}
cmd := cb.New(cfg)
var tcs []TestCase
if err := cmd.Start(); err != nil {
return nil, tcs, err
}
closeFuncs := []func(){
func() {
cmd.Process.Kill()
},
}
go func() { fmt.Println(cmd.Wait()) }()
var err error
tcs, err = testCases(*cfg)
if err != nil {
cmd.Process.Kill()
return nil, tcs, err
}
if bb == nil {
bb = DefaultBackendBuilder
}
var backend GenericServer
cbb, ok := bb.(ComposableBackendBuilder)
if ok {
backend = cbb.NewGenericServer(cfg)
} else {
httpServer := bb.New(cfg)
backend = &httpServer
}
closeFuncs = append(closeFuncs, func() { backend.Close() })
go func() {
if err := backend.ListenAndServe(); err != nil {
log.Printf("backend closed: %v\n", err)
}
}()
// wait for system under test and backend server to be ready
if err := waitForStartup(cfg.getReadyURL(), cfg.getReadyURLWait(), cfg.getStartupWait()); err != nil {
return nil, tcs, err
}
return &Runner{
closeFuncs: closeFuncs,
once: new(sync.Once),
httpClient: cfg.getHttpClient(),
}, tcs, nil
}
// waitForStartup checks the ready endpoint if provided for the provided time
// and then and additional startupWait time for the backend services to
// be ready.
func waitForStartup(readyURL string, readyURLWait, startupWait time.Duration) error {
if readyURL != "" {
return waitForReady(readyURL, readyURLWait)
}
if startupWait <= 0 {
startupWait = 1500 * time.Millisecond
}
<-time.After(startupWait)
return nil
}
func waitForReady(readyURL string, readyURLWait time.Duration) error {
if readyURLWait < time.Second {
readyURLWait = time.Second
}
deadline := time.Now().Add(readyURLWait)
resp, err := http.Get(readyURL)
for (resp == nil || resp.StatusCode != 200) && time.Now().Before(deadline) {
<-time.After(time.Second)
resp, err = http.Get(readyURL)
}
if resp != nil && resp.StatusCode == 200 {
fmt.Printf("system under test %s is ready\n", readyURL)
return nil
}
if err != nil {
return fmt.Errorf("cannot check %s is ready: %s", readyURL, err.Error())
}
if resp != nil {
return fmt.Errorf("cannot check %s is ready: expecting 200 status code, got %d",
readyURL, resp.StatusCode)
}
return fmt.Errorf("cannot check %s is ready", readyURL)
}
// Runner handles the integration test execution, by dealing with the request generation, response verification
// and the final shutdown
type Runner struct {
closeFuncs []func()
once *sync.Once
httpClient *http.Client
}
// Close shuts down the mocked backend server and the process of the instance
// under test
func (i *Runner) Close() {
i.once.Do(func() {
for _, closeF := range i.closeFuncs {
closeF()
}
})
}
// Check runs a test case, returning an error if something goes wrong
func (i *Runner) Check(tc TestCase) error {
req, err := newRequest(tc.In)
if err != nil {
return err
}
resp, err := i.httpClient.Do(req)
if err != nil && err.Error() != tc.Err {
return err
}
if err != nil {
return nil
}
return assertResponse(resp, tc.Out)
}
type responseError struct {
errMessage []string
}
func (m responseError) Error() string {
return "wrong response:\n\t" + strings.Join(m.errMessage, "\n\t")
}
func assertResponse(actual *http.Response, expected Output) error {
var errMsgs []string
if actual.StatusCode != expected.StatusCode {
errMsgs = append(errMsgs, fmt.Sprintf("unexpected status code. have: %d, want: %d", actual.StatusCode, expected.StatusCode))
}
for k, vs := range expected.Header {
k = textproto.CanonicalMIMEHeaderKey(k)
hs, ok := actual.Header[k]
isEqual := reflect.DeepEqual(vs, hs)
if ok && isEqual {
continue
}
if ok {
errMsgs = append(errMsgs, fmt.Sprintf("unexpected value for header %s. have: %s, want: %s", k, hs, vs))
continue
}
if vs[0] != "" {
errMsgs = append(errMsgs, fmt.Sprintf("header %s not present: %+v", k, actual.Header))
}
}
var body interface{}
var bodyBytes []byte
if actual.Body != nil {
// check if the body is compressed with gzip
var r io.Reader = actual.Body
if ce, ok := actual.Header["Content-Encoding"]; ok && len(ce) > 0 && (ce[0] == "gzip" || ce[0] == "x-gzip") {
if gr, err := gzip.NewReader(actual.Body); err == nil {
r = gr
}
}
b, err := io.ReadAll(r)
if err != nil {
return err
}
_ = actual.Body.Close()
switch expected.Body.(type) {
case string:
body = string(b)
default:
_ = json.Unmarshal(b, &body)
}
bodyBytes = b
}
if len(expected.Schema) != 0 {
if len(bodyBytes) == 0 {
return responseError{
errMessage: append(errMsgs, "cannot validate empty body"),
}
}
s, err := json.Marshal(expected.Schema)
if err != nil {
return responseError{
errMessage: append(errMsgs, fmt.Sprintf("problem marshaling the user provided json-schema: %s", err)),
}
}
schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(s))
if err != nil {
return responseError{
errMessage: append(errMsgs, fmt.Sprintf("problem generating json-schema schema: %s", err)),
}
}
result, err := schema.Validate(gojsonschema.NewBytesLoader(bodyBytes))
if err != nil {
return responseError{
errMessage: append(errMsgs, fmt.Sprintf("problem validating the body: %s", err)),
}
}
if !result.Valid() {
return responseError{
errMessage: append(errMsgs, fmt.Sprintf("the result is not valid: %s", result.Errors())),
}
}
} else if expected.Body != "" {
if !reflect.DeepEqual(body, expected.Body) {
errMsgs = append(errMsgs, fmt.Sprintf("unexpected body.\n\t\thave: %v\n\t\twant: %v", body, expected.Body))
}
}
if len(errMsgs) == 0 {
return nil
}
return responseError{
errMessage: errMsgs,
}
}
func testCases(cfg Config) ([]TestCase, error) {
var tcs []TestCase
content, err := readSpecs(cfg.getSpecsPath())
if err != nil {
return tcs, err
}
for name, c := range content {
tc, err := parseTestCase(name, c)
if err != nil {
return tcs, err
}
tcs = append(tcs, tc)
}
return tcs, nil
}
func parseTestCase(name string, in []byte) (TestCase, error) {
tc := TestCase{}
if err := json.Unmarshal(in, &tc); err != nil {
return tc, err
}
tc.Name = name
return tc, nil
}
func newRequest(in Input) (*http.Request, error) {
var body io.Reader
if in.Body != nil {
var b []byte
switch in.Body.(type) {
case string:
b = []byte(in.Body.(string))
default:
b, _ = json.Marshal(in.Body)
}
body = bytes.NewBuffer(b)
}
req, err := http.NewRequest(in.Method, in.URL, body)
if err != nil {
return nil, err
}
if host, ok := in.Header["Host"]; ok {
req.Host = host
}
for k, v := range in.Header {
req.Header.Add(k, v)
}
return req, nil
}
func readSpecs(dirPath string) (map[string][]byte, error) {
data := map[string][]byte{}
files, err := os.ReadDir(dirPath)
if err != nil {
return data, err
}
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".json") {
continue
}
content, err := os.ReadFile(path.Join(dirPath, file.Name()))
if err != nil {
return data, err
}
data[file.Name()[:len(file.Name())-5]] = content
}
return data, nil
}
var defaultCmdBuilder gatewayCmdBuilder
type gatewayCmdBuilder struct{}
func (k gatewayCmdBuilder) New(cfg *Config) *exec.Cmd {
cmd := exec.Command(cfg.getBinPath(), "run", "-d", "-c", cfg.getCfgPath())
cmd.Env = k.getEnviron(cfg)
return cmd
}
func (gatewayCmdBuilder) getEnviron(cfg *Config) []string {
environ := []string{"USAGE_DISABLE=1", "AUTH_ENABLED=false"}
var patterns []*regexp.Regexp
for _, pattern := range strings.Split(cfg.getEnvironPatterns(), ",") {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
patterns = append(patterns, re)
}
for _, candidate := range os.Environ() {
for _, pattern := range patterns {
if pattern.MatchString(candidate) {
environ = append(environ, candidate)
break
}
}
}
return environ
}
var DefaultBackendBuilder mockBackendBuilder
type mockBackendBuilder struct{}
func (mockBackendBuilder) New(cfg *Config) http.Server {
mux := http.NewServeMux()
mux.HandleFunc("/param_forwarding/", checkXForwardedFor(http.HandlerFunc(echoEndpoint)))
mux.HandleFunc("/xml", checkXForwardedFor(http.HandlerFunc(xmlEndpoint)))
mux.HandleFunc("/collection/", checkXForwardedFor(http.HandlerFunc(collectionEndpoint)))
mux.HandleFunc("/delayed/", checkXForwardedFor(delayedEndpoint(cfg.getDelay(), http.HandlerFunc(echoEndpoint))))
mux.HandleFunc("/redirect/", checkXForwardedFor(http.HandlerFunc(redirectEndpoint)))
mux.HandleFunc("/jwk/symmetric", http.HandlerFunc(symmetricJWKEndpoint))
return http.Server{ // skipcq: GO-S2112
Addr: fmt.Sprintf(":%v", cfg.getBackendPort()),
Handler: mux,
}
}
func collectionEndpoint(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "application/json")
var res []interface{}
for i := 0; i < 10; i++ {
res = append(res, map[string]interface{}{
"path": r.URL.Path,
"i": i,
})
}
json.NewEncoder(rw).Encode(res)
}
func checkXForwardedFor(h http.Handler) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
if ip := net.ParseIP(r.Header.Get("X-Forwarded-For")); ip == nil || !ip.IsLoopback() {
http.Error(rw, "invalid X-Forwarded-For", 400)
return
}
h.ServeHTTP(rw, r)
}
}
func delayedEndpoint(d time.Duration, h http.Handler) http.HandlerFunc {
return func(rw http.ResponseWriter, req *http.Request) {
<-time.After(d)
h.ServeHTTP(rw, req)
}
}
func xmlEndpoint(rw http.ResponseWriter, _ *http.Request) {
rw.Header().Add("Content-Type", "application/xml; charset=utf-8")
rw.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<user type="admin">
<name>Elliot</name>
<social>
<facebook>https://facebook.com</facebook>
<twitter>https://twitter.com</twitter>
<youtube>https://youtube.com</youtube>
</social>
</user>`))
}
func echoEndpoint(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "application/json")
rw.Header().Add("Set-Cookie", "test1=test1")
rw.Header().Add("Set-Cookie", "test2=test2")
r.Header.Del("X-Forwarded-For")
resp := map[string]interface{}{
"path": r.URL.Path,
"query": r.URL.Query(),
"headers": r.Header,
"foo": 42,
}
if r.URL.Query().Get("dump_body") == "1" {
b, _ := io.ReadAll(r.Body)
r.Body.Close()
resp["body"] = string(b)
}
json.NewEncoder(rw).Encode(resp)
}
func redirectEndpoint(rw http.ResponseWriter, r *http.Request) {
u := r.URL
u.Path = "/param_forwarding/"
status, ok2 := r.URL.Query()["status"]
code := 301
if !ok2 || status[0] != "301" {
var err error
code, err = strconv.Atoi(status[0])
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
}
http.Redirect(rw, r, u.String(), code)
}
func symmetricJWKEndpoint(rw http.ResponseWriter, _ *http.Request) {
rw.Header().Add("Content-Type", "application/json")
rw.Write([]byte(`{
"keys": [
{
"kty": "oct",
"alg": "A128KW",
"k": "GawgguFyGrWKav7AX4VKUg",
"kid": "sim1"
},
{
"kty": "oct",
"k": "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow",
"kid": "sim2",
"alg": "HS256"
}
]
}`))
}