Skip to content

Commit

Permalink
Fuzz and property tests for json codec (#91)
Browse files Browse the repository at this point in the history
* Fuzz and property tests for all codecs

- Fuzz/property tests for json codec
- Fuzz/property tests for protoObservationCodec
- Fuzz/property tests for protoOutcomeCodec
* Property+fuzz test for OnchainConfigCodec
  • Loading branch information
samsondav authored Dec 2, 2024
1 parent cdc3d1e commit a90db35
Show file tree
Hide file tree
Showing 13 changed files with 1,239 additions and 50 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ toolchain go1.22.5

require (
github.com/hashicorp/go-plugin v1.6.2-0.20240829161738-06afb6d7ae99
github.com/leanovate/gopter v0.2.11
github.com/shopspring/decimal v1.4.0
github.com/smartcontractkit/chainlink-common v0.3.1-0.20241106142051-c7bded1c08ae
github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12
Expand Down
486 changes: 486 additions & 0 deletions go.sum

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions llo/json_report_codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func UnmarshalJSONStreamValue(enc *JSONStreamValue) (StreamValue, error) {

type JSONReportCodec struct{}

func (cdc JSONReportCodec) Encode(ctx context.Context, r Report, _ llotypes.ChannelDefinition) ([]byte, error) {
func (cdc JSONReportCodec) Encode(_ context.Context, r Report, _ llotypes.ChannelDefinition) ([]byte, error) {
type encode struct {
ConfigDigest types.ConfigDigest
SeqNr uint64
Expand Down Expand Up @@ -81,7 +81,6 @@ func (cdc JSONReportCodec) Encode(ctx context.Context, r Report, _ llotypes.Chan
return json.Marshal(e)
}

// Fuzz testing: MERC-6522
func (cdc JSONReportCodec) Decode(b []byte) (r Report, err error) {
type decode struct {
ConfigDigest string
Expand All @@ -97,6 +96,11 @@ func (cdc JSONReportCodec) Decode(b []byte) (r Report, err error) {
if err != nil {
return r, fmt.Errorf("failed to decode report: expected JSON (got: %s); %w", b, err)
}
if d.SeqNr == 0 {
// catch obviously bad inputs, since a valid report can never have SeqNr == 0
return r, fmt.Errorf("missing SeqNr")
}

cdBytes, err := hex.DecodeString(d.ConfigDigest)
if err != nil {
return r, fmt.Errorf("invalid ConfigDigest; %w", err)
Expand All @@ -112,10 +116,6 @@ func (cdc JSONReportCodec) Decode(b []byte) (r Report, err error) {
return r, fmt.Errorf("failed to decode StreamValue: %w", err)
}
}
if d.SeqNr == 0 {
// catch obviously bad inputs, since a valid report can never have SeqNr == 0
return r, fmt.Errorf("missing SeqNr")
}

return Report{
ConfigDigest: cd,
Expand Down
256 changes: 253 additions & 3 deletions llo/json_report_codec_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package llo

import (
"bytes"
"fmt"
"math"
reflect "reflect"
"testing"

"github.com/leanovate/gopter"
"github.com/leanovate/gopter/gen"
"github.com/leanovate/gopter/prop"
"github.com/shopspring/decimal"
"github.com/smartcontractkit/libocr/commontypes"
"github.com/smartcontractkit/libocr/offchainreporting2/types"
ocr2types "github.com/smartcontractkit/libocr/offchainreporting2/types"

Expand All @@ -16,6 +23,250 @@ import (
"github.com/stretchr/testify/require"
)

func FuzzJSONCodec_Decode_Unpack(f *testing.F) {
validJSON := []byte(`{"foo":"bar"}`)
emptyInput := []byte(``)
nilInput := []byte(nil)
nullJSON := []byte(`null`)
incompleteJSON := []byte(`{`)
notJSON := []byte(`"random string"`)
unprintable := []byte{1, 2, 3}
validJSONReport := []byte(`{"ConfigDigest":"0102030000000000000000000000000000000000000000000000000000000000","SeqNr":43,"ChannelID":46,"ValidAfterSeconds":44,"ObservationTimestampSeconds":45,"Values":[{"Type":0,"Value":"1"},{"Type":0,"Value":"2"},{"Type":1,"Value":"Q{Bid: 3.13, Benchmark: 4.4, Ask: 5.12}"}],"Specimen":true}`)
invalidConfigDigest := []byte(`{"SeqNr":42,"ConfigDigest":"foo"}`)
invalidConfigDigestNotEnoughBytes := []byte(`{"SeqNr":42,"ConfigDigest":"0xdead"}`)
badStreamValues := []byte(`{"SeqNr":42,"ConfigDigest":"0102030000000000000000000000000000000000000000000000000000000000", "Values":[{"Type":0,"Value":null},{"Type":-1,"Value":"2"}]}`)

f.Add(validJSON)
f.Add(emptyInput)
f.Add(nilInput)
f.Add(nullJSON)
f.Add(incompleteJSON)
f.Add(notJSON)
f.Add(unprintable)
f.Add(validJSONReport)
f.Add(invalidConfigDigest)
f.Add(invalidConfigDigestNotEnoughBytes)
f.Add(badStreamValues)

validPackedJSONTemplate := `{"configDigest":"0102030000000000000000000000000000000000000000000000000000000000","seqNr":43,"report":%s,"sigs":[{"Signature":"AgME","Signer":2}]}`
packedJSONReports := [][]byte{
[]byte(fmt.Sprintf(validPackedJSONTemplate, validJSON)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, emptyInput)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, nilInput)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, nullJSON)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, incompleteJSON)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, notJSON)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, unprintable)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, validJSONReport)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, invalidConfigDigest)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, invalidConfigDigestNotEnoughBytes)),
[]byte(fmt.Sprintf(validPackedJSONTemplate, badStreamValues)),
}
for _, packedJSONReport := range packedJSONReports {
f.Add(packedJSONReport)
}

packedJSONSigTemplate := `{"configDigest":"0102030000000000000000000000000000000000000000000000000000000000","seqNr":43,"report":{},"sigs":[{"Signature":%s,"Signer":2}]}`
badSigs := [][]byte{
[]byte(fmt.Sprintf(packedJSONSigTemplate, `null`)),
[]byte(fmt.Sprintf(packedJSONSigTemplate, `""`)),
[]byte(fmt.Sprintf(packedJSONSigTemplate, `1`)),
[]byte(fmt.Sprintf(packedJSONSigTemplate, `[]`)),
[]byte(fmt.Sprintf(packedJSONSigTemplate, `"abc$def#ghi!"`)),
}
for _, badSig := range badSigs {
f.Add(badSig)
}

var codec JSONReportCodec
f.Fuzz(func(t *testing.T, data []byte) {
// test that it doesn't panic, don't care about errors
codec.Decode(data) //nolint:errcheck
codec.Unpack(data) //nolint:errcheck
codec.UnpackDecode(data) //nolint:errcheck
})
}

func Test_JSONCodec_Properties(t *testing.T) {
properties := gopter.NewProperties(nil)

ctx := tests.Context(t)
cd := llotypes.ChannelDefinition{}
codec := JSONReportCodec{}

properties.Property("Encode/Decode", prop.ForAll(
func(r Report) bool {
b, err := codec.Encode(ctx, r, cd)
require.NoError(t, err)
r2, err := codec.Decode(b)
require.NoError(t, err)
return equalReports(r, r2)
},
gen.StrictStruct(reflect.TypeOf(&Report{}), map[string]gopter.Gen{
"ConfigDigest": genConfigDigest(),
"SeqNr": genSeqNr(),
"ChannelID": gen.UInt32(),
"ValidAfterSeconds": gen.UInt32(),
"ObservationTimestampSeconds": gen.UInt32(),
"Values": genStreamValues(),
"Specimen": gen.Bool(),
}),
))

properties.Property("Pack/Unpack", prop.ForAll(
func(digest types.ConfigDigest, seqNr uint64, report ocr2types.Report, sigs []types.AttributedOnchainSignature) bool {
b, err := codec.Pack(digest, seqNr, report, sigs)
require.NoError(t, err)
digest2, seqNr2, report2, sigs2, err := codec.Unpack(b)
require.NoError(t, err)

if digest != digest2 {
return false
}
if seqNr != seqNr2 {
return false
}
if !bytes.Equal(report, report2) {
return false
}
if len(sigs) != len(sigs2) {
return false
}
for i := range sigs {
if sigs[i].Signer != sigs2[i].Signer || !bytes.Equal(sigs[i].Signature, sigs2[i].Signature) {
return false
}
}
return true
},
genConfigDigest(),
genSeqNr(),
genSerializedReport(),
genSigs(),
))

properties.TestingRun(t)
}

func equalReports(r, r2 Report) bool {
if r.ConfigDigest != r2.ConfigDigest {
return false
}
if r.SeqNr != r2.SeqNr {
return false
}
if r.ChannelID != r2.ChannelID {
return false
}
if r.ValidAfterSeconds != r2.ValidAfterSeconds {
return false
}
if r.ObservationTimestampSeconds != r2.ObservationTimestampSeconds {
return false
}
if len(r.Values) != len(r2.Values) {
return false
}
for i := range r.Values {
if !equalStreamValues(r.Values[i], r2.Values[i]) {
return false
}
}
return r.Specimen == r2.Specimen
}

func equalStreamValues(sv, sv2 StreamValue) bool {
if sv.Type() != sv2.Type() {
return false
}
m1, err := sv.MarshalBinary()
if err != nil {
// should be impossible
panic(err)
}
m2, err := sv2.MarshalBinary()
if err != nil {
// should be impossible
panic(err)
}
return bytes.Equal(m1, m2)
}

func genConfigDigest() gopter.Gen {
return func(p *gopter.GenParameters) *gopter.GenResult {
var cd types.ConfigDigest
p.Rng.Read(cd[:])
return gopter.NewGenResult(cd, gopter.NoShrinker)
}
}

func genSeqNr() gopter.Gen {
return gen.UInt64Range(1, math.MaxUint64)
}

func genSerializedReport() gopter.Gen {
return gen.Const(ocr2types.Report(`{"foo":"bar"}`))
}

func genSigs() gopter.Gen {
return gen.SliceOf(genSig())
}

func genSig() gopter.Gen {
return gen.StrictStruct(reflect.TypeOf(&types.AttributedOnchainSignature{}), map[string]gopter.Gen{
"Signature": genSigBytes(),
"Signer": genSigner(),
})
}

func genSigner() gopter.Gen {
return gen.UInt8().Map(func(v uint8) commontypes.OracleID {
return commontypes.OracleID(v)
})
}

func genSigBytes() gopter.Gen {
return gen.SliceOf(gen.UInt8())
}

func genDecimalValue() gopter.Gen {
return func(p *gopter.GenParameters) *gopter.GenResult {
var sv StreamValue = ToDecimal(decimal.NewFromFloat(p.Rng.Float64()))
return gopter.NewGenResult(sv, gopter.NoShrinker)
}
}

func genQuote() gopter.Gen {
return func(p *gopter.GenParameters) *gopter.GenResult {
var sv StreamValue = &Quote{
Bid: decimal.NewFromFloat(p.Rng.Float64()),
Benchmark: decimal.NewFromFloat(p.Rng.Float64()),
Ask: decimal.NewFromFloat(p.Rng.Float64()),
}
return gopter.NewGenResult(sv, gopter.NoShrinker)
}
}

func genStreamValue() gopter.Gen {
return func(p *gopter.GenParameters) *gopter.GenResult {
switch p.Rng.Intn(3) {
case 0:
return genDecimalValue()(p)
case 1:
return genQuote()(p)
case 2:
return gopter.NewGenResult((StreamValue)(nil), gopter.NoShrinker)
}
return nil
}
}

var streamValueSliceType = reflect.TypeOf((*StreamValue)(nil)).Elem()

func genStreamValues() gopter.Gen {
return gen.SliceOf(genStreamValue(), streamValueSliceType)
}

func Test_JSONCodec(t *testing.T) {
t.Run("Encode=>Decode", func(t *testing.T) {
ctx := tests.Context(t)
Expand All @@ -34,7 +285,6 @@ func Test_JSONCodec(t *testing.T) {
encoded, err := cdc.Encode(ctx, r, llo.ChannelDefinition{})
require.NoError(t, err)

fmt.Println("encoded", string(encoded))
assert.Equal(t, `{"ConfigDigest":"0102030000000000000000000000000000000000000000000000000000000000","SeqNr":43,"ChannelID":46,"ValidAfterSeconds":44,"ObservationTimestampSeconds":45,"Values":[{"Type":0,"Value":"1"},{"Type":0,"Value":"2"},{"Type":1,"Value":"Q{Bid: 3.13, Benchmark: 4.4, Ask: 5.12}"}],"Specimen":true}`, string(encoded))

decoded, err := cdc.Decode(encoded)
Expand Down Expand Up @@ -97,8 +347,8 @@ func Test_JSONCodec(t *testing.T) {
t.Run("invalid input fails decode", func(t *testing.T) {
cdc := JSONReportCodec{}
_, err := cdc.Decode([]byte(`{}`))
assert.EqualError(t, err, "invalid ConfigDigest; cannot convert bytes to ConfigDigest. bytes have wrong length 0")
_, err = cdc.Decode([]byte(`{"ConfigDigest":"0102030000000000000000000000000000000000000000000000000000000000"}`))
assert.EqualError(t, err, "missing SeqNr")
_, err = cdc.Decode([]byte(`{"seqNr":1}`))
assert.EqualError(t, err, "invalid ConfigDigest; cannot convert bytes to ConfigDigest. bytes have wrong length 0")
})
}
2 changes: 0 additions & 2 deletions llo/onchain_config_codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ var _ OnchainConfigCodec = EVMOnchainConfigCodec{}
// returned by EncodeValueInt192.
type EVMOnchainConfigCodec struct{}

// TODO: Needs fuzz testing - MERC-6522
func (EVMOnchainConfigCodec) Decode(b []byte) (OnchainConfig, error) {
if len(b) != onchainConfigEncodedLength {
return OnchainConfig{}, fmt.Errorf("unexpected length of OnchainConfig, expected %v, got %v", onchainConfigEncodedLength, len(b))
Expand All @@ -60,7 +59,6 @@ func (EVMOnchainConfigCodec) Decode(b []byte) (OnchainConfig, error) {
return o, nil
}

// TODO: Needs fuzz testing - MERC-6522
func (EVMOnchainConfigCodec) Encode(c OnchainConfig) ([]byte, error) {
if c.Version != onchainConfigVersion {
return nil, fmt.Errorf("unexpected version of OnchainConfig, expected %v, got %v", onchainConfigVersion, c.Version)
Expand Down
Loading

0 comments on commit a90db35

Please sign in to comment.