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)
}
milestone:6
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
https://github.com/thomasfinstad/terraform-provider-vyos/blob/c3bd4b16ebe7a43fe5699e92a09c689c41d3aa65/internal/terraform/tests/api/api-mock.go#L282