thomasfinstad / terraform-provider-vyos-rolling

Terraform provider for VyOS with a focus on automatic resource generation
6 stars 0 forks source link

evaluate usage of testify for Mock server #197

Open github-actions[bot] opened 7 months ago

github-actions[bot] commented 7 months ago

milestone:6

Can be used to compare json.

Ref: https://pkg\.go\.dev/github\.com/stretchr/testify/require?utm\_source\=godoc\#JSONEq

the values we receive as they are likely, but not guarantied, to be from

other json marshalling operations

I do not know of a lazier way to do a botched "Deep Equals" that

don't care about slice order for deeply complex slices.

this should work fairly ok as we know anything that will be passed to it has

already been unmarashled from json.

possible issue: If two objects contains the same

JSONified letters but with different meanings, eg: ["redhat", "thread"] and ["thread", "hatred"]

they will be considered equal for the sorting and might cause issues

if there are multiple of them

https://github.com/thomasfinstad/terraform-provider-vyos/blob/c3bd4b16ebe7a43fe5699e92a09c689c41d3aa65/internal/terraform/tests/api/api-mock.go#L112


package api

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "net"
    "net/http"
    "os"
    "slices"
    "strings"
    "time"

    "github.com/google/go-cmp/cmp"
    "github.com/google/go-cmp/cmp/cmpopts"
)

// used by CRUD helper tests

// Example is an example of how to use the mocking
func Example() {
    // go run . & sleep 2 && curl -k --location --request POST "http://$VYOS_HOST/retrieve" --form key="$VYOS_KEY" --form data='{"op": "showConfig", "path": ["firewall", "ipv4", "name", "SrvMain-WanTelia"]}'

    addr := "localhost:8080"
    srv := &http.Server{Addr: addr}

    // Input
    uri := "/retrieve"
    key := "test-key"
    ops := `{"op": "showConfig", "path": ["firewall", "ipv4", "name", "SrvMain-WanTelia"]}`

    // Output
    response := `{"success": true, "data": {"default-action": "reject", "default-log": {}, "description": "Managed by terraform", "rule": {"1": {"action": "accept", "description": "Allow established connections", "protocol": "all", "state": "established"}, "2": {"action": "accept", "description": "Allow related connections", "protocol": "all", "state": "related"}, "3": {"action": "drop", "description": "Disallow invalid packets", "log": {}, "protocol": "all", "state": "invalid"}, "1000": {"action": "accept", "description": "Allow http outgoing traffic", "destination": {"group": {"port-group": "Web"}}, "protocol": "tcp"}}}, "error": null}`

    el := NewExchangeList()
    e := el.Add()
    e.Expect(uri, key, ops)
    e.Response(200, response)
    Server(srv, el)

    /*
        ... make http requests here to test that they match
    */
}

// NewExchangeList constructs a new list used to add new exchanges
func NewExchangeList() *ExchangeList {
    return &ExchangeList{failed: "N/A"}
}

// ExchangeList holds and handles exchanges to be matched against
type ExchangeList struct {
    exchanges []*Exchange
    failed    string
}

// Add appends a new exchange to the list and returns a reference of the exchange
func (el *ExchangeList) Add() *Exchange {
    e := &Exchange{
        matched: false,
        delay:   0,
    }
    el.exchanges = append(el.exchanges, e)
    return e
}

func (el *ExchangeList) handle(w http.ResponseWriter, r *http.Request) (foundMatch bool) {
    return el.Unmatched()[0].handle(w, r)
}

// Unmatched returns all exchanges that did not trigger a match so far, useful for inspection
func (el *ExchangeList) Unmatched() []*Exchange {
    var ret []*Exchange
    for _, e := range el.exchanges {
        if !e.matched {
            ret = append(ret, e)
        }
    }

    return ret
}

// Failed returns returns a human friendly string representation for the request that did not match
func (el *ExchangeList) Failed() string {
    return el.failed
}

// Matched returns all exchanges that triggered a match so far, useful for inspection
func (el *ExchangeList) Matched() []*Exchange {
    var ret []*Exchange
    for _, e := range el.exchanges {
        if e.matched {
            ret = append(ret, e)
        }
    }

    return ret
}

// Exchange holds information used to match requests sent to the Server
type Exchange struct {
    matched  bool
    expect   expect
    response response
    delay    time.Duration
}

// Expect configures how the incoming request is expected to look
func (e *Exchange) Expect(uri, key string, ops string) *Exchange {
    // TODO evaluate usage of testify for Mock server
    //  milestone:6
    //  Can be used to compare json.
    //  Ref: https://pkg.go.dev/github.com/stretchr/testify/require?utm_source=godoc#JSONEq

    // If we can json marshal the ops string it will be more likely to conform to
    // the values we receive as they are likely, but not guarantied, to be from
    // other json marshalling operations

    // Check if we can match a JSON blob to maps
    unmarshalMap := make(map[string]any)
    err := json.Unmarshal([]byte(ops), &unmarshalMap)
    if err == nil {
        opsB, err := json.Marshal(unmarshalMap)
        if err == nil {
            ops = string(opsB)
        }
    }

    // Check if we can match a JSON blob to lists
    unmarshalSlice := []interface{}{}
    err = json.Unmarshal([]byte(ops), &unmarshalSlice)
    if err == nil {
        opsB, err := json.Marshal(unmarshalSlice)
        if err == nil {
            ops = string(opsB)
        }
    }

    // Crete expect and return self
    e.expect = expect{
        uri: uri,
        key: key,
        ops: ops,
    }

    return e
}

// Delay configures how long to wait before responding
func (e *Exchange) Delay(delay time.Duration) *Exchange {
    e.delay = delay

    return e
}

// Sexpect returns a human friendly string representation of the expect config
func (e *Exchange) Sexpect() string {
    return fmt.Sprintf("Expected Uri: %v\nExpected Key: %v\nExpected Ops: %v", e.expect.uri, e.expect.key, e.expect.ops)
}

// Response configures how the Exchange should respond when matched
func (e *Exchange) Response(code int, body string) *Exchange {
    e.response = response{
        code: code,
        body: body,
    }

    return e
}

func (e *Exchange) handle(w http.ResponseWriter, r *http.Request) (ok bool) {
    if !e.expect.matches(r) {
        return false
    }

    if e.delay > 0 {
        log.Printf("Mock srv:  Delaying response for: %dms", e.delay/time.Millisecond)
        time.Sleep(e.delay)
    }

    if !e.response.reply(w) {
        panic("Mock srv: unable to send reply")
    }

    log.Printf("Mock srv: Matched: %v", e.expect)
    e.matched = true
    return true
}

type expect struct {
    uri, key string
    ops      string
}

func (e expect) matches(r *http.Request) bool {
    // Check the simple parameters first to see if we can skip some work
    if !(e.uri == r.RequestURI && e.key == r.FormValue("key")) {
        return false
    }

    // sorting function for the cmp.Equal call
    // I do not know of a lazier way to do a botched "Deep Equals" that
    // don't care about slice order for deeply complex slices.
    // this should work fairly ok as we know anything that will be passed to it has
    // already been unmarashled from json.
    //
    // possible issue: If two objects contains the same
    // JSONified letters but with different meanings, eg: ["redhat", "thread"] and ["thread", "hatred"]
    // they will be considered equal for the sorting and might cause issues
    // if there are multiple of them
    sillySort := func(v1, v2 any) bool {
        v1Byte, err := json.Marshal(v1)
        if err != nil {
            panic(err)
        }
        v1StrSlice := strings.Split(string(v1Byte), "")
        slices.Sort(v1StrSlice)

        v2Byte, err := json.Marshal(v2)
        if err != nil {
            panic(err)
        }
        v2StrSlice := strings.Split(string(v2Byte), "")
        slices.Sort(v2StrSlice)

        return strings.Join(v1StrSlice, "") > strings.Join(v2StrSlice, "")
    }

    formData := r.FormValue("data")

    // Check if we can match a JSON blob to maps
    eOpsJSONM := make(map[string]any)
    formDataJSONM := make(map[string]any)
    opsErr := json.Unmarshal([]byte(e.ops), &eOpsJSONM)
    formErr := json.Unmarshal([]byte(formData), &formDataJSONM)
    if opsErr == nil && formErr == nil {
        log.Printf("Mock srv: map[string]any exchange check\n")
        return cmp.Equal(eOpsJSONM, formDataJSONM,
            cmpopts.SortMaps(sillySort),
            cmpopts.SortSlices(sillySort),
        )
    }

    // Check if we can match a JSON blob to lists
    eOpsJSONL := []interface{}{}
    formDataJSONL := []interface{}{}
    opsErr = json.Unmarshal([]byte(e.ops), &eOpsJSONL)
    formErr = json.Unmarshal([]byte(formData), &formDataJSONL)
    if opsErr == nil && formErr == nil {
        log.Printf("Mock srv: []interface{}{} exchange check\n")
        return cmp.Equal(eOpsJSONL, formDataJSONL,
            cmpopts.SortMaps(sillySort),
            cmpopts.SortSlices(sillySort),
        )
    }

    // Otherwise try to just match as a simple string
    log.Printf("Mock srv: comparing exchange expect as string\n")
    return e.ops == formData
}

type response struct {
    code int
    body string
}

func (r response) reply(w http.ResponseWriter) (ok bool) {
    _, err := w.Write([]byte(r.body))
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return false
    }

    if f, ok := w.(http.Flusher); ok {
        f.Flush()
    }
    return true
}

// TODO change to using test server https://pkg.go.dev/net/http/httptest#Server
//  milestone:6

// Server starts and maintains the http server until all exchanges are matched
func Server(srv *http.Server, el *ExchangeList) {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

        if !el.handle(w, r) {
            log.Printf("Mock srv: Request did not match the next expected exchange pattern:\n")
            el.failed = fmt.Sprintf("Requested Uri: %v\nRequested Key: %v\nRequested Ops: %v", r.RequestURI, r.FormValue("key"), r.FormValue("data"))

            w.WriteHeader(http.StatusNotFound)
            w.Write([]byte("NO MATCH"))
            if f, ok := w.(http.Flusher); ok {
                f.Flush()
            }
            log.Printf("Mock srv: Flushed\n")

            go srv.Shutdown(context.TODO())
            log.Printf("Mock srv: Srv Shutdown\n")
        }

        if len(el.Unmatched()) == 0 {
            // Only works if the response.reply() does a Flush() on the http.ResponseWriter,
            //   otherwise it will close the server before response is sent
            // Run as a go func as being in this handler and shutting down the server is self-blocking
            go srv.Shutdown(context.TODO())
            log.Printf("Mock srv: Srv Shutdown\n")
        }
    })

    srv.Handler = mux

    // Split out listen and serve functions to reduce chances of server not being ready when test starts
    l, err := net.Listen("tcp", srv.Addr)
    if err != nil {
        log.Fatalf("Mock srv: error starting listner: %s\n", err)
    }

    go func() {
        err := srv.Serve(l)
        if errors.Is(err, http.ErrServerClosed) {
            log.Printf("Mock srv: server closed\n")
        } else if err != nil {
            log.Printf("Mock srv: error starting server: %s\n", err)
            os.Exit(1)
        }
    }()

    time.Sleep(50 * time.Millisecond)
}