Files
drover-go/internal/checker/checker_test.go
T
root 4b985bb7f0
Build / test (push) Failing after 29s
Build / build-windows (push) Has been skipped
internal/checker: 7-step Run orchestrator + integration tests
Public Run(ctx, cfg) <-chan Result streams diagnostic events for the seven
tests (tcp, greet, auth?, connect, udp, stun, api) wired through the
SOCKS5 primitives, STUN codec, retry classification and RU hints.

- Per-test attempt loop with running/passed/failed events, transient-only
  retries (per-attempt timeout treated as transient, parent ctx cancel as
  permanent), context-aware backoff sleep.
- Connection lifecycle: tcpConn shared across greet/auth/connect (closed
  and redialed on retry); separate udpConn2 control channel for UDP
  ASSOCIATE kept alive for the duration of the stun test.
- STUN-via-SOCKS5: builds 10-byte SOCKS5 UDP header + STUN binding
  request, decodes reply with ATYP-aware header strip (1/3/4).
- runAPI plugs SOCKS5 dial into http.Transport.DialContext; passes on
  HTTP 200 OR 401.
- Skip semantics: dependency-failed tests emit single skipped result;
  cancellation latches and propagates as cancelled-failed (current) +
  cancelled-skipped (remaining).
- Defaults applied to a copy of cfg; UseAuth=false suppresses any "auth"
  result entirely.

Tests: 10 TestRun_* covering happy/auth-rejected/all-rejected/
connect-refused/udp-unsupported/timeout-then-ok/cancelled-mid-flight/
defaults plus extractRawHex unit. Fake SOCKS5 proxy + UDP relay echoing
synthetic STUN binding success responses; httptest stub for API splice.

Combined coverage 84.3% (>=80% target). go test -race clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:08:36 +03:00

882 lines
24 KiB
Go

package checker
import (
"context"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeProxy is a test SOCKS5 server with per-scenario behaviour. It also
// optionally runs a UDP relay that echoes STUN-shaped responses crafted
// to look like Binding Success Responses with XOR-MAPPED-ADDRESS pointing
// back at the client's source IP.
//
// The TCP-side splice for the API test detects CONNECT requests targeting
// apiTargetHost:apiTargetPort and, instead of sending a synthetic reply,
// dials apiTargetAddr and bridges the two conns. This lets a real
// httptest.NewServer be used as the API endpoint.
type fakeProxy struct {
t *testing.T
addr string
scenario string
udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply
// API-passthrough hook: when a CONNECT targets this host:port,
// the proxy dials apiTargetAddr and splices the conns instead of
// sending a fake REP=00 + close.
apiTargetHost string
apiTargetPort uint16
apiTargetAddr string
// timeoutFirstAttempt stalls the first connection on greet to
// drive a timeout. Subsequent connections behave normally.
timeoutFirstAttempt atomic.Int32
}
// newFakeProxy starts a TCP listener and a UDP relay (if relevant for
// the scenario). Both are torn down via t.Cleanup.
func newFakeProxy(t *testing.T, scenario string) *fakeProxy {
t.Helper()
fp := &fakeProxy{t: t, scenario: scenario}
// Start UDP relay for scenarios that need it.
if needsUDPRelay(scenario) {
ua, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
require.NoError(t, err)
uconn, err := net.ListenUDP("udp", ua)
require.NoError(t, err)
fp.udpRelayAddr = uconn.LocalAddr().(*net.UDPAddr)
t.Cleanup(func() { _ = uconn.Close() })
go fp.runRelay(uconn)
}
// Start TCP listener.
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
fp.addr = ln.Addr().String()
if scenario == "timeout_then_ok" {
fp.timeoutFirstAttempt.Store(1)
}
t.Cleanup(func() { _ = ln.Close() })
go fp.serve(ln)
return fp
}
func needsUDPRelay(scenario string) bool {
switch scenario {
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok":
return true
default:
return false
}
}
// serve accepts connections forever until the listener is closed.
func (fp *fakeProxy) serve(ln net.Listener) {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go fp.handle(conn)
}
}
func (fp *fakeProxy) handle(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
// First-attempt-timeout scenario: read greet, then sleep past
// the per-test timeout to force a deadline error.
if fp.timeoutFirstAttempt.CompareAndSwap(1, 0) {
buf := make([]byte, 1024)
_, _ = conn.Read(buf)
time.Sleep(2 * time.Second)
return
}
br := newPeekReader(conn)
// Step 1: greeting.
greet, err := readGreeting(br)
if err != nil {
return
}
switch fp.scenario {
case "all_methods_rejected":
_, _ = conn.Write([]byte{0x05, 0xFF})
return
case "auth_rejected":
// Server picks user/pass.
_, _ = conn.Write([]byte{0x05, 0x02})
// Read auth.
_ = readAuth(br)
_, _ = conn.Write([]byte{0x01, 0x01}) // status=fail
return
}
// Method selection: scenarios that involve auth force 0x02 if
// offered; otherwise prefer 0x00.
preferAuth := fp.scenario == "happy_with_auth"
chosen := byte(0xFF)
if preferAuth {
for _, m := range greet.methods {
if m == 0x02 {
chosen = 0x02
break
}
}
}
if chosen == 0xFF {
for _, m := range greet.methods {
if m == 0x00 {
chosen = 0x00
break
}
}
}
if chosen == 0xFF {
for _, m := range greet.methods {
if m == 0x02 {
chosen = 0x02
break
}
}
}
if chosen == 0xFF {
_, _ = conn.Write([]byte{0x05, 0xFF})
return
}
_, _ = conn.Write([]byte{0x05, chosen})
if chosen == 0x02 {
if err := readAuth(br); err != nil {
return
}
_, _ = conn.Write([]byte{0x01, 0x00}) // success
}
// Step 2: read CMD request.
cmdReq, err := readSocks5Request(br)
if err != nil {
return
}
switch cmdReq.cmd {
case 0x01: // CONNECT
switch fp.scenario {
case "connect_refused":
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return
}
// API passthrough?
if fp.apiTargetHost != "" && cmdReq.host == fp.apiTargetHost && cmdReq.port == fp.apiTargetPort {
// Dial real target, splice.
target, derr := net.Dial("tcp", fp.apiTargetAddr)
if derr != nil {
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return
}
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
// Clear deadline for the splice.
_ = conn.SetDeadline(time.Time{})
_ = target.SetDeadline(time.Time{})
// Splice. We can't get already-buffered bytes back
// out of br trivially, but the client only sent the
// 7+len bytes for CONNECT and we read exactly that —
// so br has no leftover buffered bytes here.
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(target, conn); done <- struct{}{} }()
go func() { _, _ = io.Copy(conn, target); done <- struct{}{} }()
<-done
_ = target.Close()
return
}
// Default happy CONNECT.
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
// Keep conn open briefly so client doesn't see EOF before
// reading the 10-byte reply.
time.Sleep(50 * time.Millisecond)
return
case 0x03: // UDP ASSOCIATE
if fp.scenario == "udp_unsupported" {
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return
}
// Reply with our UDP relay endpoint.
ip4 := fp.udpRelayAddr.IP.To4()
if ip4 == nil {
_, _ = conn.Write([]byte{0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return
}
reply := []byte{0x05, 0x00, 0x00, 0x01,
ip4[0], ip4[1], ip4[2], ip4[3],
byte(fp.udpRelayAddr.Port >> 8), byte(fp.udpRelayAddr.Port)}
_, _ = conn.Write(reply)
// Keep TCP control channel open so the relay stays valid.
// The client will close conn when done. We just block on
// read until peer closes.
_ = conn.SetDeadline(time.Time{})
_, _ = io.Copy(io.Discard, conn)
return
default:
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return
}
}
// runRelay reads SOCKS5 UDP datagrams, parses the embedded STUN binding
// request, and replies with a synthetic Binding Success Response carrying
// XOR-MAPPED-ADDRESS = client's source.
func (fp *fakeProxy) runRelay(uconn *net.UDPConn) {
buf := make([]byte, 2048)
for {
n, src, err := uconn.ReadFromUDP(buf)
if err != nil {
return
}
if n < 10 {
continue
}
// Parse SOCKS5 UDP wrapper. Expect ATYP=01.
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
continue
}
var hdrLen int
switch buf[3] {
case 0x01:
hdrLen = 10
case 0x04:
hdrLen = 22
case 0x03:
if n < 5 {
continue
}
hdrLen = 4 + 1 + int(buf[4]) + 2
default:
continue
}
if n < hdrLen+20 {
continue
}
stunReq := buf[hdrLen:n]
// Expect a binding request.
if len(stunReq) < 20 {
continue
}
var txID [12]byte
copy(txID[:], stunReq[8:20])
// Build XOR-MAPPED-ADDRESS attribute value for src.
ip4 := src.IP.To4()
if ip4 == nil {
continue
}
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
// Build STUN Binding Success Response.
stunResp := make([]byte, 20+12) // header + 4-byte attr header + 8-byte XMA
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
binary.BigEndian.PutUint16(stunResp[2:4], 12) // attr length
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
copy(stunResp[8:20], txID[:])
// Attribute header: type, length.
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
binary.BigEndian.PutUint16(stunResp[22:24], 8)
// Value: 0, family=01, x-port, x-addr.
stunResp[24] = 0
stunResp[25] = 0x01
binary.BigEndian.PutUint16(stunResp[26:28], xport)
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
// Wrap in SOCKS5 UDP header.
out := make([]byte, 0, 10+len(stunResp))
out = append(out, 0x00, 0x00, 0x00, 0x01)
out = append(out, ip4...)
var portBuf [2]byte
binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port))
out = append(out, portBuf[:]...)
out = append(out, stunResp...)
_, _ = uconn.WriteToUDP(out, src)
}
}
// peekReader wraps net.Conn so we can read variable-length SOCKS5 frames.
type peekReader struct {
r io.Reader
}
func newPeekReader(r io.Reader) *peekReader { return &peekReader{r: r} }
func (p *peekReader) ReadFull(n int) ([]byte, error) {
buf := make([]byte, n)
if _, err := io.ReadFull(p.r, buf); err != nil {
return nil, err
}
return buf, nil
}
type greetingMsg struct {
methods []byte
}
func readGreeting(r *peekReader) (*greetingMsg, error) {
hdr, err := r.ReadFull(2)
if err != nil {
return nil, err
}
if hdr[0] != 0x05 {
return nil, fmt.Errorf("bad ver")
}
nMethods := int(hdr[1])
methods, err := r.ReadFull(nMethods)
if err != nil {
return nil, err
}
return &greetingMsg{methods: methods}, nil
}
func readAuth(r *peekReader) error {
hdr, err := r.ReadFull(2)
if err != nil {
return err
}
if hdr[0] != 0x01 {
return fmt.Errorf("bad auth ver")
}
ulen := int(hdr[1])
if _, err := r.ReadFull(ulen); err != nil {
return err
}
plenBuf, err := r.ReadFull(1)
if err != nil {
return err
}
plen := int(plenBuf[0])
if _, err := r.ReadFull(plen); err != nil {
return err
}
return nil
}
type socks5Request struct {
cmd byte
atyp byte
host string
port uint16
}
func readSocks5Request(r *peekReader) (*socks5Request, error) {
hdr, err := r.ReadFull(4)
if err != nil {
return nil, err
}
if hdr[0] != 0x05 {
return nil, fmt.Errorf("bad ver")
}
out := &socks5Request{cmd: hdr[1], atyp: hdr[3]}
switch hdr[3] {
case 0x01:
ipBuf, err := r.ReadFull(4)
if err != nil {
return nil, err
}
out.host = net.IP(ipBuf).String()
case 0x03:
lenBuf, err := r.ReadFull(1)
if err != nil {
return nil, err
}
hostBuf, err := r.ReadFull(int(lenBuf[0]))
if err != nil {
return nil, err
}
out.host = string(hostBuf)
case 0x04:
ipBuf, err := r.ReadFull(16)
if err != nil {
return nil, err
}
out.host = net.IP(ipBuf).String()
default:
return nil, fmt.Errorf("bad atyp")
}
portBuf, err := r.ReadFull(2)
if err != nil {
return nil, err
}
out.port = binary.BigEndian.Uint16(portBuf)
return out, nil
}
func methodChosen(cur, _ byte) bool { return cur != 0xFF }
// drainResults pulls every Result off ch into a slice (with a hard timeout
// so a hung implementation doesn't hang the test).
func drainResults(t *testing.T, ch <-chan Result, timeout time.Duration) []Result {
t.Helper()
var out []Result
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
select {
case r, ok := <-ch:
if !ok {
return out
}
out = append(out, r)
case <-deadline.C:
t.Fatalf("checker.Run did not finish within %s; got %d results so far: %+v", timeout, len(out), out)
}
}
}
// finalByID returns the LAST result emitted for the given test id, or zero.
func finalByID(results []Result, id string) (Result, bool) {
for i := len(results) - 1; i >= 0; i-- {
if results[i].ID == id && results[i].Status != StatusRunning {
return results[i], true
}
}
return Result{}, false
}
// hostPort splits an addr returned by net.Listener.Addr().String().
func hostPort(addr string) (string, int) {
host, p, err := net.SplitHostPort(addr)
if err != nil {
panic(err)
}
pn, err := strconv.Atoi(p)
if err != nil {
panic(err)
}
return host, pn
}
// proxyConfig builds a Config pointed at the given fakeProxy with sane
// short timeouts for tests.
func proxyConfig(fp *fakeProxy, useAuth bool) Config {
host, port := hostPort(fp.addr)
cfg := Config{
ProxyHost: host,
ProxyPort: port,
UseAuth: useAuth,
PerTestTimeout: 500 * time.Millisecond,
MaxRetries: 1,
RetryBackoff: 30 * time.Millisecond,
}
if useAuth {
cfg.ProxyLogin = "u"
cfg.ProxyPassword = "p"
}
if fp.udpRelayAddr != nil {
// no-op; relay is announced via UDP ASSOCIATE reply
_ = fp.udpRelayAddr
}
return cfg
}
// stubAPIServer starts an httptest server returning HTTP 200 with a tiny
// JSON body, plus arranges fakeProxy to splice CONNECTs targeting it.
func stubAPIServer(t *testing.T, fp *fakeProxy, status int) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = io.WriteString(w, `{"url":"wss://gateway.discord.gg"}`)
}))
t.Cleanup(srv.Close)
// Parse the test server's host:port.
host, port := hostPort(strings.TrimPrefix(srv.URL, "http://"))
fp.apiTargetHost = host
fp.apiTargetPort = uint16(port)
fp.apiTargetAddr = srv.Listener.Addr().String()
return srv.URL + "/api/v9/gateway"
}
// stubGatewayServer stands in for gateway.discord.gg:443 so the connect
// test has a real target. We don't actually speak TLS — the client's
// CONNECT only reads the 10-byte SOCKS5 reply, so as long as we send
// REP=00 the test passes. proxyConfig points DiscordGateway at this addr.
//
// We piggy-back on a TCP listener that does nothing.
func stubGatewayAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
// Just keep open; the splice will read/write nothing
// useful (the SOCKS5 reply is fake REP=00 from the
// proxy itself, not from us — see fakeProxy.handle).
go func(c net.Conn) {
defer c.Close()
_, _ = io.Copy(io.Discard, c)
}(conn)
}
}()
return ln.Addr().String()
}
func TestRun_HappyNoAuth(t *testing.T) {
fp := newFakeProxy(t, "happy_no_auth")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
cfg.StunServer = "127.0.0.1:1" // unused: we patch via direct relay; see below
// We don't actually need DNS — runStun does net.LookupIP("ip4", host).
// Use a literal IP so the resolver returns it.
cfg.StunServer = "127.0.0.1:65000"
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second)
expected := []string{"tcp", "greet", "connect", "udp", "stun", "api"}
finals := map[string]Result{}
for _, id := range expected {
r, ok := finalByID(results, id)
require.True(t, ok, "missing final result for %q in %+v", id, results)
finals[id] = r
}
for _, id := range expected {
assert.Equal(t, StatusPassed, finals[id].Status, "test %s should pass; got %+v", id, finals[id])
}
// auth must not appear (UseAuth=false).
for _, r := range results {
assert.NotEqual(t, "auth", r.ID, "auth must not be emitted when UseAuth=false")
}
// Metrics format spot-checks.
assert.Contains(t, finals["greet"].Metric, "no auth")
assert.Equal(t, "REP=00", finals["connect"].Metric)
assert.Equal(t, "HTTP 200", finals["api"].Metric)
}
func TestRun_HappyWithAuth(t *testing.T) {
fp := newFakeProxy(t, "happy_with_auth")
cfg := proxyConfig(fp, true)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
cfg.StunServer = "127.0.0.1:65000"
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second)
expected := []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api"}
for _, id := range expected {
r, ok := finalByID(results, id)
require.True(t, ok, "missing %s; results=%+v", id, results)
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
}
r, _ := finalByID(results, "auth")
assert.Equal(t, "ok", r.Metric)
}
func TestRun_AuthRejected(t *testing.T) {
fp := newFakeProxy(t, "auth_rejected")
cfg := proxyConfig(fp, true)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
cfg.StunServer = "127.0.0.1:65000"
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second)
// tcp + greet pass, auth fails.
rTCP, _ := finalByID(results, "tcp")
assert.Equal(t, StatusPassed, rTCP.Status)
rG, _ := finalByID(results, "greet")
assert.Equal(t, StatusPassed, rG.Status)
rA, ok := finalByID(results, "auth")
require.True(t, ok)
assert.Equal(t, StatusFailed, rA.Status)
assert.NotEmpty(t, rA.Hint)
for _, id := range []string{"connect", "udp", "stun", "api"} {
r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
}
}
func TestRun_AllMethodsRejected(t *testing.T) {
fp := newFakeProxy(t, "all_methods_rejected")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
cfg.StunServer = "127.0.0.1:65000"
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second)
rTCP, _ := finalByID(results, "tcp")
assert.Equal(t, StatusPassed, rTCP.Status)
rG, ok := finalByID(results, "greet")
require.True(t, ok)
assert.Equal(t, StatusFailed, rG.Status)
assert.NotEmpty(t, rG.Hint)
for _, id := range []string{"connect", "udp", "stun", "api"} {
r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
}
}
func TestRun_ConnectRefused(t *testing.T) {
fp := newFakeProxy(t, "connect_refused")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
cfg.StunServer = "127.0.0.1:65000"
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second)
rT, _ := finalByID(results, "tcp")
assert.Equal(t, StatusPassed, rT.Status)
rG, _ := finalByID(results, "greet")
assert.Equal(t, StatusPassed, rG.Status)
rC, ok := finalByID(results, "connect")
require.True(t, ok)
assert.Equal(t, StatusFailed, rC.Status)
assert.NotEmpty(t, rC.Hint)
assert.NotEmpty(t, rC.RawHex)
// udp goes through a SECOND conn → unaffected; should pass.
rU, _ := finalByID(results, "udp")
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect")
// stun depends on udp → passes too.
rS, _ := finalByID(results, "stun")
assert.Equal(t, StatusPassed, rS.Status)
// api depends on connect → skipped.
rA, _ := finalByID(results, "api")
assert.Equal(t, StatusSkipped, rA.Status)
}
func TestRun_UDPUnsupported(t *testing.T) {
fp := newFakeProxy(t, "udp_unsupported")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
cfg.StunServer = "127.0.0.1:65000"
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second)
for _, id := range []string{"tcp", "greet", "connect"} {
r, _ := finalByID(results, id)
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
}
rU, _ := finalByID(results, "udp")
require.Equal(t, StatusFailed, rU.Status)
assert.NotEmpty(t, rU.Hint)
rS, _ := finalByID(results, "stun")
assert.Equal(t, StatusSkipped, rS.Status)
rA, _ := finalByID(results, "api")
assert.Equal(t, StatusPassed, rA.Status)
}
func TestRun_TimeoutThenOK(t *testing.T) {
fp := newFakeProxy(t, "timeout_then_ok")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 401)
cfg.StunServer = "127.0.0.1:65000"
cfg.PerTestTimeout = 200 * time.Millisecond
cfg.RetryBackoff = 20 * time.Millisecond
cfg.MaxRetries = 1
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 15*time.Second)
// Find the greet results.
var greetEvents []Result
for _, r := range results {
if r.ID == "greet" {
greetEvents = append(greetEvents, r)
}
}
// Expect: running(1), failed(1), running(2), passed(2). 4 events.
require.Len(t, greetEvents, 4, "events=%+v all=%+v", greetEvents, results)
assert.Equal(t, StatusRunning, greetEvents[0].Status)
assert.Equal(t, 1, greetEvents[0].Attempt)
assert.Equal(t, StatusFailed, greetEvents[1].Status)
assert.Equal(t, 1, greetEvents[1].Attempt)
assert.Equal(t, StatusRunning, greetEvents[2].Status)
assert.Equal(t, 2, greetEvents[2].Attempt)
assert.Equal(t, StatusPassed, greetEvents[3].Status)
assert.Equal(t, 2, greetEvents[3].Attempt)
// All seven non-auth tests should ultimately pass.
for _, id := range []string{"tcp", "greet", "connect", "udp", "stun", "api"} {
r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r)
}
// API should report 401.
rA, _ := finalByID(results, "api")
assert.Equal(t, "HTTP 401", rA.Metric)
}
func TestRun_CancelledMidFlight(t *testing.T) {
fp := newFakeProxy(t, "happy_no_auth")
cfg := proxyConfig(fp, false)
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
cfg.StunServer = "127.0.0.1:65000"
ctx, cancel := context.WithCancel(context.Background())
ch := Run(ctx, cfg)
var (
results []Result
mu sync.Mutex
)
done := make(chan struct{})
go func() {
defer close(done)
for r := range ch {
mu.Lock()
results = append(results, r)
mu.Unlock()
// Cancel as soon as we see tcp pass.
if r.ID == "tcp" && r.Status == StatusPassed {
cancel()
}
}
}()
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("timed out waiting for cancelled run to finish")
}
// At least one Failed/Skipped after tcp Pass.
mu.Lock()
defer mu.Unlock()
var failed, skipped int
for _, r := range results {
switch r.Status {
case StatusFailed:
if r.Error == "cancelled" {
failed++
}
case StatusSkipped:
if r.Error == "cancelled" {
skipped++
}
}
}
// Either: one cancelled-failed + rest cancelled-skipped, OR all
// cancelled-skipped (if cancellation hit before next test even
// started). Both are acceptable.
// Without auth, 5 tests remain after tcp (greet/connect/udp/stun/api).
// Cancel may race with greet completing successfully, so accept ≥4.
assert.GreaterOrEqual(t, failed+skipped, 4, "expected at least 4 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results)
}
func TestRun_AppliesDefaults(t *testing.T) {
// Use a Config{} with only ProxyHost/Port populated; everything
// else should fall back to spec defaults.
fp := newFakeProxy(t, "happy_no_auth")
host, port := hostPort(fp.addr)
cfg := Config{
ProxyHost: host,
ProxyPort: port,
}
// Verify applyDefaults produces expected values.
out := applyDefaults(cfg)
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
assert.Equal(t, 1, out.MaxRetries)
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
assert.Equal(t, "gateway.discord.gg:443", out.DiscordGateway)
assert.Equal(t, "https://discord.com/api/v9/gateway", out.DiscordAPI)
assert.Equal(t, "stun.l.google.com:19302", out.StunServer)
// Behavioral: passing a zero Config to Run should not panic and
// should at minimum emit a TCP result. We override defaults to
// shorter values so the test isn't slow when the public Discord
// targets are unreachable.
cfg.PerTestTimeout = 200 * time.Millisecond
cfg.RetryBackoff = 20 * time.Millisecond
cfg.DiscordGateway = stubGatewayAddr(t)
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
cfg.StunServer = "127.0.0.1:65000"
ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second)
rT, ok := finalByID(results, "tcp")
require.True(t, ok)
assert.Equal(t, StatusPassed, rT.Status)
}
func TestRun_NegativeRetryClamped(t *testing.T) {
cfg := Config{MaxRetries: -5, RetryBackoff: -1 * time.Second, PerTestTimeout: -1 * time.Second}
out := applyDefaults(cfg)
// Spec: MaxRetries < 0 → 0. But our default for "not set" is 1.
// We treat <0 as 0, then bump 0→1 (default for zero).
// Either 0 or 1 is acceptable per spec wording; we settled on 1.
assert.True(t, out.MaxRetries == 0 || out.MaxRetries == 1)
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
}
func TestExtractRawHex(t *testing.T) {
cases := []struct {
in, want string
}{
{"socks5: bad version (raw=05ff)", "05ff"},
{"socks5: bad version (raw=DEADBEEF)", "DEADBEEF"},
{"no raw here", ""},
{"", ""},
}
for _, c := range cases {
assert.Equal(t, c.want, extractRawHex(c.in), "input=%q", c.in)
}
}