mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-18 12:49:07 +02:00
241 lines
8.7 KiB
Go
241 lines
8.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"testing"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
|
)
|
|
|
|
func printed(t *testing.T, err error) string {
|
|
t.Helper()
|
|
cmd := &cobra.Command{}
|
|
var buf bytes.Buffer
|
|
cmd.SetErr(&buf)
|
|
printCommandError(cmd, err)
|
|
return buf.String()
|
|
}
|
|
|
|
// The daemon writes these sentences for the user, so they must reach the
|
|
// terminal as written rather than inside "rpc error: code = ... desc = ...".
|
|
func TestPrintCommandErrorStripsTheGRPCEnvelope(t *testing.T) {
|
|
out := printed(t, ipcauth.SessionHeldError("disconnecting"))
|
|
|
|
assert.Contains(t, out, "Disconnecting is refused while another user has this machine connected.")
|
|
assert.Contains(t, out, "netbird down", "the remedy is shown")
|
|
assert.NotContains(t, out, "rpc error")
|
|
assert.NotContains(t, out, "PermissionDenied")
|
|
assert.NotContains(t, out, "Error:", "guidance stands on its own")
|
|
}
|
|
|
|
// A command that adds context still renders, since the status survives wrapping
|
|
// and that is what the backoff loops in up and login read.
|
|
func TestPrintCommandErrorSeesThroughWrapping(t *testing.T) {
|
|
wrapped := daemonCallError("call service down method", ipcauth.SessionHeldError("disconnecting"))
|
|
|
|
st, ok := gstatus.FromError(wrapped)
|
|
require.True(t, ok, "wrapping must not hide the status from code checks")
|
|
assert.Equal(t, codes.PermissionDenied, st.Code())
|
|
|
|
out := printed(t, wrapped)
|
|
assert.NotContains(t, out, "rpc error")
|
|
assert.NotContains(t, out, "call service down method")
|
|
}
|
|
|
|
func TestPrintCommandErrorKeepsOrdinaryErrors(t *testing.T) {
|
|
out := printed(t, errors.New("connection refused"))
|
|
assert.Contains(t, out, "Error:")
|
|
assert.Contains(t, out, "connection refused")
|
|
}
|
|
|
|
// A status with no daemon detail is not ours to reword.
|
|
func TestPrintCommandErrorLeavesForeignStatusAlone(t *testing.T) {
|
|
out := printed(t, gstatus.Error(codes.Unavailable, "daemon not initialized"))
|
|
assert.Contains(t, out, "Error:")
|
|
assert.Contains(t, out, "daemon not initialized")
|
|
}
|
|
|
|
func TestPrintCommandErrorRendersEveryDaemonReason(t *testing.T) {
|
|
for name, err := range map[string]error{
|
|
"privilege": ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"),
|
|
"session": ipcauth.SessionHeldError("connecting"),
|
|
"ownership": ipcauth.NotOwnerError("switching profile"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
out := printed(t, err)
|
|
assert.NotContains(t, out, "rpc error", fmt.Sprintf("%s refusal still shows the envelope", name))
|
|
assert.NotContains(t, out, "Error:")
|
|
})
|
|
}
|
|
}
|
|
|
|
// The interceptor is what makes this general: once a refusal leaves the daemon
|
|
// it reads correctly however a command wraps it, including with %v, which
|
|
// breaks the chain every other approach relies on.
|
|
func TestDaemonDenialSurvivesAnyWrapping(t *testing.T) {
|
|
denial := asDaemonDenial(ipcauth.SessionHeldError("switching profile"))
|
|
|
|
for name, wrapped := range map[string]error{
|
|
"unwrapped": denial,
|
|
"wrapped once": fmt.Errorf("switch profile: %w", denial),
|
|
"wrapped twice": fmt.Errorf("switch profile: %w",
|
|
fmt.Errorf("switch profile failed: %w", denial)),
|
|
"wrapped with %v": fmt.Errorf("switch profile: %v", denial),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
out := printed(t, wrapped)
|
|
assert.NotContains(t, out, "rpc error", "the envelope must never reach the terminal")
|
|
assert.NotContains(t, out, "PermissionDenied")
|
|
assert.Contains(t, out, "Switching profile is refused")
|
|
assert.Contains(t, out, "netbird down")
|
|
})
|
|
}
|
|
}
|
|
|
|
// Re-presenting the error must not cost the code the backoff loops read.
|
|
func TestDaemonDenialKeepsItsStatus(t *testing.T) {
|
|
denial := asDaemonDenial(ipcauth.SessionHeldError("connecting"))
|
|
|
|
st, ok := gstatus.FromError(denial)
|
|
require.True(t, ok)
|
|
assert.Equal(t, codes.PermissionDenied, st.Code())
|
|
|
|
st, ok = gstatus.FromError(fmt.Errorf("up failed: %w", denial))
|
|
require.True(t, ok, "a %w wrap must still expose the code")
|
|
assert.Equal(t, codes.PermissionDenied, st.Code())
|
|
}
|
|
|
|
// Anything that is not a daemon refusal is left exactly as it was.
|
|
func TestAsDaemonDenialLeavesOtherErrorsAlone(t *testing.T) {
|
|
plain := errors.New("connection refused")
|
|
assert.Same(t, plain, asDaemonDenial(plain))
|
|
|
|
foreign := gstatus.Error(codes.Unavailable, "daemon not initialized")
|
|
assert.Equal(t, foreign, asDaemonDenial(foreign))
|
|
assert.Nil(t, asDaemonDenial(nil))
|
|
}
|
|
|
|
// fakeStream reports err from every call, standing in for a stream the daemon
|
|
// opened and then refused.
|
|
type fakeStream struct {
|
|
grpc.ClientStream
|
|
err error
|
|
}
|
|
|
|
func (f fakeStream) RecvMsg(any) error { return f.err }
|
|
func (f fakeStream) SendMsg(any) error { return f.err }
|
|
func (f fakeStream) Header() (metadata.MD, error) { return nil, f.err }
|
|
|
|
// Opening a stream does not wait for the server to accept it, so a refusal
|
|
// arrives on the first Recv. capture and expose both read it there.
|
|
func TestDenialStreamConvertsRefusalsAfterOpen(t *testing.T) {
|
|
s := denialStream{ClientStream: fakeStream{err: ipcauth.SessionHeldError("starting a packet capture")}}
|
|
|
|
for name, err := range map[string]error{
|
|
"RecvMsg": s.RecvMsg(nil),
|
|
"SendMsg": s.SendMsg(nil),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
require.Error(t, err)
|
|
assert.NotContains(t, err.Error(), "rpc error", "the envelope must not survive")
|
|
assert.Contains(t, err.Error(), "Starting a packet capture is refused")
|
|
|
|
st, ok := gstatus.FromError(err)
|
|
require.True(t, ok, "the code has to survive for callers that branch on it")
|
|
assert.Equal(t, codes.PermissionDenied, st.Code())
|
|
})
|
|
}
|
|
|
|
_, err := s.Header()
|
|
require.Error(t, err)
|
|
assert.NotContains(t, err.Error(), "rpc error")
|
|
}
|
|
|
|
// A clean end of stream is not an error. Callers compare against io.EOF, so it
|
|
// has to come back as the very same value.
|
|
func TestDenialStreamPassesEOFThrough(t *testing.T) {
|
|
s := denialStream{ClientStream: fakeStream{err: io.EOF}}
|
|
|
|
assert.Same(t, io.EOF, s.RecvMsg(nil))
|
|
assert.True(t, errors.Is(s.RecvMsg(nil), io.EOF))
|
|
}
|
|
|
|
func TestDenialStreamLeavesOtherErrorsAlone(t *testing.T) {
|
|
plain := errors.New("transport closing")
|
|
s := denialStream{ClientStream: fakeStream{err: plain}}
|
|
|
|
assert.Same(t, plain, s.RecvMsg(nil))
|
|
}
|
|
|
|
// captureLog points the standard logger at a buffer for the duration of a test,
|
|
// standing in for the console writer every interactive command installs.
|
|
func captureLog(t *testing.T, level log.Level) *bytes.Buffer {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
logger := log.StandardLogger()
|
|
prevOut, prevLevel, prevFmt := logger.Out, logger.Level, logger.Formatter
|
|
logger.SetOutput(&buf)
|
|
logger.SetLevel(level)
|
|
// The default formatter escapes the quotes inside a message, which would let
|
|
// a logged error slip past a comparison against the error's own text.
|
|
logger.SetFormatter(&log.TextFormatter{DisableQuote: true, DisableTimestamp: true})
|
|
t.Cleanup(func() {
|
|
logger.SetOutput(prevOut)
|
|
logger.SetLevel(prevLevel)
|
|
logger.SetFormatter(prevFmt)
|
|
})
|
|
return &buf
|
|
}
|
|
|
|
// Console logging and PrintErrln both write to os.Stderr, so a command that logs
|
|
// the error it is about to return has it printed twice: once by the logger and
|
|
// once by Execute. SilenceErrors does not cover this, it only retires cobra's
|
|
// own copy.
|
|
func TestCommandDoesNotLogTheErrorItReturns(t *testing.T) {
|
|
logged := captureLog(t, log.InfoLevel)
|
|
|
|
prev := logLevel
|
|
logLevel = "bogus"
|
|
t.Cleanup(func() { logLevel = prev })
|
|
|
|
err := downCmd.RunE(downCmd, nil)
|
|
require.Error(t, err, "an unparseable log level fails before the command dials")
|
|
assert.NotContains(t, logged.String(), err.Error(), "Execute renders this error, so the command must not log it")
|
|
|
|
assert.Contains(t, printed(t, err), "not a valid logrus Level", "and it is still reported once")
|
|
}
|
|
|
|
// The rendered sentence drops the envelope and code on purpose, so the raw error
|
|
// stays available to a bug report at debug level, below what a user sees.
|
|
func TestPrintCommandErrorKeepsTheRawErrorAtDebug(t *testing.T) {
|
|
logged := captureLog(t, log.DebugLevel)
|
|
|
|
out := printed(t, ipcauth.SessionHeldError("disconnecting"))
|
|
|
|
assert.NotContains(t, out, "rpc error", "the user still reads the sentence alone")
|
|
assert.Contains(t, logged.String(), "rpc error", "the envelope a bug report needs survives in the log")
|
|
assert.Contains(t, logged.String(), "PermissionDenied")
|
|
}
|
|
|
|
// At the level an interactive command actually runs at, the diagnostic stays out
|
|
// of the way, so the failure reaches the terminal exactly once.
|
|
func TestPrintCommandErrorLogsNothingAtInfo(t *testing.T) {
|
|
logged := captureLog(t, log.InfoLevel)
|
|
|
|
printed(t, errors.New("connection refused"))
|
|
|
|
assert.NotContains(t, logged.String(), "connection refused")
|
|
}
|