squirrel eating cone in forest

Como fazer fuzz test em requests HTTP (parte 1)

Na edição de 2022 da GopherCon Brasil, tive o prazer de palestrar sobre Fuzz Test. Foi muito bacana, pois durante a palestra, assim como nos corredores do evento, fizeram vários questionamentos que eu ainda não tinha feito sobre essa feature do Go.

Se você ainda não conhece esse tipo de teste, convido você a ler um post que publicamos aqui no blog (link para o post) onde explicamos melhor o assunto.

O que vou tratar nesse post é o resultado das perguntas feitas no evento mais um link que o Ricardo Maricato me enviou.

Para ver uma das formas de implementar o Fuzz Test para requests HTTP, vamos implementar um endpoint para validação dos dados de uma pessoa.

Vamos começar criando uma struct com um método de validação, e algumas variáveis para armazenar os erros de validação que podemos ter.

var (
	ErrNameRequired = errors.New("Name is required")

	ErrEmailRequired = errors.New("Email is required")

	ErrPwdRequired = errors.New("Password is required")

	ErrPwdMinChars = errors.New("Password minimum is 6 chars")

	ErrAgeMin = errors.New("Minimum age is 18")
)

type Person struct {
	Name     string `json:"name"`
	Email    string `json:"email"`
	Password string `json:"password"`
	Age      uint8  `json:"age"`
}

func (p *Person) IsValid() error {
	if p.Name == "" {
		return ErrNameRequired
	}
	if p.Email == "" {
		return ErrEmailRequired
	}
	if p.Password == "" {
		return ErrPwdRequired
	}
	if len(p.Password) < 6 {
		return ErrPwdMinChars
	}
	if p.Age < 18 {
		return ErrAgeMin
	}
	return nil
}

Agora, vamos implementar o handler que irá fazer o decode do payload e executar as validações necessárias.

func validate(rw http.ResponseWriter, r *http.Request) {
	data, err := ioutil.ReadAll(r.Body)
	if err != nil {
		http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInsufficientStorage)
		return
	}

	var person Person

	err = json.Unmarshal(data, &person)
	if err != nil {
		http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		return
	}

	err = person.IsValid()
	if err != nil {
		rw.WriteHeader(http.StatusBadRequest)
		fmt.Fprintf(rw, err.Error())
		return
	}

	rw.Header().Add("Content-Type", "application/json")
	rw.WriteHeader(http.StatusOK)
	fmt.Fprintf(rw, "")
}

Por último, precisamos implementar nossa função main. O arquivo completo ficará assim:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
)

var (
	ErrNameRequired = errors.New("Name is required")

	ErrEmailRequired = errors.New("Email is required")

	ErrPwdRequired = errors.New("Password is required")

	ErrPwdMinChars = errors.New("Password minimum is 6 chars")

	ErrAgeMin = errors.New("Minimum age is 18")
)

type Person struct {
	Name     string `json:"name"`
	Email    string `json:"email"`
	Password string `json:"password"`
	Age      uint8  `json:"age"`
}

func (p *Person) IsValid() error {
	if p.Name == "" {
		return ErrNameRequired
	}
	if p.Email == "" {
		return ErrEmailRequired
	}
	if p.Password == "" {
		return ErrPwdRequired
	}
	if len(p.Password) < 6 {
		return ErrPwdMinChars
	}
	if p.Age < 18 {
		return ErrAgeMin
	}
	return nil
}

func validate(rw http.ResponseWriter, r *http.Request) {
	data, err := ioutil.ReadAll(r.Body)
	if err != nil {
		http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInsufficientStorage)
		return
	}

	var person Person

	err = json.Unmarshal(data, &person)
	if err != nil {
		http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		return
	}

	err = person.IsValid()
	if err != nil {
		rw.WriteHeader(http.StatusBadRequest)
		fmt.Fprintf(rw, err.Error())
		return
	}

	rw.Header().Add("Content-Type", "application/json")
	rw.WriteHeader(http.StatusOK)
	fmt.Fprintf(rw, "")
}

func main() {
	http.HandleFunc("/person/validate", validate)

	log.Fatal(http.ListenAndServe(":8080", nil))
}

Maravilha! Agora que já temos nosso endpoint implementado, vamos começar escrever a nossa função de Fuzz Test.

Podemos pensar nossa função em cinco blocos:

  • Criar alguns test cases;
  • Adicionar os test cases como Seed para o fuzz;
  • Mapear os erros conhecidos;
  • Iniciar um servidor para testes;
  • Função que irá receber os inputs gerados.
func FuzzValidate(f *testing.F) {
	// criar test cases
	testcases := []Person{
		{"Tiago Temporin", "tiago@aprendagolang.com.br", "1234", 32},
		{"Maria Castro", "maria.castro@algumdominio.com", "1Av6s#", 22},
		{"Daniela Fernandez", "dani@teste.com.br", "1234", 16},
	}

	// adicionar test cases como seed
	for _, tc := range testcases {
		data, _ := json.Marshal(tc)

		f.Add(data)
	}

	// mapear os erros conhecidos
	knowErrs := map[string]bool{
		ErrNameRequired.Error():  true,
		ErrEmailRequired.Error(): true,
		ErrPwdRequired.Error():   true,
		ErrPwdMinChars.Error():   true,
		ErrAgeMin.Error():        true,
	}

	// iniciar servidor para test
	srv := httptest.NewServer(http.HandlerFunc(validate))
	defer srv.Close()

	// função que irá receber os inputs gerados
	f.Fuzz(func(t *testing.T, data []byte) {

Dentro da função que irá receber os inputs, vamos realizar um POST com o input que foi gerado e depois validar se o status code é diferente de 200.

Caso seja diferente de 200, precisamos validar se o erro retornado foi um dos erros que mapeamos ou algo não esperado por nós.

O arquivo completo ficará assim:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"testing"
)

func FuzzValidate(f *testing.F) {
	// criar test cases
	testcases := []Person{
		{"Tiago Temporin", "tiago@aprendagolang.com.br", "1234", 32},
		{"Maria Castro", "maria.castro@algumdominio.com", "1Av6s#", 22},
		{"Daniela Fernandez", "dani@teste.com.br", "1234", 16},
	}

	// adicionar test cases como seed
	for _, tc := range testcases {
		data, _ := json.Marshal(tc)

		f.Add(data)
	}

	// mapear os erros conhecidos
	knowErrs := map[string]bool{
		ErrNameRequired.Error():  true,
		ErrEmailRequired.Error(): true,
		ErrPwdRequired.Error():   true,
		ErrPwdMinChars.Error():   true,
		ErrAgeMin.Error():        true,
	}

	// iniciar servidor para test
	srv := httptest.NewServer(http.HandlerFunc(validate))
	defer srv.Close()

	// função que irá receber os inputs gerados
	f.Fuzz(func(t *testing.T, data []byte) {
		// realizar POST
		resp, err := http.DefaultClient.Post(srv.URL, "application/json", bytes.NewBuffer(data))
		if err != nil {
			t.Errorf("Error: %v", err)
		}

		// validar status code
		if resp.StatusCode != http.StatusOK {
			// ler response body
			body, _ := ioutil.ReadAll(resp.Body)
			// verificar se é um erro conhecido
			if _, ok := knowErrs[string(body)]; ok {
				t.Skip(fmt.Sprintf("skiping knowing error: %s", body))
			}

			t.Errorf("Expected status code %d, got %d with error :%s", http.StatusOK, resp.StatusCode, body)
		}
	})
}

Ao executar nosso teste com o comando go test -fuzz . -v obtive o seguinte resultado:

Dando uma olhada na pasta testdata/fuzz/FuzzValidate, conseguimos verificar qual foi o input da request que falhou.

go test fuzz v1
[]byte("0")

Huummm… realmente eu não estava esperando um input fora do formato. Para resolver esse problema e deixar nosso código testável, vamos adicionar uma nova variável de erro, modificar o status e a mensagem de erro quando o payload não está no formato esperado.

var (
...

ErrInvalidPayload = errors.New("Invalid payload")
)

...

err = json.Unmarshal(data, &person)
if err != nil {
	rw.WriteHeader(http.StatusUnprocessableEntity)
	fmt.Fprintf(rw, ErrInvalidPayload.Error())
	return
}
...

Precisamos adicionar esse novo erro a nossa lista de erros conhecidos da função de teste.

...
knowErrs := map[string]bool{
		ErrNameRequired.Error():   true,
		ErrEmailRequired.Error():  true,
		ErrPwdRequired.Error():    true,
		ErrPwdMinChars.Error():    true,
		ErrAgeMin.Error():         true,
		ErrInvalidPayload.Error(): true, // novo erro
	}
...

Ao executar novamente o go test -fuzz . -v por mais de 1 minuto, não obtive mais nenhum erro.

Esse tipo de teste que escrevemos irá nos ajudar a validar payloads inesperados. Após esse cenário, podemos escrever um outro tipo de fuzz test para gerar inputs inesperados em um payload esperado.

Porém, para não ficar muito extenso, esse outro modo ficará para outro post.

Enquanto isso, se você quer saber mais sobre como fazer testes em Go, confira nosso curso “Testes e Benchmark“.

No mais, deixe suas dúvidas nos comentários.

Até a próxima.


Se inscreva na nossa newsletter

* indicates required

Deixe uma resposta