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 26b4cf3f61cd25b1334fa481de0439bb7e14a858 Author: Szymon Janc <[email protected]> AuthorDate: Thu Oct 22 00:23:21 2026 +0200 nmxact/nmcoap: Add unit tests for reassembly, listeners and messages Cover the CoAP-TCP Reassembler (single, fragmented with extended length, recovery after an invalid message), the Receiver for both UDP and TCP, listener criteria comparison, matching (including the nil/empty token equivalence from "treat 0-length and nil tokens as equal") and sort order, ParseOp, ObserveCode.Spec and CreateMsg (path, URI query split, observe option, token, TCP vs datagram). Assisted-by: Claude:claude-opus-5-5 --- nmxact/nmcoap/frag_test.go | 161 ++++++++++++++++++++++++++ nmxact/nmcoap/listener_test.go | 200 ++++++++++++++++++++++++++++++++ nmxact/nmcoap/nmcoap_test.go | 251 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 612 insertions(+) diff --git a/nmxact/nmcoap/frag_test.go b/nmxact/nmcoap/frag_test.go new file mode 100644 index 0000000..7a3a3ff --- /dev/null +++ b/nmxact/nmcoap/frag_test.go @@ -0,0 +1,161 @@ +/** + * 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 nmcoap + +import ( + "bytes" + "testing" + + "github.com/runtimeco/go-coap" +) + +// Invalid CoAP-TCP message: 1-byte body containing a reserved option +// delta (15) that is not the payload marker. +var garbageTcp = []byte{0x10, 0x00, 0xf0} + +func tcpMsg(t *testing.T, token []byte, path string, + payload []byte) []byte { + + t.Helper() + + m := coap.NewTcpMessage(coap.MessageParams{ + Type: coap.Confirmable, + Code: coap.Content, + Token: token, + Payload: payload, + }) + if path != "" { + m.SetPathString(path) + } + + b, err := m.MarshalBinary() + if err != nil { + t.Fatalf("failed to encode CoAP: %v", err) + } + return b +} + +func checkTcpMsg(t *testing.T, m *coap.TcpMessage, token []byte, + payload []byte) { + + t.Helper() + + if m == nil { + t.Fatalf("expected a message, got nil") + } + if !bytes.Equal(m.Token(), token) { + t.Fatalf("token = % x, want % x", m.Token(), token) + } + if !bytes.Equal(m.Payload(), payload) { + t.Fatalf("payload = % x, want % x", m.Payload(), payload) + } +} + +func TestReassemblerSingle(t *testing.T) { + pl := []byte("hello world") + b := tcpMsg(t, []byte{1, 2}, "omgr", pl) + + r := NewReassembler() + m := r.RxFrag(b) + checkTcpMsg(t, m, []byte{1, 2}, pl) + if m.PathString() != "omgr" { + t.Fatalf("path = %q, want omgr", m.PathString()) + } +} + +func TestReassemblerFragmented(t *testing.T) { + // Large enough to use the extended length field. + pl := bytes.Repeat([]byte{0x5a}, 300) + b := tcpMsg(t, []byte{7}, "omgr", pl) + + for _, fragSz := range []int{1, 2, 7, 20, len(b) - 1} { + r := NewReassembler() + var m *coap.TcpMessage + for off := 0; off < len(b); off += fragSz { + end := off + fragSz + if end > len(b) { + end = len(b) + } + + m = r.RxFrag(b[off:end]) + if end < len(b) && m != nil { + t.Fatalf("fragSz=%d: message returned early at off %d", + fragSz, off) + } + } + checkTcpMsg(t, m, []byte{7}, pl) + } +} + +func TestReassemblerGarbageRecovery(t *testing.T) { + r := NewReassembler() + if m := r.RxFrag(garbageTcp); m != nil { + t.Fatalf("garbage produced a message: %v", m) + } + + // The invalid data must have been dropped; the next valid message is + // received normally. + pl := []byte{1, 2, 3} + m := r.RxFrag(tcpMsg(t, []byte{9}, "", pl)) + checkTcpMsg(t, m, []byte{9}, pl) +} + +func TestReceiverUdp(t *testing.T) { + rx := NewReceiver(false) + + m := coap.NewDgramMessage(coap.MessageParams{ + Type: coap.Confirmable, + Code: coap.Changed, + MessageID: 0x1234, + Token: []byte{4}, + Payload: []byte("x"), + }) + b, err := m.MarshalBinary() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := rx.Rx(b) + if got == nil { + t.Fatalf("expected message") + } + if got.Code() != coap.Changed || got.MessageID() != 0x1234 || + !bytes.Equal(got.Token(), []byte{4}) { + + t.Fatalf("unexpected message %+v", got) + } + + if got := rx.Rx([]byte{0x00}); got != nil { + t.Fatalf("garbage produced a message: %v", got) + } +} + +func TestReceiverTcp(t *testing.T) { + rx := NewReceiver(true) + b := tcpMsg(t, []byte{3}, "a/b", []byte("p")) + + if got := rx.Rx(b[:2]); got != nil { + t.Fatalf("partial message returned: %v", got) + } + got := rx.Rx(b[2:]) + if got == nil || got.PathString() != "a/b" { + t.Fatalf("unexpected message %v", got) + } +} diff --git a/nmxact/nmcoap/listener_test.go b/nmxact/nmcoap/listener_test.go new file mode 100644 index 0000000..e815540 --- /dev/null +++ b/nmxact/nmcoap/listener_test.go @@ -0,0 +1,200 @@ +/** + * 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 nmcoap + +import ( + "testing" + "time" + + "github.com/runtimeco/go-coap" +) + +func sign(i int) int { + switch { + case i < 0: + return -1 + case i > 0: + return 1 + default: + return 0 + } +} + +func TestCompareMsgCriteria(t *testing.T) { + tests := []struct { + a, b MsgCriteria + want int + }{ + {MsgCriteria{}, MsgCriteria{}, 0}, + {MsgCriteria{Path: "a"}, MsgCriteria{Path: "a"}, 0}, + {MsgCriteria{Path: "a"}, MsgCriteria{Path: "b"}, -1}, + {MsgCriteria{Path: "b"}, MsgCriteria{Path: "a"}, 1}, + {MsgCriteria{Path: ""}, MsgCriteria{Path: "a"}, -1}, + + // Path is the primary key. + {MsgCriteria{Path: "a", Token: []byte{9}}, + MsgCriteria{Path: "b", Token: []byte{1}}, -1}, + + {MsgCriteria{Token: []byte{1}}, MsgCriteria{Token: []byte{1}}, 0}, + {MsgCriteria{Token: []byte{1}}, MsgCriteria{Token: []byte{2}}, -1}, + {MsgCriteria{Token: []byte{2}}, MsgCriteria{Token: []byte{1}}, 1}, + {MsgCriteria{Token: []byte{1}}, MsgCriteria{Token: []byte{1, 0}}, + -1}, + + // A nil token is a wildcard and sorts before any concrete token, + // including an empty one. + {MsgCriteria{Token: nil}, MsgCriteria{Token: []byte{1}}, -1}, + {MsgCriteria{Token: []byte{1}}, MsgCriteria{Token: nil}, 1}, + {MsgCriteria{Token: nil}, MsgCriteria{Token: []byte{}}, -1}, + {MsgCriteria{Token: []byte{}}, MsgCriteria{Token: []byte{}}, 0}, + } + + for _, tt := range tests { + got := sign(CompareMsgCriteria(tt.a, tt.b)) + if got != tt.want { + t.Errorf("Compare(%s, %s) = %d, want %d", + tt.a.String(), tt.b.String(), got, tt.want) + } + } +} + +func TestMatchMsgCriteria(t *testing.T) { + tests := []struct { + name string + listen MsgCriteria + msg MsgCriteria + expected bool + }{ + {"wildcard", MsgCriteria{}, + MsgCriteria{Path: "x", Token: []byte{1}}, true}, + {"path match", MsgCriteria{Path: "x"}, + MsgCriteria{Path: "x", Token: []byte{1}}, true}, + {"path mismatch", MsgCriteria{Path: "x"}, + MsgCriteria{Path: "y"}, false}, + {"token match", MsgCriteria{Token: []byte{1, 2}}, + MsgCriteria{Path: "x", Token: []byte{1, 2}}, true}, + {"token mismatch", MsgCriteria{Token: []byte{1, 2}}, + MsgCriteria{Token: []byte{1, 3}}, false}, + {"token missing", MsgCriteria{Token: []byte{1}}, + MsgCriteria{}, false}, + {"path and token", MsgCriteria{Path: "x", Token: []byte{1}}, + MsgCriteria{Path: "x", Token: []byte{1}}, true}, + {"path ok token bad", MsgCriteria{Path: "x", Token: []byte{1}}, + MsgCriteria{Path: "x", Token: []byte{2}}, false}, + + // A 0-length token and a nil token are equal ("nmxact: treat + // 0-length and nil tokens as equal"). + {"empty listener nil msg", MsgCriteria{Token: []byte{}}, + MsgCriteria{Token: nil}, true}, + {"empty listener empty msg", MsgCriteria{Token: []byte{}}, + MsgCriteria{Token: []byte{}}, true}, + {"empty listener token msg", MsgCriteria{Token: []byte{}}, + MsgCriteria{Token: []byte{1}}, false}, + } + + for _, tt := range tests { + if got := MatchMsgCriteria(tt.listen, tt.msg); got != tt.expected { + t.Errorf("%s: Match(%s, %s) = %v, want %v", tt.name, + tt.listen.String(), tt.msg.String(), got, tt.expected) + } + } +} + +func TestSortListeners(t *testing.T) { + wild := NewListener(MsgCriteria{}) + pathA := NewListener(MsgCriteria{Path: "a"}) + pathATok1 := NewListener(MsgCriteria{Path: "a", Token: []byte{1}}) + pathATok2 := NewListener(MsgCriteria{Path: "a", Token: []byte{2}}) + pathB := NewListener(MsgCriteria{Path: "b"}) + tok1 := NewListener(MsgCriteria{Token: []byte{1}}) + + ls := []*Listener{wild, pathATok1, tok1, pathB, pathA, pathATok2} + SortListeners(ls) + + want := []*Listener{pathB, pathATok2, pathATok1, pathA, tok1, wild} + for i := range want { + if ls[i] != want[i] { + for j, l := range ls { + t.Logf("[%d] %s", j, l.Criteria.String()) + } + t.Fatalf("index %d: got %s, want %s", i, + ls[i].Criteria.String(), want[i].Criteria.String()) + } + } + + // After sorting, the first matching listener is the most specific one. + msg := MsgCriteria{Path: "a", Token: []byte{1}} + for _, l := range ls { + if MatchMsgCriteria(l.Criteria, msg) { + if l != pathATok1 { + t.Fatalf("first match = %s, want %s", + l.Criteria.String(), pathATok1.Criteria.String()) + } + break + } + } +} + +func TestMsgCriteriaString(t *testing.T) { + tests := []struct { + mc MsgCriteria + want string + }{ + {MsgCriteria{}, "token=nil path=nil"}, + {MsgCriteria{Token: []byte{0xab, 0x01}, Path: "omgr"}, + "token=ab01 path=omgr"}, + {MsgCriteria{Token: []byte{}}, "token= path=nil"}, + } + + for _, tt := range tests { + if got := tt.mc.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + } +} + +func TestCriteriaFromMsg(t *testing.T) { + m := coap.NewDgramMessage(coap.MessageParams{ + Code: coap.Content, + Token: []byte{5, 6}, + }) + m.SetPathString("/a/b") + + mc := CriteriaFromMsg(m) + if mc.Path != "a/b" || string(mc.Token) != "\x05\x06" { + t.Fatalf("unexpected criteria %s", mc.String()) + } +} + +func TestListenerClose(t *testing.T) { + l := NewListener(MsgCriteria{}) + tmo := l.AfterTimeout(time.Hour) + l.Close() + + if _, ok := <-l.RspChan; ok { + t.Fatalf("RspChan not closed") + } + if _, ok := <-l.ErrChan; ok { + t.Fatalf("ErrChan not closed") + } + if _, ok := <-tmo; ok { + t.Fatalf("timeout chan not closed") + } +} diff --git a/nmxact/nmcoap/nmcoap_test.go b/nmxact/nmcoap/nmcoap_test.go new file mode 100644 index 0000000..494eb2b --- /dev/null +++ b/nmxact/nmcoap/nmcoap_test.go @@ -0,0 +1,251 @@ +/** + * 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 nmcoap + +import ( + "bytes" + "testing" + + "github.com/runtimeco/go-coap" +) + +func TestParseOp(t *testing.T) { + tests := []struct { + in string + want coap.COAPCode + }{ + {"get", coap.GET}, + {"GET", coap.GET}, + {"Put", coap.PUT}, + {"post", coap.POST}, + {"DeLeTe", coap.DELETE}, + } + + for _, tt := range tests { + got, err := ParseOp(tt.in) + if err != nil { + t.Errorf("ParseOp(%q): unexpected error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("ParseOp(%q) = %v, want %v", tt.in, got, tt.want) + } + } + + for _, bad := range []string{"", "fetch", "gett", " get"} { + if _, err := ParseOp(bad); err == nil { + t.Errorf("ParseOp(%q): expected error", bad) + } + } +} + +func TestObserveCodeSpec(t *testing.T) { + tests := []struct { + o ObserveCode + want int + }{ + {OBSERVE_NONE, -1}, + {OBSERVE_START, 0}, + {OBSERVE_STOP, 1}, + {ObserveCode(42), -1}, + } + + for _, tt := range tests { + if got := tt.o.Spec(); got != tt.want { + t.Errorf("ObserveCode(%d).Spec() = %d, want %d", + int(tt.o), got, tt.want) + } + } +} + +func TestNextMessageId(t *testing.T) { + a := NextMessageId() + b := NextMessageId() + if b != a+1 { + t.Fatalf("NextMessageId() = %d after %d, want %d", b, a, a+1) + } +} + +func TestValidateToken(t *testing.T) { + for n := 0; n <= 8; n++ { + if err := validateToken(make([]byte, n)); err != nil { + t.Errorf("len=%d: unexpected error: %v", n, err) + } + } + if err := validateToken(make([]byte, 9)); err == nil { + t.Errorf("len=9: expected error") + } +} + +// encodeDecode encodes m and parses it back with the matching transport +// parser. +func encodeDecode(t *testing.T, isTcp bool, m coap.Message) coap.Message { + t.Helper() + + b, err := Encode(m) + if err != nil { + t.Fatalf("Encode(): %v", err) + } + + if isTcp { + tm, rest, err := coap.PullTcp(b) + if err != nil || tm == nil || len(rest) != 0 { + t.Fatalf("PullTcp() = %v, % x, %v", tm, rest, err) + } + return tm + } + + dm, err := coap.ParseDgramMessage(b) + if err != nil { + t.Fatalf("ParseDgramMessage(): %v", err) + } + return dm +} + +func TestCreateMsg(t *testing.T) { + tests := []struct { + name string + mp MsgParams + path string + query []string + observe interface{} + }{ + { + name: "plain", + mp: MsgParams{Code: coap.GET, Uri: "/a/b", + Token: []byte{1, 2}}, + path: "a/b", + }, + { + name: "query", + mp: MsgParams{Code: coap.PUT, Uri: "res?x=1&y=2", + Token: []byte{3}, Payload: []byte("pl")}, + path: "res", + query: []string{"x=1&y=2"}, + }, + { + name: "question mark in query", + mp: MsgParams{Code: coap.POST, Uri: "res?a?b", + Token: []byte{4}}, + path: "res", + query: []string{"a?b"}, + }, + { + name: "observe start", + mp: MsgParams{Code: coap.GET, Uri: "obs", + Observe: OBSERVE_START, Token: []byte{5}}, + path: "obs", + observe: uint32(0), + }, + { + name: "observe stop", + mp: MsgParams{Code: coap.GET, Uri: "obs", + Observe: OBSERVE_STOP, Token: []byte{6}}, + path: "obs", + observe: uint32(1), + }, + } + + for _, tt := range tests { + for _, isTcp := range []bool{false, true} { + m, err := CreateMsg(isTcp, tt.mp) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tt.name, err) + } + + _, gotTcp := m.(*coap.TcpMessage) + if gotTcp != isTcp { + t.Fatalf("%s: got %T, isTcp=%v", tt.name, m, isTcp) + } + + m = encodeDecode(t, isTcp, m) + + if m.Code() != tt.mp.Code { + t.Errorf("%s: code = %v, want %v", tt.name, m.Code(), + tt.mp.Code) + } + if !bytes.Equal(m.Token(), tt.mp.Token) { + t.Errorf("%s: token = % x, want % x", tt.name, m.Token(), + tt.mp.Token) + } + if !bytes.Equal(m.Payload(), tt.mp.Payload) { + t.Errorf("%s: payload = % x, want % x", tt.name, + m.Payload(), tt.mp.Payload) + } + if m.PathString() != tt.path { + t.Errorf("%s: path = %q, want %q", tt.name, + m.PathString(), tt.path) + } + + var q []string + for _, o := range m.Options(coap.URIQuery) { + q = append(q, o.(string)) + } + if len(q) != len(tt.query) || + (len(q) > 0 && q[0] != tt.query[0]) { + + t.Errorf("%s: query = %v, want %v", tt.name, q, tt.query) + } + + if obs := m.Option(coap.Observe); obs != tt.observe { + t.Errorf("%s: observe = %#v, want %#v", tt.name, obs, + tt.observe) + } + } + } +} + +func TestCreateMsgDefaultToken(t *testing.T) { + m1, err := CreateMsg(false, MsgParams{Code: coap.GET, Uri: "a"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m2, err := CreateMsg(false, MsgParams{Code: coap.GET, Uri: "a"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(m1.Token()) != 1 || len(m2.Token()) != 1 { + t.Fatalf("tokens = % x, % x; want 1 byte each", + m1.Token(), m2.Token()) + } + if m2.Token()[0] != m1.Token()[0]+1 { + t.Fatalf("tokens = % x, % x; want consecutive", + m1.Token(), m2.Token()) + } +} + +func TestRxFilterFunc(t *testing.T) { + in := coap.NewDgramMessage(coap.MessageParams{Code: coap.GET}) + out := coap.NewDgramMessage(coap.MessageParams{Code: coap.PUT}) + + f := RxFilterFunc(func(m coap.Message) (coap.Message, error) { + if m != in { + t.Fatalf("filter got unexpected message") + } + return out, nil + }) + + var rf RxMsgFilter = f + got, err := rf.Filter(in) + if err != nil || got != out { + t.Fatalf("Filter() = %v, %v", got, err) + } +}
