mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-17 00:44:20 +00:00
116 lines
4.1 KiB
Go
116 lines
4.1 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
meshcore "github.com/meshcore-go/meshcore-go"
|
|
|
|
"github.com/jleight/meshtender/internal/auth"
|
|
"github.com/jleight/meshtender/internal/identity"
|
|
"github.com/jleight/meshtender/internal/store"
|
|
)
|
|
|
|
// TestConsoleWebSocketOutlivesServerReadTimeout pins the one thing audit O1's server
|
|
// timeouts could plausibly have broken: a long-lived console session.
|
|
//
|
|
// http.Server's ReadTimeout/WriteTimeout/IdleTimeout become deadlines on the
|
|
// underlying connection, which looks like it should sever an upgraded WebSocket the
|
|
// moment one elapses. It doesn't — net/http clears the deadline when a handler hijacks
|
|
// the connection (hijackLocked calls rwc.SetDeadline(time.Time{})), so the socket
|
|
// inherits nothing and is bounded only by consoleIdleTimeout and the shutdown drain.
|
|
//
|
|
// That behaviour is load-bearing but belongs to the standard library, not to this
|
|
// codebase, so it's worth an explicit test: it would catch a future WebSocket library
|
|
// that re-arms deadlines after hijacking, or a change in Go's behaviour, either of
|
|
// which would silently start cutting console sessions mid-command.
|
|
//
|
|
// The server here uses a deliberately tiny 250ms ReadTimeout rather than the
|
|
// production 30s so the assertion takes a second instead of half a minute.
|
|
func TestConsoleWebSocketOutlivesServerReadTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
st, ctx := coreStore(t)
|
|
|
|
masterKey := testMasterKey
|
|
idSvc, err := identity.LoadOrCreate(ctx, st, masterKey)
|
|
if err != nil {
|
|
t.Fatalf("identity: %v", err)
|
|
}
|
|
authSvc, err := auth.New(st, st.Pool(), testAuthConfig())
|
|
if err != nil {
|
|
t.Fatalf("auth: %v", err)
|
|
}
|
|
srv, err := NewServer(st, authSvc, idSvc, testConfig())
|
|
if err != nil {
|
|
t.Fatalf("server: %v", err)
|
|
}
|
|
|
|
// The production timeouts live in main.go; mirror their SHAPE here with a tiny
|
|
// read deadline so the hazard reproduces in milliseconds instead of 30 seconds.
|
|
const readTimeout = 250 * time.Millisecond
|
|
ts := httptest.NewUnstartedServer(srv.Handler())
|
|
ts.Config.ReadHeaderTimeout = readTimeout
|
|
ts.Config.ReadTimeout = readTimeout
|
|
ts.Config.WriteTimeout = readTimeout
|
|
ts.Config.IdleTimeout = readTimeout
|
|
ts.Start()
|
|
defer ts.Close()
|
|
|
|
jar, _ := cookiejar.New(nil)
|
|
user := seedSession(t, ts, st, ctx, jar, "wsdeadline")
|
|
|
|
repeater, err := meshcore.GenerateLocalIdentity(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("repeater identity: %v", err)
|
|
}
|
|
rep, err := st.CreateRepeater(ctx, &store.Repeater{
|
|
OwnerID: user.ID, Name: "Deadline", PublicKeyHex: repeater.String(),
|
|
RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create repeater: %v", err)
|
|
}
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/repeaters/" + rep.PublicID + "/console/ws"
|
|
hdr := http.Header{}
|
|
var parts []string
|
|
for _, c := range jar.Cookies(mustURL(t, ts.URL)) {
|
|
parts = append(parts, c.Name+"="+c.Value)
|
|
}
|
|
if len(parts) > 0 {
|
|
hdr.Set("Cookie", strings.Join(parts, "; "))
|
|
}
|
|
|
|
dctx, dcancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer dcancel()
|
|
ws, _, err := websocket.Dial(dctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr})
|
|
if err != nil {
|
|
t.Fatalf("ws dial: %v", err)
|
|
}
|
|
defer ws.Close(websocket.StatusNormalClosure, "")
|
|
|
|
// Idle well past every deadline above, then use the socket. A surviving write and
|
|
// read prove the connection wasn't torn down by an inherited request deadline.
|
|
time.Sleep(4 * readTimeout)
|
|
|
|
wctx, wcancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer wcancel()
|
|
if err := ws.Write(wctx, websocket.MessageText, []byte(`{"type":"ready"}`)); err != nil {
|
|
t.Fatalf("write after %v idle failed — the socket inherited the server's read/write "+
|
|
"deadline instead of clearing it: %v", 4*readTimeout, err)
|
|
}
|
|
// The handler answers a "ready" with its tuning/status frames, so a successful read
|
|
// confirms the session is still live end to end, not just that the write buffered.
|
|
if _, _, err := ws.Read(wctx); err != nil {
|
|
t.Fatalf("read after %v idle failed — the session did not survive the server's "+
|
|
"request deadlines: %v", 4*readTimeout, err)
|
|
}
|
|
}
|