mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-30 02:29:08 +02:00
[Client] Surface readable Authz errors and add profile claim command (#7540)
* [client] Surface error messages for IPC authz in UI (#7553)
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user