This is an automated email from the ASF dual-hosted git repository. sjanc pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/mynewt-newtmgr.git
commit 37ca926ca7886e31384113ef37c39ae9ed3d6e33 Author: Szymon Janc <[email protected]> AuthorDate: Thu Oct 22 00:22:02 2026 +0200 nmxact/omp: Add unit tests for OMP encoding and decoding Check that EncodeOmpDgram and EncodeOmpTcp produce a confirmable CoAP PUT to /omgr with the NMP sequence as token and the NMP header in the "_h" payload field, and that the TX filter is applied. Also add a regression test for the "omitempty" fix: the struct-to-map conversion must drop empty optional fields and must not include NmpBase. For DecodeOmp, cover all accepted response codes, ignored request codes, error codes, missing or short "_h", bad CBOR, unknown NMP ids and the RX filter. Assisted-by: Claude:claude-opus-5-5 --- nmxact/omp/omp_test.go | 456 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) diff --git a/nmxact/omp/omp_test.go b/nmxact/omp/omp_test.go new file mode 100644 index 0000000..39d7155 --- /dev/null +++ b/nmxact/omp/omp_test.go @@ -0,0 +1,456 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package omp + +import ( + "bytes" + "errors" + "reflect" + "sort" + "testing" + + "github.com/runtimeco/go-coap" + "github.com/ugorji/go/codec" + + "mynewt.apache.org/newtmgr/nmxact/nmcoap" + "mynewt.apache.org/newtmgr/nmxact/nmp" +) + +func cborMap(t *testing.T, b []byte) map[string]interface{} { + t.Helper() + + m := map[string]interface{}{} + if err := codec.NewDecoderBytes(b, new(codec.CborHandle)). + Decode(&m); err != nil { + + t.Fatalf("failed to decode CBOR: %v", err) + } + return m +} + +func cborBytes(t *testing.T, v interface{}) []byte { + t.Helper() + + b := []byte{} + if err := codec.NewEncoderBytes(&b, new(codec.CborHandle)). + Encode(v); err != nil { + + t.Fatalf("failed to encode CBOR: %v", err) + } + return b +} + +func keys(m map[string]interface{}) []string { + ks := []string{} + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return ks +} + +// checkOmpReq verifies the CoAP envelope of an encoded OMP request and +// returns the decoded CBOR payload. +func checkOmpReq(t *testing.T, m coap.Message, + hdr nmp.NmpHdr) map[string]interface{} { + + t.Helper() + + if m.Code() != coap.PUT { + t.Fatalf("code = %v, want PUT", m.Code()) + } + if m.PathString() != "omgr" { + t.Fatalf("path = %q, want omgr", m.PathString()) + } + if !bytes.Equal(m.Token(), []byte{hdr.Seq}) { + t.Fatalf("token = % x, want %02x", m.Token(), hdr.Seq) + } + + pl := cborMap(t, m.Payload()) + h, ok := pl["_h"].([]byte) + if !ok { + t.Fatalf("_h missing or wrong type: %#v", pl["_h"]) + } + if !bytes.Equal(h, hdr.Bytes()) { + t.Fatalf("_h = % x, want % x", h, hdr.Bytes()) + } + + return pl +} + +func TestEncodeOmpDgram(t *testing.T) { + req := nmp.NewEchoReq() + req.Payload = "hello" + msg := req.Msg() + + data, err := EncodeOmpDgram(nil, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + m, err := coap.ParseDgramMessage(data) + if err != nil { + t.Fatalf("failed to parse CoAP: %v", err) + } + if m.Type() != coap.Confirmable { + t.Fatalf("type = %v, want Confirmable", m.Type()) + } + + pl := checkOmpReq(t, m, msg.Hdr) + if want := []string{"_h", "d"}; !reflect.DeepEqual(keys(pl), want) { + t.Fatalf("payload keys = %v, want %v", keys(pl), want) + } + if pl["d"] != "hello" { + t.Fatalf("d = %v, want hello", pl["d"]) + } +} + +func TestEncodeOmpTcp(t *testing.T) { + req := nmp.NewEchoReq() + req.Payload = "hello" + msg := req.Msg() + + data, err := EncodeOmpTcp(nil, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + m, rest, err := coap.PullTcp(data) + if err != nil { + t.Fatalf("failed to parse CoAP: %v", err) + } + if m == nil || len(rest) != 0 { + t.Fatalf("PullTcp() = %v, rest % x", m, rest) + } + + pl := checkOmpReq(t, m, msg.Hdr) + if want := []string{"_h", "d"}; !reflect.DeepEqual(keys(pl), want) { + t.Fatalf("payload keys = %v, want %v", keys(pl), want) + } +} + +// The OMP encoder builds a field map from the request struct; it must +// honour "omitempty" and must not include the NmpBase header. Regression +// test for 'nmxact: Fix "omitempty" handling in NMP requests'. +func TestEncodeOmpOmitEmpty(t *testing.T) { + tests := []struct { + name string + req nmp.NmpReq + keys []string + }{ + { + name: "image upload first chunk", + req: func() nmp.NmpReq { + r := nmp.NewImageUploadReq() + r.Len = 100 + r.DataSha = []byte{1, 2} + r.Upgrade = true + r.Data = []byte{0xaa} + return r + }(), + keys: []string{"_h", "data", "image", "len", "off", "sha", + "upgrade"}, + }, + { + name: "image upload next chunk", + req: func() nmp.NmpReq { + r := nmp.NewImageUploadReq() + r.Off = 50 + r.Data = []byte{0xbb} + return r + }(), + keys: []string{"_h", "data", "image", "off"}, + }, + { + name: "config write save only", + req: func() nmp.NmpReq { + r := nmp.NewConfigWriteReq() + r.Save = true + return r + }(), + keys: []string{"_h", "save"}, + }, + { + name: "config write name val", + req: func() nmp.NmpReq { + r := nmp.NewConfigWriteReq() + r.Name = "a/b" + r.Val = "1" + return r + }(), + keys: []string{"_h", "name", "val"}, + }, + { + name: "fs upload", + req: func() nmp.NmpReq { + r := nmp.NewFsUploadReq() + r.Name = "/f" + r.Off = 10 + r.Data = []byte{1} + return r + }(), + keys: []string{"_h", "data", "len", "name", "off"}, + }, + { + name: "reset", + req: nmp.NewResetReq(), + keys: []string{"_h"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := tt.req.Msg() + data, err := EncodeOmpDgram(nil, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m, err := coap.ParseDgramMessage(data) + if err != nil { + t.Fatalf("failed to parse CoAP: %v", err) + } + + pl := checkOmpReq(t, m, msg.Hdr) + if !reflect.DeepEqual(keys(pl), tt.keys) { + t.Fatalf("payload keys = %v, want %v", keys(pl), tt.keys) + } + }) + } +} + +type testTxFilter struct { + called int + err error +} + +func (f *testTxFilter) Filter(m coap.Message) (coap.Message, error) { + f.called++ + if f.err != nil { + return nil, f.err + } + m.SetURIQuery("filtered") + return m, nil +} + +func (f *testTxFilter) Freeze() {} +func (f *testTxFilter) Unfreeze() {} + +func TestEncodeOmpTxFilter(t *testing.T) { + f := &testTxFilter{} + data, err := EncodeOmpDgram(f, nmp.NewResetReq().Msg()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.called != 1 { + t.Fatalf("filter called %d times, want 1", f.called) + } + + m, err := coap.ParseDgramMessage(data) + if err != nil { + t.Fatalf("failed to parse CoAP: %v", err) + } + if q := m.Option(coap.URIQuery); q != "filtered" { + t.Fatalf("query = %v, want filtered", q) + } + + f = &testTxFilter{err: errors.New("boom")} + if _, err := EncodeOmpTcp(f, nmp.NewResetReq().Msg()); err == nil { + t.Fatalf("expected filter error") + } +} + +func echoRspMsg(t *testing.T, code coap.COAPCode, withHdr bool, + seq uint8) coap.Message { + + t.Helper() + + hdr := nmp.NmpHdr{ + Op: nmp.NMP_OP_WRITE_RSP, + Group: nmp.NMP_GROUP_DEFAULT, + Id: nmp.NMP_ID_DEF_ECHO, + Seq: seq, + } + + pl := map[string]interface{}{ + "r": "pong", + "rc": 0, + } + if withHdr { + pl["_h"] = hdr.Bytes() + } + + return coap.NewDgramMessage(coap.MessageParams{ + Type: coap.Acknowledgement, + Code: code, + Token: []byte{seq}, + Payload: cborBytes(t, pl), + }) +} + +func TestDecodeOmp(t *testing.T) { + codes := []coap.COAPCode{ + coap.Created, coap.Deleted, coap.Valid, coap.Changed, coap.Content, + } + + for _, c := range codes { + rsp, err := DecodeOmp(echoRspMsg(t, c, true, 0x21), nil) + if err != nil { + t.Fatalf("%v: unexpected error: %v", c, err) + } + + er, ok := rsp.(*nmp.EchoRsp) + if !ok { + t.Fatalf("%v: got %T, want *nmp.EchoRsp", c, rsp) + } + if er.Payload != "pong" || er.Hdr().Seq != 0x21 { + t.Fatalf("%v: unexpected rsp %+v hdr %+v", c, er, *er.Hdr()) + } + } +} + +func TestDecodeOmpIgnoresRequests(t *testing.T) { + for _, c := range []coap.COAPCode{coap.GET, coap.PUT, coap.POST, + coap.DELETE} { + + rsp, err := DecodeOmp(echoRspMsg(t, c, true, 1), nil) + if rsp != nil || err != nil { + t.Errorf("%v: DecodeOmp() = (%v, %v), want (nil, nil)", + c, rsp, err) + } + } +} + +func TestDecodeOmpErrors(t *testing.T) { + tests := []struct { + name string + msg coap.Message + }{ + {"error code", echoRspMsg(t, coap.NotFound, true, 1)}, + {"internal error code", echoRspMsg(t, coap.InternalServerError, + true, 1)}, + {"missing _h", echoRspMsg(t, coap.Changed, false, 1)}, + {"bad cbor", coap.NewDgramMessage(coap.MessageParams{ + Code: coap.Changed, + Payload: []byte{0xa1, 0x62}, + })}, + {"short _h", coap.NewDgramMessage(coap.MessageParams{ + Code: coap.Changed, + Payload: cborBytes(t, map[string]interface{}{ + "_h": []byte{1, 2, 3}, + }), + })}, + {"unknown nmp id", coap.NewDgramMessage(coap.MessageParams{ + Code: coap.Changed, + Payload: cborBytes(t, map[string]interface{}{ + "_h": (&nmp.NmpHdr{Op: nmp.NMP_OP_WRITE_RSP, + Group: 99, Id: 99}).Bytes(), + }), + })}, + } + + for _, tt := range tests { + rsp, err := DecodeOmp(tt.msg, nil) + if err == nil { + t.Errorf("%s: expected error, got %+v", tt.name, rsp) + } + } +} + +func TestDecodeOmpRxFilter(t *testing.T) { + // Filter is applied before the code check. + var called bool + f := nmcoap.RxFilterFunc(func(m coap.Message) (coap.Message, error) { + called = true + return echoRspMsg(t, coap.Changed, true, 5), nil + }) + + rsp, err := DecodeOmp(echoRspMsg(t, coap.NotFound, false, 5), f) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called || rsp == nil || rsp.Hdr().Seq != 5 { + t.Fatalf("unexpected rsp %+v (filter called=%v)", rsp, called) + } + + // Filter error is propagated. + f = nmcoap.RxFilterFunc(func(m coap.Message) (coap.Message, error) { + return nil, errors.New("boom") + }) + if _, err := DecodeOmp(echoRspMsg(t, coap.Changed, true, 5), + f); err == nil { + + t.Fatalf("expected filter error") + } + + // Requests are ignored before the filter runs. + called = false + f = nmcoap.RxFilterFunc(func(m coap.Message) (coap.Message, error) { + called = true + return m, nil + }) + if rsp, err := DecodeOmp(echoRspMsg(t, coap.PUT, true, 5), + f); rsp != nil || err != nil || called { + + t.Fatalf("request: rsp=%v err=%v called=%v", rsp, err, called) + } +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + // A device answering an OMP request echoes the request header with the + // response op. + req := nmp.NewEchoReq() + req.Payload = "x" + msg := req.Msg() + + data, err := EncodeOmpTcp(nil, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m, _, err := coap.PullTcp(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pl := cborMap(t, m.Payload()) + hdr, err := nmp.DecodeNmpHdr(pl["_h"].([]byte)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + hdr.Op = nmp.NMP_OP_WRITE_RSP + + rm := coap.NewTcpMessage(coap.MessageParams{ + Code: coap.Changed, + Token: m.Token(), + Payload: cborBytes(t, map[string]interface{}{ + "_h": hdr.Bytes(), + "r": pl["d"], + }), + }) + + rsp, err := DecodeOmp(rm, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if er := rsp.(*nmp.EchoRsp); er.Payload != "x" || + er.Hdr().Seq != msg.Hdr.Seq { + + t.Fatalf("unexpected rsp %+v", er) + } +}
