Skip to content

Commit de54d3a

Browse files
Marko Petzoldclaude
andcommitted
coverage: E2EE pack/unpack, sessionKillByAuthrole, ConnectRawSocketPeer
Three small focused test additions targeting previously-uncovered functions identified by the cross-package coverage report. None of them require source changes; all are pure pinning tests. client/export_e2ee_test.go (new): - packE2EEPayload: 0% → 90.9% - unpackE2EEPayload: 0% → 100% Sister tests to the PPT pack/unpack coverage in export_ppt_test.go. Covers CBOR round-trip, invalid-serializer errors (json/msgpack/unknown/empty), and corrupt-bytes deserialization error. router/sessionkill_test.go: - sessionKillByAuthrole: 0% → 91.7% Three tests mirroring TestSessionKillByAuthid: - happy path: caller's own authrole excluded; victims get GOODBYE with reason+message; RESULT count is 2. - missing authrole argument → ErrNoSuchSession. - invalid reason URI → ErrInvalidURI. transport/rawsocket_connect_test.go (new): - ConnectRawSocketPeer: 34.3% → 91.4% Six tests covering the error paths: - bad network type → checkNetworkType error - bad serialization value → getProtoByte error - dial failure (no listener) → DialContext error - dial succeeds, server speaks garbage → clientHandshake error - TLS handshake against plain-TCP server → tls.Handshake error - TLS handshake while context expires → ctx.Err() Race detector clean over count=10. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49e3f9a commit de54d3a

3 files changed

Lines changed: 343 additions & 0 deletions

File tree

client/export_e2ee_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package client
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/gammazero/nexus/v3/transport/serialize"
9+
"github.com/gammazero/nexus/v3/wamp"
10+
)
11+
12+
// TestPackE2EEPayloadCBORRoundTrip pins the End-to-End-Encrypted
13+
// payload pack/unpack helpers (sister of the PPT helpers tested in
14+
// export_ppt_test.go). E2EE is hard-coded to the CBOR serializer
15+
// (only entry in E2eeSerializers); this test verifies the wrap-bytes-
16+
// then-unwrap-bytes round-trip.
17+
func TestPackE2EEPayloadCBORRoundTrip(t *testing.T) {
18+
origArgs := wamp.List{"opaque", "second", true}
19+
origKwargs := wamp.Dict{"k1": "v1", "k2": "v2"}
20+
21+
opts := wamp.Dict{wamp.OptPPTSerializer: "cbor"}
22+
packed, err := packE2EEPayload(opts, origArgs, origKwargs)
23+
require.NoError(t, err)
24+
require.Len(t, packed, 1, "E2EE pack must produce a single-element args list")
25+
bin, ok := packed[0].([]byte)
26+
require.True(t, ok, "E2EE pack must produce []byte payload, got %T", packed[0])
27+
require.NotEmpty(t, bin)
28+
29+
details := wamp.Dict{wamp.OptPPTSerializer: "cbor"}
30+
gotArgs, gotKwargs, err := unpackE2EEPayload(details, packed)
31+
require.NoError(t, err)
32+
require.Equal(t, origArgs, gotArgs)
33+
require.Equal(t, origKwargs, gotKwargs)
34+
}
35+
36+
// TestPackE2EEPayloadInvalidSerializer pins the error path: an
37+
// unsupported ppt_serializer value (anything not in the
38+
// E2eeSerializers whitelist) returns ErrPPTSerializerInvalid.
39+
func TestPackE2EEPayloadInvalidSerializer(t *testing.T) {
40+
cases := []struct {
41+
name string
42+
serializer string
43+
}{
44+
{"json_not_allowed_for_e2ee", "json"},
45+
{"msgpack_not_allowed_for_e2ee", "msgpack"},
46+
{"unknown_serializer", "x_unknown"},
47+
{"empty_string", ""},
48+
}
49+
for _, tc := range cases {
50+
t.Run(tc.name, func(t *testing.T) {
51+
opts := wamp.Dict{wamp.OptPPTSerializer: tc.serializer}
52+
packed, err := packE2EEPayload(opts, wamp.List{"x"}, nil)
53+
require.ErrorIs(t, err, ErrPPTSerializerInvalid)
54+
require.Nil(t, packed)
55+
})
56+
}
57+
}
58+
59+
// TestUnpackE2EEPayloadInvalidSerializer pins the inverse error
60+
// path on the receive side.
61+
func TestUnpackE2EEPayloadInvalidSerializer(t *testing.T) {
62+
details := wamp.Dict{wamp.OptPPTSerializer: "json"}
63+
args, kwargs, err := unpackE2EEPayload(details, wamp.List{[]byte("doesn't matter")})
64+
require.ErrorIs(t, err, ErrPPTSerializerInvalid)
65+
require.Nil(t, args)
66+
require.Nil(t, kwargs)
67+
}
68+
69+
// TestUnpackE2EEPayloadCorruptBytes pins the deserialization error
70+
// path: a well-typed []byte payload that isn't valid CBOR returns
71+
// ErrSerialization.
72+
func TestUnpackE2EEPayloadCorruptBytes(t *testing.T) {
73+
// A few definitely-not-cbor bytes.
74+
garbage := []byte{0xff, 0xff, 0xff, 0xff}
75+
// Sanity: the CBOR serializer rejects this for our PassthruPayload type.
76+
var pp wamp.PassthruPayload
77+
require.Error(t, (&serialize.CBORSerializer{}).DeserializeDataItem(garbage, &pp),
78+
"sanity: garbage must not deserialize as PassthruPayload")
79+
80+
details := wamp.Dict{wamp.OptPPTSerializer: "cbor"}
81+
args, kwargs, err := unpackE2EEPayload(details, wamp.List{garbage})
82+
require.ErrorIs(t, err, ErrSerialization)
83+
require.Nil(t, args)
84+
require.Nil(t, kwargs)
85+
}

router/sessionkill_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,103 @@ func TestSessionKillByAuthid(t *testing.T) {
158158
})
159159
}
160160

161+
func TestSessionKillByAuthrole(t *testing.T) {
162+
synctest.Test(t, func(t *testing.T) {
163+
r := newTestRouter(t)
164+
165+
cli1 := testClient(t, r) // caller — all three default to authrole "anonymous"
166+
cli2 := testClient(t, r)
167+
cli3 := testClient(t, r)
168+
169+
callerAuthrole, _ := wamp.AsString(cli1.Details["authrole"])
170+
require.NotEmpty(t, callerAuthrole, "test setup should give clients a non-empty authrole")
171+
172+
reason := wamp.URI("foo.bar.baz")
173+
message := "kicked by authrole"
174+
175+
// Kill all sessions with this authrole. The caller's own session
176+
// has the same authrole and must be excluded from the kill set.
177+
cli1.Send() <- &wamp.Call{
178+
Request: wamp.GlobalID(),
179+
Procedure: wamp.MetaProcSessionKillByAuthrole,
180+
Arguments: wamp.List{callerAuthrole},
181+
ArgumentsKw: wamp.Dict{"reason": reason, "message": message},
182+
}
183+
184+
msg, err := wamp.RecvTimeout(cli1, time.Second)
185+
require.NoError(t, err)
186+
res, ok := msg.(*wamp.Result)
187+
require.True(t, ok, "expected RESULT, got %T", msg)
188+
// RESULT carries the count of kicked sessions (2: cli2 and cli3).
189+
count, ok := wamp.AsInt64(res.Arguments[0])
190+
require.True(t, ok, "RESULT arg[0] should be an integer count")
191+
require.Equal(t, int64(2), count)
192+
193+
// cli2 and cli3 must receive GOODBYE with the supplied reason+message.
194+
for i, victim := range []*wamp.Session{cli2, cli3} {
195+
msg, err := wamp.RecvTimeout(victim, time.Second)
196+
require.NoErrorf(t, err, "victim %d did not receive GOODBYE", i+2)
197+
g, ok := msg.(*wamp.Goodbye)
198+
require.Truef(t, ok, "victim %d expected GOODBYE, got %T", i+2, msg)
199+
require.Equal(t, reason, g.Reason)
200+
m, _ := wamp.AsString(g.Details["message"])
201+
require.Equal(t, message, m)
202+
}
203+
204+
// cli1 (the caller) must NOT have been kicked off.
205+
_, err = wamp.RecvTimeout(cli1, time.Millisecond)
206+
require.Error(t, err, "caller should be excluded from authrole-kill")
207+
})
208+
}
209+
210+
// TestSessionKillByAuthroleMissingArg pins the no-argument error
211+
// path: kill_by_authrole with no Arguments must return
212+
// wamp.error.no_such_session.
213+
func TestSessionKillByAuthroleMissingArg(t *testing.T) {
214+
synctest.Test(t, func(t *testing.T) {
215+
r := newTestRouter(t)
216+
cli := testClient(t, r)
217+
218+
cli.Send() <- &wamp.Call{
219+
Request: wamp.GlobalID(),
220+
Procedure: wamp.MetaProcSessionKillByAuthrole,
221+
// Arguments intentionally empty.
222+
}
223+
224+
msg, err := wamp.RecvTimeout(cli, time.Second)
225+
require.NoError(t, err)
226+
errMsg, ok := msg.(*wamp.Error)
227+
require.True(t, ok, "expected ERROR, got %T", msg)
228+
require.Equal(t, wamp.ErrNoSuchSession, errMsg.Error)
229+
})
230+
}
231+
232+
// TestSessionKillByAuthroleInvalidReasonURI pins the URI-validation
233+
// error path: a malformed reason URI is rejected with
234+
// wamp.error.invalid_uri.
235+
func TestSessionKillByAuthroleInvalidReasonURI(t *testing.T) {
236+
synctest.Test(t, func(t *testing.T) {
237+
r := newTestRouter(t)
238+
cli := testClient(t, r)
239+
callerAuthrole, _ := wamp.AsString(cli.Details["authrole"])
240+
241+
cli.Send() <- &wamp.Call{
242+
Request: wamp.GlobalID(),
243+
Procedure: wamp.MetaProcSessionKillByAuthrole,
244+
Arguments: wamp.List{callerAuthrole},
245+
ArgumentsKw: wamp.Dict{
246+
"reason": "not a valid uri at all",
247+
},
248+
}
249+
250+
msg, err := wamp.RecvTimeout(cli, time.Second)
251+
require.NoError(t, err)
252+
errMsg, ok := msg.(*wamp.Error)
253+
require.True(t, ok, "expected ERROR for invalid reason URI, got %T", msg)
254+
require.Equal(t, wamp.ErrInvalidURI, errMsg.Error)
255+
})
256+
}
257+
161258
func TestSessionModifyDetails(t *testing.T) {
162259
synctest.Test(t, func(t *testing.T) {
163260
r := newTestRouter(t)
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package transport_test
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"io"
7+
"log"
8+
"net"
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/gammazero/nexus/v3/transport"
15+
"github.com/gammazero/nexus/v3/transport/serialize"
16+
)
17+
18+
// TestConnectRawSocketPeerBadNetworkType pins the early validation:
19+
// ConnectRawSocketPeer rejects unsupported network types before
20+
// attempting any I/O.
21+
func TestConnectRawSocketPeerBadNetworkType(t *testing.T) {
22+
logger := log.New(io.Discard, "", 0)
23+
peer, err := transport.ConnectRawSocketPeer(t.Context(),
24+
"udp", "127.0.0.1:0", serialize.JSON, nil, logger, 0)
25+
require.Error(t, err)
26+
require.Contains(t, err.Error(), "unsupported network type")
27+
require.Nil(t, peer)
28+
}
29+
30+
// TestConnectRawSocketPeerBadSerialization pins the second validation
31+
// step: an unsupported serialization value is rejected before the dial.
32+
func TestConnectRawSocketPeerBadSerialization(t *testing.T) {
33+
logger := log.New(io.Discard, "", 0)
34+
const bogusSerialization = serialize.Serialization(99)
35+
peer, err := transport.ConnectRawSocketPeer(t.Context(),
36+
"tcp", "127.0.0.1:0", bogusSerialization, nil, logger, 0)
37+
require.Error(t, err)
38+
require.Contains(t, err.Error(), "serialization not supported")
39+
require.Nil(t, peer)
40+
}
41+
42+
// TestConnectRawSocketPeerDialFailure pins the dial-error path: the
43+
// address is well-formed but no server is listening. net.Dial
44+
// returns an error; ConnectRawSocketPeer surfaces it directly.
45+
func TestConnectRawSocketPeerDialFailure(t *testing.T) {
46+
logger := log.New(io.Discard, "", 0)
47+
// Use a closed-immediately listener to get a guaranteed-unused
48+
// port, then dial it after closing.
49+
ln, err := net.Listen("tcp", "127.0.0.1:0")
50+
require.NoError(t, err)
51+
addr := ln.Addr().String()
52+
ln.Close()
53+
54+
ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
55+
defer cancel()
56+
peer, err := transport.ConnectRawSocketPeer(ctx,
57+
"tcp", addr, serialize.JSON, nil, logger, 0)
58+
require.Error(t, err)
59+
require.Nil(t, peer)
60+
}
61+
62+
// TestConnectRawSocketPeerHandshakeFailure pins the
63+
// post-dial-pre-peer error path: dial succeeds but the server isn't
64+
// speaking RawSocket, so clientHandshake errors. The transient conn
65+
// must be closed and no peer returned.
66+
func TestConnectRawSocketPeerHandshakeFailure(t *testing.T) {
67+
logger := log.New(io.Discard, "", 0)
68+
ln, err := net.Listen("tcp", "127.0.0.1:0")
69+
require.NoError(t, err)
70+
defer ln.Close()
71+
72+
go func() {
73+
conn, aerr := ln.Accept()
74+
if aerr != nil {
75+
return
76+
}
77+
// Send 4 bytes that are NOT the RawSocket handshake reply.
78+
_, _ = conn.Write([]byte{0x00, 0x00, 0x00, 0x00})
79+
_ = conn.Close()
80+
}()
81+
82+
peer, err := transport.ConnectRawSocketPeer(t.Context(),
83+
"tcp", ln.Addr().String(), serialize.JSON, nil, logger, 0)
84+
require.Error(t, err, "handshake should fail when server doesn't speak RawSocket")
85+
require.Nil(t, peer)
86+
}
87+
88+
// TestConnectRawSocketPeerTLSHandshakeFailure pins the TLS error
89+
// path: the server accepts the TCP connect but speaks plain TCP, not
90+
// TLS — the TLS handshake fails, ConnectRawSocketPeer returns the
91+
// error and closes the underlying conn.
92+
func TestConnectRawSocketPeerTLSHandshakeFailure(t *testing.T) {
93+
logger := log.New(io.Discard, "", 0)
94+
ln, err := net.Listen("tcp", "127.0.0.1:0")
95+
require.NoError(t, err)
96+
defer ln.Close()
97+
98+
go func() {
99+
conn, aerr := ln.Accept()
100+
if aerr != nil {
101+
return
102+
}
103+
// Read whatever the client sends (the TLS ClientHello),
104+
// then close. The TLS handshake on the client side errors
105+
// when the server doesn't reply with a ServerHello.
106+
buf := make([]byte, 1024)
107+
_, _ = conn.Read(buf)
108+
_ = conn.Close()
109+
}()
110+
111+
tlsCfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec
112+
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
113+
defer cancel()
114+
peer, err := transport.ConnectRawSocketPeer(ctx,
115+
"tcp", ln.Addr().String(), serialize.JSON, tlsCfg, logger, 0)
116+
require.Error(t, err, "TLS handshake should fail against plain-TCP server")
117+
require.Nil(t, peer)
118+
}
119+
120+
// TestConnectRawSocketPeerTLSContextCancelled pins the
121+
// context-cancelled-during-TLS-handshake path: the TLS handshake
122+
// goroutine is still running when the context expires.
123+
// ConnectRawSocketPeer returns ctx.Err() and closes the conn.
124+
func TestConnectRawSocketPeerTLSContextCancelled(t *testing.T) {
125+
logger := log.New(io.Discard, "", 0)
126+
ln, err := net.Listen("tcp", "127.0.0.1:0")
127+
require.NoError(t, err)
128+
defer ln.Close()
129+
130+
// Server: accept, then NEVER send anything. The client's TLS
131+
// handshake will block reading the ServerHello until the
132+
// context fires.
133+
accepted := make(chan net.Conn, 1)
134+
go func() {
135+
conn, aerr := ln.Accept()
136+
if aerr != nil {
137+
return
138+
}
139+
accepted <- conn
140+
// Hold the connection open. The test will close it via
141+
// listener close after the context expires.
142+
<-time.After(time.Second)
143+
_ = conn.Close()
144+
}()
145+
146+
tlsCfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec
147+
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
148+
defer cancel()
149+
peer, err := transport.ConnectRawSocketPeer(ctx,
150+
"tcp", ln.Addr().String(), serialize.JSON, tlsCfg, logger, 0)
151+
require.Error(t, err, "TLS handshake should be cancelled by context")
152+
require.Nil(t, peer)
153+
154+
// Drain the accepted-conn channel so the helper goroutine doesn't
155+
// hold a reference past test end.
156+
select {
157+
case c := <-accepted:
158+
_ = c.Close()
159+
case <-time.After(100 * time.Millisecond):
160+
}
161+
}

0 commit comments

Comments
 (0)