Files
drover-go/internal/checker/checker_test.go
T
root 168596bcb5
Build / test (push) Failing after 33s
Build / build-windows (push) Has been skipped
Release / release (push) Failing after 3m22s
sboxrun: domain+IP-CIDR rules + remove voice-quality test
Three follow-up fixes after the WinDivert→sing-box pivot:

1. Discord updater now routes through upstream. Previously only the
   process-name rule matched, but sing-box's TUN-side process
   detection on Windows mis-attributes the in-process Rust updater's
   TLS connection to e.g. steam.exe — the connection went direct and
   hit RKN block. Adding domain_suffix + ip_cidr rules for Cloudflare
   (162.159/16, 104.16/13, 172.64/13) and Fastly (199.232/16,
   151.101/16) catches updates.discord.com regardless of which PID
   the kernel claims sent it. Verified via curl through mihomo:
   updates.discord.com responds 400 in 393ms (i.e. TLS handshake
   succeeds, only the path is wrong — proves the routing reaches it).

2. DiscordSystemHelper.exe added to TargetProcs alongside Update.exe
   (modern Discord builds use it for elevated updates).

3. UDP voice quality test removed from the checker. The STUN-via-
   relay burst measured private mihomo BND.ADDR (192.168.1.132)
   which is unroutable from external clients, so the test reported
   100% loss every time despite voice actually working through
   sing-box's TUN+SOCKS5. The remaining 6 checks (TCP/greet/auth/
   connect/UDP/api) cover what's actionable; voice quality is
   verified empirically by joining a Discord call.

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

895 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
// udpDropEveryN, when > 0, drops every Nth packet through the relay
// (counted across the whole listener lifetime). N=2 → 50% loss; N=10
// → 10%; N=1 → 100% loss; 0 → no drops.
udpDropEveryN atomic.Int32
udpRelayCount atomic.Int32
// 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",
"voice_quality_warn", "voice_quality_fail":
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
}
// Optional packet-drop simulation. udpDropEveryN of value 1 drops
// everything; 2 drops every other packet; 10 drops 10%.
if dropN := fp.udpDropEveryN.Load(); dropN > 0 {
c := fp.udpRelayCount.Add(1)
if c%dropN == 0 {
continue
}
} else {
fp.udpRelayCount.Add(1)
}
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,
VoiceBurstCount: 10,
VoiceBurstInterval: 5 * 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", "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", "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", "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", "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")
// 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)
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 non-auth tests should ultimately pass.
for _, id := range []string{"tcp", "greet", "connect", "udp", "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/
// voice-quality/api). Cancel may race with greet
// completing successfully, so accept ≥3.
assert.GreaterOrEqual(t, failed+skipped, 3, "expected at least 3 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)
}
}