[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:
Theodor Midtlien
2026-09-17 14:36:56 +02:00
committed by GitHub
parent 801557e21b
commit cec9ee6699
69 changed files with 2912 additions and 681 deletions
+25 -16
View File
@@ -38,17 +38,20 @@ type LogLevel struct {
}
type Debug struct {
conn DaemonConn
conn DaemonConn
classifier errorClassifier
}
func NewDebug(conn DaemonConn) *Debug {
return &Debug{conn: conn}
// NewDebug wires up a Debug service. translator or prefs may be nil, in which
// case classification falls back to the bare error key.
func NewDebug(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Debug {
return &Debug{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
}
func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) {
cli, err := s.conn.Client()
if err != nil {
return DebugBundleResult{}, err
return DebugBundleResult{}, s.classifier.classify(err)
}
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
Anonymize: p.Anonymize,
@@ -59,7 +62,7 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
CliVersion: version.NetbirdVersion(),
})
if err != nil {
return DebugBundleResult{}, err
return DebugBundleResult{}, s.classifier.classify(err)
}
return DebugBundleResult{
Path: resp.GetPath(),
@@ -71,11 +74,11 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) {
cli, err := s.conn.Client()
if err != nil {
return LogLevel{}, err
return LogLevel{}, s.classifier.classify(err)
}
resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
if err != nil {
return LogLevel{}, err
return LogLevel{}, s.classifier.classify(err)
}
return LogLevel{Level: resp.GetLevel().String()}, nil
}
@@ -104,29 +107,33 @@ func (s *Debug) RegisterUILog(ctx context.Context, path string) error {
func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
req := &proto.StartBundleCaptureRequest{}
if timeoutSeconds > 0 {
req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second)
}
_, err = cli.StartBundleCapture(ctx, req)
return err
if _, err := cli.StartBundleCapture(ctx, req); err != nil {
return s.classifier.classify(err)
}
return nil
}
func (s *Debug) StopBundleCapture(ctx context.Context) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
_, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{})
return err
if _, err := cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{}); err != nil {
return s.classifier.classify(err)
}
return nil
}
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
// proto.LogLevel_value keys are upper-case enum names; callers pass
// lowercase logrus names. Upper-case before lookup or a valid level
@@ -135,6 +142,8 @@ func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
if !ok {
level = int32(proto.LogLevel_INFO)
}
_, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)})
return err
if _, err := cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)}); err != nil {
return s.classifier.classify(err)
}
return nil
}
+44 -31
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"strings"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
@@ -15,19 +14,27 @@ import (
"github.com/netbirdio/netbird/client/ui/preferences"
)
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
// carries one.
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
for _, detail := range gstatus.Convert(err).Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok {
continue
}
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
return info, true
}
// denialCode maps a refusal to the code the frontend presents it by, reporting
// false for a reason this build does not know. An unknown reason keeps the
// summary the daemon wrote and loses only the tailored presentation, which is
// what makes adding a reason daemon-side safe.
func denialCode(reason string) (string, bool) {
switch reason {
case ipcauth.ErrorReasonPrivilegeRequired:
return "privilege_required", true
case ipcauth.ErrorReasonSessionHeld:
return "session_held", true
case ipcauth.ErrorReasonNotProfileOwner:
return "not_profile_owner", true
default:
return "permission_denied", false
}
return nil, false
}
// privilegeRefused reports whether the daemon refused for want of privileges.
func privilegeRefused(err error) bool {
denial, ok := ipcauth.DenialFrom(err)
return ok && denial.Reason == ipcauth.ErrorReasonPrivilegeRequired
}
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
@@ -40,9 +47,10 @@ type LanguagePreference interface {
Get() preferences.UIPreferences
}
// ClientError is a structured error returned to the frontend. The frontend
// translates Code via i18n; Short is an English fallback; Long carries the
// unwrapped daemon message.
// ClientError is a structured error returned to the frontend. Short is the
// localised headline, Long the unwrapped daemon message shown under it, and Code
// the stable identifier Short was resolved from. The frontend reads Short, Long
// and Command; it does not translate Code itself.
type ClientError struct {
Code string `json:"code"`
Short string `json:"short"`
@@ -94,21 +102,8 @@ func (c errorClassifier) classify(err error) *ClientError {
grpcCode = st.Code()
}
// A refusal for want of privileges carries its own summary and the command
// that performs the operation, both written for the user. Surface them
// verbatim: no substring guessing, and no localisation of a message the
// daemon composed.
if info, ok := privilegeErrorInfo(err); ok {
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
if summary == "" {
summary = msg
}
return &ClientError{
Code: "privilege_required",
Short: summary,
Long: summary,
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
}
if denial, ok := ipcauth.DenialFrom(err); ok {
return c.classifyDenial(denial)
}
lower := strings.ToLower(msg)
@@ -155,6 +150,24 @@ func (c errorClassifier) classify(err error) *ClientError {
}
}
// classifyDenial presents a refusal the daemon explained: a localised headline
// for the reasons this build knows, with the daemon's own sentence as the
// detail the frontend shows under it. An unrecognised reason keeps that sentence
// as the headline too, so a reason added daemon-side still reaches the user.
func (c errorClassifier) classifyDenial(denial ipcauth.Denial) *ClientError {
code, known := denialCode(denial.Reason)
short := denial.Summary
if known {
short = c.translateShort(code)
}
return &ClientError{
Code: code,
Short: short,
Long: denial.Summary,
Command: denial.Command,
}
}
// translateShort resolves the localised short message for code, returning the
// bare "error.<code>" key when no translation is available so the gap stays visible.
func (c errorClassifier) translateShort(code string) string {
+109
View File
@@ -4,11 +4,17 @@ package services
import (
"errors"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/ui/i18n"
)
func TestErrorClassifier_Classify(t *testing.T) {
@@ -48,3 +54,106 @@ func TestErrorClassifier_Classify(t *testing.T) {
require.Nil(t, c.classify(nil))
})
}
// Every reason the daemon explains gets its own code, and the headline is looked
// up from that code rather than repeating the daemon's sentence, so a held
// session reads differently from a privilege refusal.
func TestClassifyMapsEveryDaemonReason(t *testing.T) {
c := errorClassifier{} // nil translator → Short is the bare "error.<code>" key
for _, tc := range []struct {
name string
err error
code string
command bool
}{
{"privilege", ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), "privilege_required", true},
{"session held", ipcauth.SessionHeldError("switching profile"), "session_held", true},
{"not owner", ipcauth.NotOwnerError("reading the profile configuration"), "not_profile_owner", false},
} {
t.Run(tc.name, func(t *testing.T) {
got := c.classify(tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.code, got.Code)
assert.Equal(t, "error."+tc.code, got.Short, "Short comes from the locale bundle, not the daemon")
assert.NotEmpty(t, got.Long, "the daemon's sentence has to reach the user")
assert.NotContains(t, got.Long, "rpc error")
assert.Equal(t, tc.command, got.Command != "")
})
}
}
// Every denial code needs an entry in the shipped bundle, or the dialog shows a
// bare "error.<code>" key where the headline should be. Resolved against the real
// locale tree so a reason added without a translation fails here, not on screen.
func TestDenialHeadlinesResolveInTheShippedBundle(t *testing.T) {
bundle, err := i18n.NewBundle(os.DirFS("../i18n/locales"))
require.NoError(t, err, "the shipped locale tree must load")
c := errorClassifier{translator: bundle}
for _, tc := range []struct {
name string
err error
short string
long string
}{
{
"privilege",
ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"),
"This action requires elevated privileges.",
"Claiming a profile requires root.",
},
{
"session held",
ipcauth.SessionHeldError("switching profile"),
"Another user has this machine connected.",
"Switching profile is refused while another user has this machine connected.",
},
{
"not owner",
ipcauth.NotOwnerError("reading the profile configuration"),
"This profile belongs to another user.",
"Reading the profile configuration is refused because the profile it addresses belongs to another user.",
},
} {
t.Run(tc.name, func(t *testing.T) {
got := c.classify(tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.short, got.Short, "Short should be the localised headline for the code")
assert.Contains(t, got.Long, tc.long, "Long should carry the daemon's own sentence")
assert.NotEqual(t, got.Short, got.Long, "a repeated sentence costs the frontend its detail line")
})
}
}
// A reason added daemon-side must still reach the user, losing only the tailored
// presentation. It must not borrow another code's headline: "permission_denied"
// is the sign-in rejection, which has nothing to do with an IPC refusal.
func TestClassifyKeepsTheSentenceForAnUnknownReason(t *testing.T) {
code, known := denialCode("SOMETHING_NEW")
assert.False(t, known, "an unrecognised reason must not claim a tailored headline")
assert.Equal(t, "permission_denied", code)
const summary = "Doing something new is refused for a reason this build predates."
st, err := gstatus.New(gcodes.PermissionDenied, summary).WithDetails(&errdetails.ErrorInfo{
Reason: "SOMETHING_NEW",
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{ipcauth.ErrorMetaSummary: summary},
})
require.NoError(t, err)
got := errorClassifier{}.classify(st.Err())
require.NotNil(t, got)
assert.Equal(t, summary, got.Short, "the daemon's sentence stands in for the headline")
assert.Equal(t, summary, got.Long)
}
// Only a privilege refusal is answered by offering to elevate. Offering it for
// a session another user holds would be nonsense.
func TestPrivilegeRefusedIsNarrow(t *testing.T) {
assert.True(t, privilegeRefused(ipcauth.PrivilegeError("x", "y")))
assert.False(t, privilegeRefused(ipcauth.SessionHeldError("connecting")))
assert.False(t, privilegeRefused(ipcauth.NotOwnerError("connecting")))
assert.False(t, privilegeRefused(errors.New("connection refused")))
}
+18 -15
View File
@@ -54,11 +54,14 @@ type RenameProfileParams struct {
}
type Profiles struct {
conn DaemonConn
conn DaemonConn
classifier errorClassifier
}
func NewProfiles(conn DaemonConn) *Profiles {
return &Profiles{conn: conn}
// NewProfiles wires up a Profiles service. translator or prefs may be nil, in
// which case classification falls back to the bare error key.
func NewProfiles(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Profiles {
return &Profiles{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
}
// Username returns the OS username the daemon expects for profile lookups.
@@ -73,11 +76,11 @@ func (s *Profiles) Username() (string, error) {
func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) {
cli, err := s.conn.Client()
if err != nil {
return nil, err
return nil, s.classifier.classify(err)
}
resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
if err != nil {
return nil, err
return nil, s.classifier.classify(err)
}
pm := profilemanager.NewProfileManager()
out := make([]Profile, 0, len(resp.GetProfiles()))
@@ -94,11 +97,11 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error)
func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
cli, err := s.conn.Client()
if err != nil {
return ActiveProfile{}, err
return ActiveProfile{}, s.classifier.classify(err)
}
resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
if err != nil {
return ActiveProfile{}, err
return ActiveProfile{}, s.classifier.classify(err)
}
return ActiveProfile{
ID: resp.GetId(),
@@ -114,7 +117,7 @@ func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
req := &proto.SwitchProfileRequest{}
if p.ProfileName != "" {
@@ -125,7 +128,7 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
}
resp, err := cli.SwitchProfile(ctx, req)
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
return resp.GetId(), nil
}
@@ -136,14 +139,14 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
return resp.GetId(), nil
}
@@ -151,14 +154,14 @@ func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
cli, err := s.conn.Client()
if err != nil {
return err
return s.classifier.classify(err)
}
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return err
return s.classifier.classify(err)
}
// The daemon deletes what it owns but runs as root, so it leaves the
@@ -188,7 +191,7 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
Username: p.Username,
@@ -196,7 +199,7 @@ func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, e
NewProfileName: p.NewName,
})
if err != nil {
return "", err
return "", s.classifier.classify(err)
}
return resp.GetOldProfileName(), nil
}
+85
View File
@@ -0,0 +1,85 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
// stubProfileDaemon refuses SwitchProfile the way the daemon refuses a caller
// who does not hold the session. The embedded interface is nil, so any other
// call panics rather than passing quietly.
type stubProfileDaemon struct {
proto.DaemonServiceClient
err error
}
func (d *stubProfileDaemon) SwitchProfile(_ context.Context, _ *proto.SwitchProfileRequest, _ ...grpc.CallOption) (*proto.SwitchProfileResponse, error) {
return nil, d.err
}
// sessionHeldRefusal is the error the daemon raises when another user holds the
// connection, detail and all: see ipcauth.SessionHeldError.
func sessionHeldRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, sessionHeldSummaryText+"\n\nsudo netbird down").
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonSessionHeld,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{
ipcauth.ErrorMetaSummary: sessionHeldSummaryText,
ipcauth.ErrorMetaCommand: "sudo netbird down",
},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
const sessionHeldSummaryText = "Switching profiles is refused while another user has this machine connected."
func profilesRefusingSwitch(t *testing.T) *Profiles {
t.Helper()
// nil translator → Short is the bare "error.<code>" key, which is enough to
// tell a resolved headline from the daemon's own sentence.
return NewProfiles(stubConn{client: &stubProfileDaemon{err: sessionHeldRefusal(t)}}, nil, nil)
}
// A refused switch has to reach the caller as the classified value, since that is
// the only thing carrying the headline and the command the frontend renders.
func TestProfilesSwitchClassifiesRefusal(t *testing.T) {
_, err := profilesRefusingSwitch(t).Switch(context.Background(), ProfileRef{ProfileName: "work"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "Switch must return the classified error, got %T", err)
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, sessionHeldSummaryText, clientErr.Long, "the daemon's sentence is the detail")
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
}
// The switcher used to wrap this in fmt.Errorf, which left the Wails binding
// nothing to marshal and put the raw "switch profile %q: rpc error: ..." string
// in front of the user instead of the headline and the copyable command.
func TestProfileSwitcherReturnsClassifiedRefusal(t *testing.T) {
switcher := NewProfileSwitcher(profilesRefusingSwitch(t), nil, nil)
err := switcher.SwitchActive(context.Background(), ProfileRef{ProfileName: "01HZY0000000000000000000"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "the switcher must pass the classified error through, got %T", err)
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
assert.Equal(t, "error.session_held", err.Error(),
"no wrapping prefix and no gRPC dump in front of the headline")
}
+6 -3
View File
@@ -4,7 +4,6 @@ package services
import (
"context"
"fmt"
"strings"
log "github.com/sirupsen/logrus"
@@ -68,9 +67,12 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
s.feed.BeginProfileSwitch()
}
// Returned unwrapped: the Wails binding marshals the outermost error, so a
// wrapper replaces the classified headline and the daemon's suggested
// command with a raw gRPC string. The Infof above names the profile.
resolvedID, err := s.profiles.Switch(ctx, p)
if err != nil {
return fmt.Errorf("switch profile %q: %w", p.ProfileName, err)
return err
}
// Mirror into the user-side ProfileManager state: the CLI's `netbird up`
@@ -90,8 +92,9 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
}
if connect {
// Unwrapped for the same reason as the switch above.
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
return err
}
}
+7 -5
View File
@@ -133,14 +133,16 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
cli, err := s.conn.Client()
if err != nil {
return Config{}, err
return Config{}, s.classifier.classify(err)
}
resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return Config{}, err
// Reading another user's profile is refused here, and the settings
// screen puts the result straight in front of the user.
return Config{}, s.classifier.classify(err)
}
return Config{
ManagementURL: resp.GetManagementUrl(),
@@ -175,7 +177,7 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
return SaveOutcome{}, s.classifier.classify(err)
}
req := &proto.SetConfigRequest{
ProfileName: p.ProfileName,
@@ -207,7 +209,7 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
SshJWTCacheTTL: p.SSHJWTCacheTTL,
}
if _, err := cli.SetConfig(ctx, req); err != nil {
if _, refused := privilegeErrorInfo(err); refused {
if privilegeRefused(err) {
return s.setConfigElevated(ctx, p, req, err)
}
// Classified so the frontend gets the daemon's guidance instead of the
@@ -251,7 +253,7 @@ func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req
cli, err := s.conn.Client()
if err != nil {
return SaveOutcome{}, err
return SaveOutcome{}, s.classifier.classify(err)
}
if _, err := cli.SetConfig(ctx, req); err != nil {
return SaveOutcome{}, s.classifier.classify(err)
+63
View File
@@ -0,0 +1,63 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
const notOwnerSummaryText = "Reading the profile configuration is refused because the profile it addresses belongs to another user."
// stubConfigDaemon refuses GetConfig the way the daemon refuses a caller who
// does not own the profile the request names. The embedded interface is nil, so
// any other call panics rather than passing quietly.
type stubConfigDaemon struct {
proto.DaemonServiceClient
err error
}
func (d *stubConfigDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
return nil, d.err
}
// notOwnerRefusal is the error the gate raises for a profile owned by somebody
// else: see ipcauth.NotOwnerError. It carries no command on purpose — privilege
// is not what the method asked for.
func notOwnerRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, notOwnerSummaryText).
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonNotProfileOwner,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{ipcauth.ErrorMetaSummary: notOwnerSummaryText},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
// The settings screen reads the config on mount and shows whatever comes back,
// so an unclassified refusal there is a raw gRPC dump in front of the user.
func TestSettingsGetConfigClassifiesRefusal(t *testing.T) {
// nil translator → Short is the bare "error.<code>" key.
settings := NewSettings(stubConn{client: &stubConfigDaemon{err: notOwnerRefusal(t)}}, nil, nil, testDaemonAddr)
_, err := settings.GetConfig(context.Background(), ConfigParams{ProfileName: "01HZY0000000000000000000"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "GetConfig must return the classified error, got %T", err)
assert.Equal(t, "not_profile_owner", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, notOwnerSummaryText, clientErr.Long, "the daemon's sentence is the detail")
assert.Empty(t, clientErr.Command, "this refusal has no command to offer")
}
+9 -6
View File
@@ -22,12 +22,15 @@ type UpdateResult struct {
// Update is the Wails-bound facade over the daemon's update RPCs. The state
// machine and push event live in client/ui/updater.
type Update struct {
conn DaemonConn
holder *updater.Holder
conn DaemonConn
holder *updater.Holder
classifier errorClassifier
}
func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update {
return &Update{conn: conn, holder: holder}
// NewUpdate wires up an Update service. translator or prefs may be nil, in
// which case classification falls back to the bare error key.
func NewUpdate(conn DaemonConn, holder *updater.Holder, translator ErrorTranslator, prefs LanguagePreference) *Update {
return &Update{conn: conn, holder: holder, classifier: errorClassifier{translator: translator, prefs: prefs}}
}
func (s *Update) GetState() updater.State {
@@ -52,11 +55,11 @@ func (s *Update) Quit() {
func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) {
cli, err := s.conn.Client()
if err != nil {
return UpdateResult{}, err
return UpdateResult{}, s.classifier.classify(err)
}
resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{})
if err != nil {
return UpdateResult{}, err
return UpdateResult{}, s.classifier.classify(err)
}
return UpdateResult{
Success: resp.GetSuccess(),
+12 -3
View File
@@ -269,7 +269,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
return s
}
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
// settingsWindowURL is the start URL for a settings window showing tab. The tab
// travels in the URL so the first render already has it. EventSettingsOpen
// reaches the frontend only after it reports ready, by which point a tab that
// reads the daemon config has mounted and sent its read.
func settingsWindowURL(tab string) string {
return "/#/settings?tab=" + url.QueryEscape(tab)
}
func (s *WindowManager) newSettingsWindow(tab string) *application.WebviewWindow {
a := CurrentAppearance()
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: windowSettings,
@@ -282,7 +290,7 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
BackgroundColour: WindowBackgroundColour(a),
URL: "/#/settings",
URL: settingsWindowURL(tab),
Mac: AppleMacOSAppearanceOptions(a),
Windows: MicrosoftWindowsAppearanceOptions(a),
Linux: LinuxAppearanceOptions(s.linuxIcon),
@@ -305,7 +313,8 @@ func (s *WindowManager) OpenSettings(tab string) {
target = "general"
}
s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
factory := func() *application.WebviewWindow { return s.newSettingsWindow(target) }
s.withWindow(windowSettings, &s.settings, factory, func(w *application.WebviewWindow, _ bool) {
s.mu.Lock()
ready := s.ready[w.ID()]
if !ready {
+9
View File
@@ -348,3 +348,12 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
// The settings window opens on whichever tab the caller asked for, so a tab that
// does not read the daemon configuration never mounts the one that does.
func TestSettingsWindowURLCarriesTab(t *testing.T) {
require.Equal(t, "/#/settings?tab=profiles", settingsWindowURL("profiles"))
require.Equal(t, "/#/settings?tab=general", settingsWindowURL("general"))
require.Equal(t, "/#/settings?tab=a%2Fb+c", settingsWindowURL("a/b c"),
"a tab name is escaped rather than trusted to be URL-safe")
}