Files
3x-ui/internal/web/runtime/tls_client_test.go
T
BlindMaster24 4a8fdceed6 perf(nodes): reuse one pooled client per node instead of rebuilding it (#6548)
* perf(nodes): reuse one pooled client per node instead of rebuilding it

The heartbeat probe asks for a client every 5s per node, and for skip, pin and
mtls modes HTTPClientForNode built a client with its own transport each time:
every tick paid a full TCP+TLS handshake per node, which is the CPU a 100-node
fleet reports. Cache the client per node identity, close the previous one when
that identity changes, and raise the idle pool caps above any real fleet size
so a node's connection survives to its next tick.

* perf(nodes): keep one client per node in the pooled cache

Round-1 findings on this PR. The eviction dropped only entries whose key did not
start with the current identity, so every proxy variant of that identity stayed
for the life of the process. That variant is often a fresh loopback port:
withOutboundBridge mints one per call and tears the bridge down on return, so
each operator "test node" or remote-inbounds action added a client whose key can
never be hit again, and a node switched to verify mode orphaned its old entry by
returning before the loop. Replacing that filter with one entry per node bounds
the cache at the fleet size, and the verify-mode return now clears the node too.

TestHTTPClientForNodeKeepsOneClientPerNode fails without this -- watched red,
"2, want 1" -- and pins the verify-mode cleanup on the same cache.

* style(nodes): keep the eviction comment inside the two-line cap
2026-09-15 15:29:49 +03:00

665 lines
21 KiB
Go

package runtime
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
)
type generationProbeTransport struct {
id string
closed atomic.Int32
}
func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
Header: make(http.Header),
Request: &http.Request{},
}, nil
}
func (t *generationProbeTransport) CloseIdleConnections() {
t.closed.Add(1)
}
func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) {
var selected atomic.Pointer[generationProbeTransport]
oldTransport := &generationProbeTransport{id: "old"}
newTransport := &generationProbeTransport{id: "new"}
selected.Store(oldTransport)
rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) {
return selected.Load(), nil
})
if err != nil {
t.Fatalf("newCredentialRotatingTransport: %v", err)
}
rotating.mu.Lock()
initial := rotating.current
rotating.mu.Unlock()
if initial != oldTransport {
t.Fatalf("initial transport = %p, want old %p", initial, oldTransport)
}
selected.Store(newTransport)
InvalidateMasterClientConnections()
req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil)
resp, err := rotating.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip after credential rotation: %v", err)
}
_ = resp.Body.Close()
rotating.mu.Lock()
current := rotating.current
rotating.mu.Unlock()
if current != newTransport {
t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport)
}
if got := oldTransport.closed.Load(); got != 1 {
t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got)
}
}
// Heartbeat and traffic sync ask for a client every few seconds; a rebuilt one
// owns an empty pool, so each tick paid a fresh TCP+TLS handshake per node.
func TestHTTPClientForNodeReusesOneConnectionAcrossCalls(t *testing.T) {
var handshakes atomic.Int32
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateNew {
handshakes.Add(1)
}
}
server.StartTLS()
defer server.Close()
u, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse test server url: %v", err)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("test server port: %v", err)
}
node := &model.Node{
Id: 31, Address: u.Hostname(), Port: port, Scheme: "https",
TlsVerifyMode: "skip", AllowPrivateAddress: true,
}
for tick := range 2 {
client, err := HTTPClientForNode(node, "")
if err != nil {
t.Fatalf("tick %d: HTTPClientForNode: %v", tick, err)
}
req, err := http.NewRequestWithContext(
netsafe.ContextWithAllowPrivate(context.Background(), true), http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("tick %d: new request: %v", tick, err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("tick %d: request: %v", tick, err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
if got := handshakes.Load(); got != 1 {
t.Fatalf("TLS handshakes = %d, want 1: a rebuilt client re-handshakes on every tick", got)
}
}
// A node that switches to pinning (or gains a proxy) must not be served by the
// client built for its previous trust decision.
func TestHTTPClientForNodeRebuildsWhenNodeIdentityChanges(t *testing.T) {
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
node := &model.Node{Id: 32, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
skipped, err := HTTPClientForNode(node, "")
if err != nil {
t.Fatalf("skip client: %v", err)
}
pinned := *node
pinned.TlsVerifyMode = "pin"
pinned.PinnedCertSha256 = pin
pinnedClient, err := HTTPClientForNode(&pinned, "")
if err != nil {
t.Fatalf("pin client: %v", err)
}
if skipped == pinnedClient {
t.Fatal("a pinned node must not reuse the client built to skip verification")
}
proxied, err := HTTPClientForNode(node, "socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("proxied client: %v", err)
}
if skipped == proxied {
t.Fatal("a proxied node must not reuse the direct client")
}
if again, err := HTTPClientForNode(node, ""); err != nil || again == pinnedClient {
t.Fatalf("a skip request must never be served the pinned client; again=%p err=%v", again, err)
}
}
func nodeClientEntries(id int) int {
nodeClientsMu.Lock()
defer nodeClientsMu.Unlock()
count := 0
for _, entry := range nodeClientsCache {
if entry.nodeID == id {
count++
}
}
return count
}
// withOutboundBridge mints a fresh loopback port per call, so the variant it
// asks for can never be hit again; only the variant in use may stay cached.
func TestHTTPClientForNodeKeepsOneClientPerNode(t *testing.T) {
node := &model.Node{Id: 77, Address: "node.example.test", Port: 443, Scheme: "https", TlsVerifyMode: "skip"}
variants := []string{"socks5://127.0.0.1:41001", "socks5://127.0.0.1:41002", ""}
for _, variant := range variants {
if _, err := HTTPClientForNode(node, variant); err != nil {
t.Fatalf("HTTPClientForNode(%q): %v", variant, err)
}
if got := nodeClientEntries(node.Id); got != 1 {
t.Fatalf("cached clients for the node after %q = %d, want 1", variant, got)
}
if client, err := HTTPClientForNode(node, variant); err != nil || client == nil {
t.Fatalf("repeat HTTPClientForNode(%q): client=%p err=%v", variant, client, err)
}
}
verify := *node
verify.TlsVerifyMode = "verify"
if client, err := HTTPClientForNode(&verify, ""); err != nil || client != defaultNodeHTTPClient {
t.Fatalf("verify client = %p, want the shared one (%p); err=%v", client, defaultNodeHTTPClient, err)
}
if got := nodeClientEntries(node.Id); got != 0 {
t.Fatalf("cached clients for a node now on verify = %d, want 0", got)
}
}
func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
before := masterCertEpoch.Load()
SetMasterClientCertProvider(func() (tls.Certificate, error) {
return tls.Certificate{}, context.Canceled
})
if err := ReloadMasterClientConnections(); err == nil {
t.Fatal("reload with an invalid provider unexpectedly succeeded")
}
if got := masterCertEpoch.Load(); got != before {
t.Fatalf("failed reload changed generation from %d to %d", before, got)
}
SetMasterClientCertProvider(func() (tls.Certificate, error) {
return masterCertForTest(t), nil
})
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
if err := ReloadMasterClientConnections(); err != nil {
t.Fatalf("ReloadMasterClientConnections: %v", err)
}
if got := masterCertEpoch.Load(); got != before+1 {
t.Fatalf("successful reload generation = %d, want %d", got, before+1)
}
}
func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) {
oldTransport := &generationProbeTransport{id: "old"}
newTransport := &generationProbeTransport{id: "new"}
var selected atomic.Pointer[generationProbeTransport]
selected.Store(oldTransport)
firstBuildCaptured := make(chan struct{})
releaseFirstBuild := make(chan struct{})
var once sync.Once
build := func() (idleClosingRoundTripper, error) {
captured := selected.Load()
once.Do(func() {
close(firstBuildCaptured)
<-releaseFirstBuild
})
return captured, nil
}
type result struct {
transport *credentialRotatingTransport
err error
}
resultCh := make(chan result, 1)
go func() {
transport, err := newCredentialRotatingTransport(build)
resultCh <- result{transport: transport, err: err}
}()
<-firstBuildCaptured
selected.Store(newTransport)
InvalidateMasterClientConnections()
close(releaseFirstBuild)
got := <-resultCh
if got.err != nil {
t.Fatalf("newCredentialRotatingTransport: %v", got.err)
}
got.transport.mu.Lock()
current := got.transport.current
got.transport.mu.Unlock()
if current != newTransport {
t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport)
}
if calls := oldTransport.closed.Load(); calls != 1 {
t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls)
}
}
func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) {
oldCert := masterCertForTest(t)
newCert := masterCertForTest(t)
selected := oldCert
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
client, err := HTTPClientForNode(&model.Node{
Scheme: "https",
Address: "node.example.test",
Port: 443,
TlsVerifyMode: "mtls",
}, "")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
rotating, ok := client.Transport.(*credentialRotatingTransport)
if !ok {
t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport)
}
leaf := func() []byte {
rotating.mu.Lock()
defer rotating.mu.Unlock()
transport, ok := rotating.current.(*http.Transport)
if !ok {
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
}
return transport.TLSClientConfig.Certificates[0].Certificate[0]
}
if got := leaf(); string(got) != string(oldCert.Certificate[0]) {
t.Fatal("initial TLS config does not contain the old credential")
}
selected = newCert
InvalidateMasterClientConnections()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
if err != nil {
t.Fatalf("NewRequestWithContext: %v", err)
}
if _, err := client.Do(req); err == nil {
t.Fatal("canceled request unexpectedly succeeded")
}
if got := leaf(); string(got) != string(newCert.Certificate[0]) {
t.Fatal("TLS config retained the old credential after invalidation")
}
}
func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) {
oldCert := masterCertForTest(t)
newCert := masterCertForTest(t)
selected := oldCert
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
const proxyURL = "http://127.0.0.1:18080"
client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL)
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
rotating, ok := client.Transport.(*credentialRotatingTransport)
if !ok {
t.Fatalf("transport = %T, want rotating transport", client.Transport)
}
current := func() *http.Transport {
rotating.mu.Lock()
defer rotating.mu.Unlock()
transport, ok := rotating.current.(*http.Transport)
if !ok {
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
}
return transport
}
assertProxy := func(transport *http.Transport) {
t.Helper()
if transport.Proxy == nil {
t.Fatalf("proxy function is nil, want %s", proxyURL)
}
req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil)
got, err := transport.Proxy(req)
if err != nil || got == nil || got.String() != proxyURL {
t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL)
}
}
assertProxy(current())
selected = newCert
InvalidateMasterClientConnections()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
_, _ = client.Do(req)
rebuilt := current()
assertProxy(rebuilt)
if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) {
t.Fatal("proxy mTLS rebuild retained the old credential")
}
}
// masterCertForTest builds a real CA-signed client certificate for mtls tests.
func masterCertForTest(t *testing.T) tls.Certificate {
t.Helper()
ca, err := crypto.GenerateNodeCA("test ca")
if err != nil {
t.Fatalf("GenerateNodeCA: %v", err)
}
client, err := crypto.IssueClientCert(ca, "master")
if err != nil {
t.Fatalf("IssueClientCert: %v", err)
}
cert, err := tls.X509KeyPair(client.CertPEM, client.KeyPEM)
if err != nil {
t.Fatalf("X509KeyPair: %v", err)
}
return cert
}
// TestTLSConfigForNode_MTLS_PresentsClientCert asserts the mtls branch presents
// the master client cert and verifies the node's server cert against system
// roots (no InsecureSkipVerify, no custom RootCAs).
func TestTLSConfigForNode_MTLS_PresentsClientCert(t *testing.T) {
cert := masterCertForTest(t)
SetMasterClientCertProvider(func() (tls.Certificate, error) { return cert, nil })
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"})
if err != nil {
t.Fatalf("tlsConfigForNode(mtls): %v", err)
}
if len(cfg.Certificates) != 1 {
t.Fatalf("mtls config must present exactly one client certificate, got %d", len(cfg.Certificates))
}
if cfg.InsecureSkipVerify {
t.Fatal("mtls must NOT skip server verification")
}
if cfg.RootCAs != nil {
t.Fatal("mtls verifies the node server against system roots (RootCAs must be nil)")
}
}
// TestTLSConfigForNode_MTLS_NoProviderFailsClosed asserts mtls fails closed when
// no master client certificate is available, rather than silently dropping auth.
func TestTLSConfigForNode_MTLS_NoProviderFailsClosed(t *testing.T) {
SetMasterClientCertProvider(nil)
if _, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}); err == nil {
t.Fatal("mtls without a configured client cert provider must fail closed")
}
}
// 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")
}
}
// TestTLSConfigForNode_CurrentContract locks the pre-mTLS behavior of
// tlsConfigForNode so the "mtls" branch added later cannot silently regress the
// existing skip/pin modes (characterization — passes on unchanged code).
func TestTLSConfigForNode_CurrentContract(t *testing.T) {
t.Run("skip disables verification with no VerifyConnection", func(t *testing.T) {
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "skip"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !cfg.InsecureSkipVerify {
t.Fatal("skip mode must set InsecureSkipVerify")
}
if cfg.VerifyConnection != nil {
t.Fatal("skip mode must not install a VerifyConnection")
}
})
t.Run("pin installs a VerifyConnection", func(t *testing.T) {
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "pin", PinnedCertSha256: pin})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.VerifyConnection == nil {
t.Fatal("pin mode must install a 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)
}
})
}
}