Files
3x-ui/internal/web/runtime/tls_client_test.go
T
Sanaei 7605902324 Test-quality audit: fix 2 prod bugs, strengthen weak tests, add mutation/fuzz/CI tooling (#5345)
* test(audit): add gremlins/rapid/coverage tooling + AUDIT.md scaffold

* test(audit): hygiene sweep (race-clean except logger global; Finding #2) + smell inventory

* test(audit): cover untested error/edge branches (TLS proxy+pin, migration tag cleanup=Finding #1)

* test(audit): strengthen internal/sub link tests (dedup key, TLS/Reality mapping, clash well-formedness)

* test(audit): property (rapid) + fuzz tests for joinHostPort/userinfo/pin/ParseLink

* test(audit): tighten frontend subSortIndex rejection assertions + wire coverage

* ci(audit): add shuffle gate + non-blocking race job (Finding #2) + fuzz-smoke; document mutation policy

* chore(audit): gitignore frontend coverage output

* test(audit): exhaustive whole-repo pass — strengthen 5 weak/fake tests (netproxy, CSP, modal per-protocol loops, schema coercions)

* docs(contributing): add Testing section (conventions, race/shuffle, fuzz, mutation policy); drop AUDIT.md ledger

* fix(logger,migration): guard logBuffer with mutex; execute legacy tag cleanup (tx.Exec); make CI race gate blocking

* ci(mutation): add nightly scoped gremlins workflow (informational artifacts)

* test(audit): strengthen runtime tests — baseURL scheme/port bounds, isNonEmptySlice, trafficReset

* test(audit): strengthen clash tests — reality field mapping + tcp-header validation

* test(audit): runtime — egress-proxy + content-type tests; drop redundant bp=='' branch

* test(audit): strengthen link parser/helper tests (defaultPort, splitComma, base64, canonicalQuery, tls/reality/transport mapping)

* test(audit): strengthen sub/xray/common/netsafe/mtproto/config/middleware tests (kill surviving mutants)

* test(audit): raise timeout on protocol-iteration modal tests (heavy re-renders, slow on CI)

* fix(logger): GetLogs returns at most c entries (off-by-one fix; addresses PR review)

* perf(logger): snapshot logBuffer under lock so GetLogs doesn't block logging; clarify fuzz-seed docs (addresses PR review)
2026-06-15 15:17:03 +02:00

225 lines
7.0 KiB
Go

package runtime
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// nodeForServer builds a node pointing at a loopback test server (loopback is
// SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer).
func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node {
t.Helper()
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("parse server port: %v", err)
}
return &model.Node{
Id: 1,
Name: "n1",
Scheme: "https",
Address: u.Hostname(),
Port: port,
BasePath: "/",
ApiToken: "token",
Enable: true,
AllowPrivateAddress: true,
TlsVerifyMode: mode,
PinnedCertSha256: pin,
}
}
func leafPinBase64(srv *httptest.Server) string {
sum := sha256.Sum256(srv.Certificate().Raw)
return base64.StdEncoding.EncodeToString(sum[:])
}
// A self-signed node must be reachable by Remote ops under skip/pin and
// rejected under verify — the split issue #5264 reported.
func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
}))
defer srv.Close()
goodPin := leafPinBase64(srv)
wrongPin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
cases := []struct {
name string
mode string
pin string
wantErr bool
}{
{"verify rejects self-signed", "verify", "", true},
{"skip accepts self-signed", "skip", "", false},
{"pin accepts matching cert", "pin", goodPin, false},
{"pin rejects mismatched cert", "pin", wrongPin, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := NewRemote(nodeForServer(t, srv, c.mode, c.pin), nil)
_, err := r.ListInboundOptions(context.Background())
if c.wantErr && err == nil {
t.Fatalf("mode %q: expected error, got nil", c.mode)
}
if !c.wantErr && err != nil {
t.Fatalf("mode %q: unexpected error: %v", c.mode, err)
}
})
}
}
// The lazily-built client is cached for the Remote's lifetime so repeated
// operations reuse one pooled transport rather than rebuilding TLS each call.
func TestRemoteClientCached(t *testing.T) {
r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"}, nil)
c1, err1 := r.httpClient()
c2, err2 := r.httpClient()
if err1 != nil || err2 != nil {
t.Fatalf("httpClient errors: %v %v", err1, err2)
}
if c1 != c2 {
t.Fatal("expected the same cached client across calls")
}
}
func TestHTTPClientForNodeVerifyShared(t *testing.T) {
// verify mode and plain http both reuse the shared default client.
for _, n := range []*model.Node{
{Scheme: "https", TlsVerifyMode: "verify"},
{Scheme: "https", TlsVerifyMode: ""},
{Scheme: "http", TlsVerifyMode: "skip"},
} {
c, err := HTTPClientForNode(n, "")
if err != nil {
t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
}
if c != defaultNodeHTTPClient {
t.Fatalf("HTTPClientForNode(%+v) = %p, want shared default %p", n, c, defaultNodeHTTPClient)
}
}
}
func TestHTTPClientForNodePinInvalid(t *testing.T) {
// pin mode must fail closed, and with a specific error per cause — not merely
// "some error" (which a bug anywhere in the build path would also satisfy).
cases := []struct {
name string
pin string
wantErr string
}{
{"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
{"empty pin", "", "certificate pin is empty"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
if err == nil {
t.Fatalf("expected error for pin %q", c.pin)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
}
})
}
}
// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
// line). Dropping it would silently disable certificate pinning whenever a proxy is set.
func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("proxy client must not be the shared default client")
}
tr, ok := c.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", c.Transport)
}
if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
}
}
// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
// using system-CA verification and NOT a pin VerifyConnection.
func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("proxy client must not be the shared default client")
}
if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
t.Fatal("verify mode must not install a pin VerifyConnection")
}
}
func TestDecodeCertPin(t *testing.T) {
raw := sha256.Sum256([]byte("cert"))
hexColon := strings.ToUpper(hex.EncodeToString(raw[:]))
// reinsert colons in openssl -fingerprint style
var withColons strings.Builder
for i := 0; i < len(hexColon); i += 2 {
if i > 0 {
withColons.WriteByte(':')
}
withColons.WriteString(hexColon[i : i+2])
}
cases := []struct {
name string
in string
wantErr bool
}{
{"base64 std", base64.StdEncoding.EncodeToString(raw[:]), false},
{"base64 raw url", base64.RawURLEncoding.EncodeToString(raw[:]), false},
{"hex bare", hex.EncodeToString(raw[:]), false},
{"hex colon openssl", withColons.String(), false},
{"empty", "", true},
{"garbage", "not-a-pin", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := DecodeCertPin(c.in)
if c.wantErr {
if err == nil {
t.Fatalf("expected error for %q", c.in)
}
return
}
if err != nil {
t.Fatalf("unexpected error for %q: %v", c.in, err)
}
if string(got) != string(raw[:]) {
t.Fatalf("decoded bytes mismatch for %q", c.in)
}
})
}
}